Files
api/services/nginx/app/tests/Api/EdgeGatewayOperatorApiTest.php
T

481 lines
14 KiB
PHP

<?php
declare(strict_types=1);
usesApiSuite();
it('creates install tokens, tracks installer status, and exposes claimed gateway detail to authorized operators', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Edge Operator Department',
]);
$session = api_fixtures()->createEdgeOperatorSession((int)$department['id']);
$createResponse = api_client()->post('/edge-gateways/install-token', [
'department_id' => (int)$department['id'],
'label' => 'Dock 7 Gateway',
], $session['headers']);
$createResponse
->assertStatus(201)
->assertEnvelope()
->assertSuccess();
$installToken = $createResponse->data();
expect($installToken)
->toBeArray()
->toHaveKeys(['claim_token_id', 'token', 'install_command']);
$statusResponse = api_client()->get(
'/edge-gateways/install-token/' . (int)$installToken['claim_token_id'] . '/status',
$session['headers']
);
$statusResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($statusResponse->data())
->toHaveKey('status', 'PENDING')
->toHaveKey('gateway_id', null);
$verifyResponse = api_client()->get('/edge-agent/install-token/verify?token=' . urlencode((string)$installToken['token']));
$verifyResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($verifyResponse->data())
->toHaveKey('valid', true)
->toHaveKey('claim_token_id', (int)$installToken['claim_token_id']);
$runningStatus = api_client()->post('/edge-agent/install-token/status', [
'token' => (string)$installToken['token'],
'status' => 'RUNNING',
'step' => 'BOOTSTRAP',
'message' => 'Installer is downloading gateway artifacts.',
'diagnostics' => [
'download-1',
'download-2',
'download-3',
'download-4',
'download-5',
'download-6',
'download-7',
],
]);
$runningStatus
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$runningStatusDetail = api_client()->get(
'/edge-gateways/install-token/' . (int)$installToken['claim_token_id'] . '/status',
$session['headers']
);
$runningStatusDetail
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($runningStatusDetail->data())
->toHaveKey('status', 'RUNNING')
->toHaveKey('step', 'BOOTSTRAP')
->and($runningStatusDetail->data()['diagnostics'] ?? [])
->toBeArray()
->toHaveCount(6);
$claimResponse = api_client()->post('/edge-agent/claim', [
'token' => (string)$installToken['token'],
'hostname' => 'edge-operator-api',
'installed_version' => 'php-agent-v1',
'metadata' => [
'agent_runtime' => 'compose-php',
],
]);
$claimResponse
->assertStatus(201)
->assertEnvelope()
->assertSuccess();
$claimedPayload = $claimResponse->data();
$gatewayId = (int)($claimedPayload['gateway']['id'] ?? 0);
expect($claimedPayload)
->toHaveKey('agent_token')
->and($gatewayId)
->toBeGreaterThan(0)
->and($claimedPayload['gateway']['status'] ?? null)
->toBe('ONLINE');
$claimedStatus = api_client()->get(
'/edge-gateways/install-token/' . (int)$installToken['claim_token_id'] . '/status',
$session['headers']
);
$claimedStatus
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($claimedStatus->data())
->toHaveKey('status', 'CLAIMED')
->toHaveKey('gateway_id', $gatewayId)
->toHaveKey('last_error', null);
$listResponse = api_client()->get('/edge-gateways?department_id=' . (int)$department['id'], $session['headers']);
$listResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect(collect_gateway_ids_from_api_response($listResponse->data()))
->toContain($gatewayId);
$detailResponse = api_client()->get('/edge-gateways/' . $gatewayId, $session['headers']);
$detailResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($detailResponse->data())
->toHaveKey('id', $gatewayId)
->toHaveKey('department_id', (int)$department['id'])
->toHaveKey('status', 'ONLINE');
});
it('manages edge gateway metadata, bindings, operations, sessions, rotation, cutover, and deletion', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Edge Operator Control Department',
]);
$session = api_fixtures()->createEdgeOperatorSession((int)$department['id']);
$gateway = api_fixtures()->createClaimedEdgeGateway([
'department_id' => (int)$department['id'],
'label' => 'Original Gateway Label',
]);
api_fixtures()->createClaimedEdgeGateway([
'department_id' => (int)$department['id'],
'label' => 'Alternate Gateway Label',
'is_primary' => 0,
]);
$updateResponse = api_client()->put('/edge-gateways/' . (int)$gateway['id'], [
'label' => 'Renamed Gateway',
'is_primary' => false,
], $session['headers']);
$updateResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($updateResponse->data())
->toHaveKey('label', 'Renamed Gateway')
->toHaveKey('is_primary', false);
$bindingsResponse = api_client()->put('/edge-gateways/' . (int)$gateway['id'] . '/bindings', [
'bindings' => [[
'relay_id' => 'relay-main',
'device_id' => 'device-main',
'local_ip' => '10.0.0.18',
'channel' => 0,
'binding_source' => 'MANUAL',
]],
], $session['headers']);
$bindingsResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($bindingsResponse->data()['bindings'] ?? [])
->toBeArray()
->toHaveCount(1)
->and(($bindingsResponse->data()['bindings'][0]['relay_id'] ?? null))
->toBe('relay-main');
$operationResponse = api_client()->post('/edge-gateways/' . (int)$gateway['id'] . '/operations', [
'type' => 'DISCOVERY',
'request' => [
'inventory' => edge_operator_test_inventory('operator'),
],
], $session['headers']);
$operationResponse
->assertStatus(201)
->assertEnvelope()
->assertSuccess();
$operationId = (int)($operationResponse->data()['operation']['id'] ?? 0);
expect($operationId)->toBeGreaterThan(0);
$operationsList = api_client()->get('/edge-gateways/' . (int)$gateway['id'] . '/operations', $session['headers']);
$operationsList
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect(collect_gateway_ids_from_api_response($operationsList->data(), 'id'))
->toContain($operationId);
$eventsResponse = api_client()->get(
'/edge-gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/events',
$session['headers']
);
$eventsResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($eventsResponse->data())
->toBeArray()
->not->toBeEmpty()
->and($eventsResponse->data()[0]['code'] ?? null)
->toBe('OPERATION_QUEUED');
$cancelResponse = api_client()->post(
'/edge-gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/cancel',
[],
$session['headers']
);
$cancelResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($cancelResponse->data()['operation']['status'] ?? null)
->toBe('CANCELLED');
$streamSession = api_client()->post(
'/edge-gateways/' . (int)$gateway['id'] . '/stream-session',
['scopes' => ['logs', 'tasks']],
$session['headers']
);
$streamSession
->assertStatus(201)
->assertEnvelope()
->assertSuccess();
expect($streamSession->data())
->toHaveKey('token')
->toHaveKey('ws_url');
$shellSession = api_client()->post(
'/edge-gateways/' . (int)$gateway['id'] . '/shell-sessions',
[
'reason' => 'Operator smoke session',
'cwd' => '/opt/truckwash-edge-agent',
'cols' => 120,
'rows' => 40,
],
$session['headers']
);
$shellSession
->assertStatus(201)
->assertEnvelope()
->assertSuccess();
expect($shellSession->data())
->toHaveKey('token')
->toHaveKey('session')
->and($shellSession->data()['session']['reason'] ?? null)
->toBe('Operator smoke session');
$rotateResponse = api_client()->post(
'/edge-gateways/' . (int)$gateway['id'] . '/rotate-credentials',
[],
$session['headers']
);
$rotateResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($rotateResponse->data())
->toHaveKey('gateway_id', (int)$gateway['id'])
->toHaveKey('agent_token')
->toHaveKey('config_json');
$cutoverResponse = api_client()->post(
'/departments/' . (int)$department['id'] . '/gateway-cutover',
['transport_mode' => 'gateway'],
$session['headers']
);
$cutoverResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($cutoverResponse->data())
->toHaveKey('department_id', (int)$department['id'])
->toHaveKey('transport_mode', 'gateway');
$deleteResponse = api_client()->delete('/edge-gateways/' . (int)$gateway['id'], null, $session['headers']);
$deleteResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($deleteResponse->data())
->toHaveKey('deleted', true)
->toHaveKey('gateway_id', (int)$gateway['id']);
});
it('rejects operator edge routes when module permission or department access is missing', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Edge Operator Access Department',
]);
$allowed = api_fixtures()->createEdgeOperatorSession((int)$department['id']);
$token = api_client()->post('/edge-gateways/install-token', [
'department_id' => (int)$department['id'],
'label' => 'Restricted Gateway',
], $allowed['headers']);
$token
->assertStatus(201)
->assertEnvelope()
->assertSuccess();
$missingModule = api_fixtures()->createUserSession([
'department_access_' . (int)$department['id'],
]);
$missingModuleResponse = api_client()->get(
'/edge-gateways?department_id=' . (int)$department['id'],
$missingModule['headers']
);
$missingModuleResponse
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['modules_shelly_config']);
$missingDepartment = api_fixtures()->createUserSession(['modules_shelly_config']);
$missingDepartmentResponse = api_client()->get(
'/edge-gateways/install-token/' . (int)$token->data()['claim_token_id'] . '/status',
$missingDepartment['headers']
);
$missingDepartmentResponse
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$department['id']]);
});
it('ignores and soft-deletes edge gateways whose department no longer exists', function (): void {
$session = api_fixtures()->createUserSession(['modules_shelly_config']);
$missingDepartmentId = 2147483000;
$listOrphan = api_fixtures()->createClaimedEdgeGateway([
'department_id' => $missingDepartmentId,
'label' => 'Orphaned Fleet Gateway',
]);
api_fixtures()->clearEdgeGatewayViewCache();
$listResponse = api_client()->get('/edge-gateways', $session['headers']);
$listResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect(collect_gateway_ids_from_api_response($listResponse->data()))
->not->toContain((int)$listOrphan['id'])
->and(api_fixtures()->fetchRowById('edge_gateways', (int)$listOrphan['id'])['deleted_at'] ?? null)
->not->toBeNull();
$workspaceMissingDepartmentId = $missingDepartmentId - 1;
$workspaceOrphan = api_fixtures()->createClaimedEdgeGateway([
'department_id' => $workspaceMissingDepartmentId,
'label' => 'Orphaned Workspace Gateway',
]);
api_fixtures()->clearEdgeGatewayViewCache();
$redis = api_test_runtime()->redis();
expect($redis)->not->toBeNull();
$redis->set('departments', json_encode([[
'id' => $workspaceMissingDepartmentId,
'name' => 'Stale Department Cache',
'description' => 'This department row is no longer in the database.',
'order_priority' => 0,
'visible' => 1,
]], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
try {
$workspaceResponse = api_client()->get(
'/modules/edge-gateways/workspace/departments',
$session['headers']
);
} finally {
$redis->del(['departments']);
}
$workspaceResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($workspaceResponse->data())
->toBeArray()
->and(collect_gateway_ids_from_api_response($workspaceResponse->data()))
->not->toContain((int)$workspaceOrphan['id'])
->and(api_fixtures()->fetchRowById('edge_gateways', (int)$workspaceOrphan['id'])['deleted_at'] ?? null)
->not->toBeNull();
});
function edge_operator_test_inventory(string $suffix): array
{
return [[
'device_id' => 'gateway-' . $suffix,
'local_ip' => '10.20.30.40',
'model' => 'TruckWash Edge Test',
'channel_count' => 2,
'online' => true,
'capabilities' => [
'local_discovery' => true,
'relay_commands' => true,
],
'metadata' => [
'hostname' => 'edge-' . $suffix,
],
]];
}
/**
* @param mixed $items
* @return array<int, int>
*/
function collect_gateway_ids_from_api_response(mixed $items, string $key = 'id'): array
{
if (!is_array($items)) {
return [];
}
$ids = [];
foreach ($items as $item) {
if (is_array($item) && isset($item[$key]) && is_numeric($item[$key])) {
$ids[] = (int)$item[$key];
}
}
return $ids;
}