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']]); }); 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 */ 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; }