createDepartment([ 'name' => 'Edge Agent Department', ]); $session = api_fixtures()->createEdgeOperatorSession((int)$department['id']); $installTokenResponse = api_client()->post('/edge-gateways/install-token', [ 'department_id' => (int)$department['id'], 'label' => 'Edge Agent Install', ], $session['headers']); $installTokenResponse ->assertStatus(201) ->assertEnvelope() ->assertSuccess(); $installToken = $installTokenResponse->data(); $installScript = api_client()->get('/edge-agent/install.sh?token=' . urlencode((string)$installToken['token'])); expect($installScript->status)->toBe(200) ->and($installScript->body) ->toContain('/edge-agent/install-token/status') ->toContain('agent.php') ->toContain((string)$installToken['token']); $artifact = api_client()->get('/edge-agent/artifacts/agent.php'); expect($artifact->status)->toBe(200) ->and($artifact->body) ->toContain('post('/edge-agent/claim', [ 'token' => (string)$installToken['token'], 'hostname' => 'edge-agent-api', 'installed_version' => 'php-agent-v1', ]); $claimResponse ->assertStatus(201) ->assertEnvelope() ->assertSuccess(); $gatewayId = (int)($claimResponse->data()['gateway']['id'] ?? 0); $agentToken = (string)($claimResponse->data()['agent_token'] ?? ''); expect($gatewayId)->toBeGreaterThan(0) ->and($agentToken)->not->toBe(''); edge_agent_test_set_heartbeat_age($gatewayId, edge_gateway_manager::HEARTBEAT_DEGRADED_AFTER_SECONDS + 1); $degradedDetail = api_client()->get('/edge-gateways/' . $gatewayId, $session['headers']); $degradedDetail ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); expect($degradedDetail->data()) ->toHaveKey('status', 'DEGRADED'); edge_agent_test_set_heartbeat_age($gatewayId, edge_gateway_manager::HEARTBEAT_OFFLINE_AFTER_SECONDS + 1); $offlineDetail = api_client()->get('/edge-gateways/' . $gatewayId, $session['headers']); $offlineDetail ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); expect($offlineDetail->data()) ->toHaveKey('status', 'OFFLINE'); $heartbeatResponse = api_client()->post('/edge-agent/gateways/' . $gatewayId . '/heartbeat', [ 'agent_token' => $agentToken, 'status' => 'ONLINE', 'hostname' => 'edge-agent-api', 'metadata' => [ 'agent_instance_id' => 'edge-agent-api-test', 'broker_connected' => true, 'broker_url' => 'wss://broker.example.test/edge-broker', 'broker_last_connected_at' => '2026-04-08T10:05:00+00:00', 'system_metrics' => [ 'cpu_percent' => 21, 'memory_mb' => 128, ], ], 'inventory' => edge_agent_test_inventory('heartbeat'), ]); $heartbeatResponse ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); expect($heartbeatResponse->data()) ->toHaveKey('status', 'ONLINE') ->and($heartbeatResponse->data()['broker_url'] ?? null) ->toBeString() ->toContain('/edge-broker'); $recoveredDetail = api_client()->get('/edge-gateways/' . $gatewayId, $session['headers']); $recoveredDetail ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); expect($recoveredDetail->data()) ->toHaveKey('status', 'ONLINE') ->and($recoveredDetail->data()['metadata']['system_metrics']['cpu_percent'] ?? null) ->toBe(21) ->and($recoveredDetail->data()['metadata']['broker_presence']['connected'] ?? null) ->toBeTrue() ->and($recoveredDetail->data()['channel_status']['broker']['connected'] ?? null) ->toBeTrue() ->and($recoveredDetail->data()['channel_status']['command']['preferred'] ?? null) ->toBe(edge_gateway_manager::DELIVERY_CHANNEL_BROKER); }); it('polls operations and commands, submits results, and records broker presence for task pages', function (): void { $department = api_fixtures()->createDepartment([ 'name' => 'Edge Agent Operations Department', ]); $session = api_fixtures()->createEdgeOperatorSession((int)$department['id']); $gateway = api_fixtures()->createClaimedEdgeGateway([ 'department_id' => (int)$department['id'], 'label' => 'Edge Agent Runtime', ]); $operationResponse = api_client()->post('/edge-gateways/' . (int)$gateway['id'] . '/operations', [ 'type' => 'DISCOVERY', 'request' => [ 'inventory' => edge_agent_test_inventory('operation'), ], ], $session['headers']); $operationResponse ->assertStatus(201) ->assertEnvelope() ->assertSuccess(); $operationId = (int)($operationResponse->data()['operation']['id'] ?? 0); expect($operationId)->toBeGreaterThan(0); $operationLease = api_client()->post('/edge-agent/gateways/' . (int)$gateway['id'] . '/operations/next', [ 'agent_token' => (string)$gateway['agent_token'], 'wait_seconds' => 0, 'agent_instance_id' => 'edge-agent-api-test', ]); $operationLease ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); expect($operationLease->data()) ->toHaveKey('id', $operationId) ->toHaveKey('status', 'IN_PROGRESS'); $eventResponse = api_client()->post( '/edge-agent/gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/events', [ 'agent_token' => (string)$gateway['agent_token'], 'level' => 'INFO', 'code' => 'DISCOVERY_RUNNING', 'message' => 'Discovery is running through the agent API.', 'context' => [ 'progress' => 55, 'label' => 'Discovery running', ], ] ); $eventResponse ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); $completeResponse = api_client()->post( '/edge-agent/gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/complete', [ 'agent_token' => (string)$gateway['agent_token'], 'ok' => true, 'result' => [ 'inventory' => edge_agent_test_inventory('completed'), ], ] ); $completeResponse ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); expect($completeResponse->data()) ->toHaveKey('status', 'COMPLETED'); $job = api_fixtures()->createEdgeCommandJob([ 'gateway_id' => (int)$gateway['id'], 'command_type' => 'GET_RELAY_STATUS', 'request' => [ 'relayId' => 'relay-main', 'localIp' => '10.0.0.18', ], ]); $commandPoll = api_client()->post('/edge-agent/gateways/' . (int)$gateway['id'] . '/commands/poll', [ 'agent_token' => (string)$gateway['agent_token'], 'wait_seconds' => 0, ]); $commandPoll ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); expect($commandPoll->data()) ->toHaveKey('id', (int)$job['id']) ->toHaveKey('command_type', 'GET_RELAY_STATUS'); $commandResult = api_client()->post( '/edge-agent/gateways/' . (int)$gateway['id'] . '/commands/' . (int)$job['id'] . '/result', [ 'agent_token' => (string)$gateway['agent_token'], 'ok' => true, 'result' => [ 'relayId' => 'relay-main', 'online' => true, ], ] ); $commandResult ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); expect($commandResult->data()) ->toHaveKey('acknowledged', true) ->and($commandResult->data()['job']['status'] ?? null) ->toBe('COMPLETED'); $presenceResponse = api_client()->post('/edge-agent/gateways/' . (int)$gateway['id'] . '/presence', [ 'agent_token' => (string)$gateway['agent_token'], 'status' => 'connected', 'connection_id' => 'broker-connection-1', 'metadata' => [ 'transport' => 'ws', ], ]); $presenceResponse ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); expect($presenceResponse->data()) ->toHaveKey('connected', true); $tasksPage = api_client()->get('/edge-gateways/' . (int)$gateway['id'] . '/tasks', $session['headers']); $tasksPage ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); expect($tasksPage->data()['operations'] ?? []) ->toBeArray() ->not->toBeEmpty() ->and($tasksPage->data()['recent_commands'] ?? []) ->toBeArray() ->not->toBeEmpty(); expect($tasksPage->data()['recent_operations_summary']['completed'] ?? 0) ->toBeGreaterThanOrEqual(1); }); it('rejects missing and invalid edge agent tokens', function (): void { $department = api_fixtures()->createDepartment([ 'name' => 'Edge Agent Auth Department', ]); $gateway = api_fixtures()->createClaimedEdgeGateway([ 'department_id' => (int)$department['id'], ]); $missingToken = api_client()->get('/edge-agent/install-token/verify'); $missingToken ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('Missing token'); $missingAgentToken = api_client()->post('/edge-agent/gateways/' . (int)$gateway['id'] . '/heartbeat', [ 'status' => 'ONLINE', ]); $missingAgentToken ->assertStatus(401) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('Missing edge gateway agent token'); $invalidAgentToken = api_client()->post('/edge-agent/gateways/' . (int)$gateway['id'] . '/operations/next', [ 'agent_token' => 'invalid-token', 'wait_seconds' => 0, ]); $invalidAgentToken ->assertStatus(401) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('Invalid edge gateway token'); }); function edge_agent_test_set_heartbeat_age(int $gatewayId, int $secondsAgo): void { $db = api_test_runtime()->db(); $row = api_fixtures()->fetchRowById('edge_gateways', $gatewayId) ?? []; $referenceTimestamp = strtotime((string)($row['last_heartbeat_at'] ?? $row['updated_at'] ?? $row['created_at'] ?? '')); if ($referenceTimestamp === false || $referenceTimestamp <= 0) { $referenceTimestamp = time(); } $timestamp = date('Y-m-d H:i:s', $referenceTimestamp - max(0, $secondsAgo)); $escapedTimestamp = $db->real_escape_string($timestamp); $db->query("UPDATE edge_gateways SET last_heartbeat_at = '{$escapedTimestamp}', status = 'ONLINE' WHERE id = " . (int)$gatewayId); $redis = api_test_runtime()->redis(); if ($redis !== null) { foreach ([ 'obj_prop:*:' . $gatewayId . ':status', 'obj_prop:*:' . $gatewayId . ':last_heartbeat_at', 'obj_prop:*:' . $gatewayId . ':updated_at', ] as $pattern) { $keys = $redis->keys($pattern); if (is_array($keys) && $keys !== []) { $redis->del($keys); } } } api_fixtures()->clearEdgeGatewayViewCache(); } function edge_agent_test_inventory(string $suffix): array { return [[ 'device_id' => 'agent-' . $suffix, 'local_ip' => '10.30.40.50', 'model' => 'TruckWash Edge Agent', 'channel_count' => 1, 'online' => true, 'capabilities' => [ 'gateway_management_v2' => true, ], 'metadata' => [ 'hostname' => 'edge-' . $suffix, ], ]]; }