Files
api/services/nginx/app/tests/Api/EdgeGatewayBrokerApiTest.php
T
Jeppe Bundgaard 2aded0812a Add new tests for shell bridge and broker to handle structured failures and invalid session handling
- Add tests for shell bridge to validate structured error reporting on spawn failures.

- Add broker tests to ensure proper rejection of malformed browser shell upgrades without leaking sensitive tokens.

- Update `.env.example` with `EDGE_PUBLIC_BROKER_URL` for public access configuration.
2026-04-28 09:00:12 +02:00

439 lines
13 KiB
PHP

<?php
declare(strict_types=1);
usesApiSuite();
it('validates broker sessions and ingests presence, telemetry, logs, and shell lifecycle data', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Edge Broker Department',
]);
$session = api_fixtures()->createEdgeOperatorSession((int)$department['id']);
$gateway = api_fixtures()->createClaimedEdgeGateway([
'department_id' => (int)$department['id'],
'label' => 'Broker Gateway',
]);
$validateGateway = api_client()->post(
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/validate',
['token' => (string)$gateway['agent_token']],
edge_test_broker_headers()
);
$validateGateway
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($validateGateway->data())
->toHaveKey('gateway_id', (int)$gateway['id'])
->toHaveKey('department_id', (int)$department['id']);
$presence = api_client()->post(
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/presence',
[
'status' => 'connected',
'connection_id' => 'broker-presence-1',
'metadata' => [
'transport' => 'ws',
],
],
edge_test_broker_headers()
);
$presence
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($presence->data())
->toHaveKey('connected', true)
->toHaveKey('connection_id', 'broker-presence-1');
$telemetry = api_client()->post(
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/telemetry',
[
'status' => 'ONLINE',
'metadata' => [
'system_metrics' => [
'cpu_load' => 0.42,
'memory_mb' => 512,
],
],
'inventory' => edge_broker_test_inventory('telemetry'),
],
edge_test_broker_headers()
);
$telemetry
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($telemetry->data()['metadata']['system_metrics']['cpu_load'] ?? null)
->toBe(0.42);
$logEntry = api_client()->post(
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/logs',
[
'level' => 'INFO',
'stream' => 'agent',
'source' => 'BROKER',
'message' => 'Broker forwarded a live gateway log.',
'context' => [
'source' => 'broker-test',
],
],
edge_test_broker_headers()
);
$logEntry
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($logEntry->data())
->toHaveKey('message', 'Broker forwarded a live gateway log.');
$streamSession = api_client()->post(
'/edge-gateways/' . (int)$gateway['id'] . '/stream-session',
['scopes' => ['logs', 'statistics', 'tasks']],
$session['headers']
);
$streamSession
->assertStatus(201)
->assertEnvelope()
->assertSuccess();
$streamValidation = api_client()->post(
'/edge-agent/internal/browser-streams/validate',
['token' => (string)$streamSession->data()['token']],
edge_test_broker_headers()
);
$streamValidation
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($streamValidation->data())
->toHaveKey('session_type', 'gateway-stream')
->toHaveKey('gateway_id', (int)$gateway['id']);
$shellSession = api_client()->post(
'/edge-gateways/' . (int)$gateway['id'] . '/shell-sessions',
['reason' => 'Broker shell validation'],
$session['headers']
);
$shellSession
->assertStatus(201)
->assertEnvelope()
->assertSuccess();
expect($shellSession->data()['diagnostics'] ?? [])
->toHaveKey('ready', true)
->toHaveKey('reason_code', 'READY')
->and($shellSession->data()['diagnostics']['broker_presence']['connection_id'] ?? null)
->toBe('broker-presence-1');
$shellToken = (string)$shellSession->data()['token'];
$validateShell = api_client()->post(
'/edge-agent/internal/shell-sessions/validate',
['token' => $shellToken],
edge_test_broker_headers()
);
$validateShell
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($validateShell->data())
->toHaveKey('status', 'PENDING');
$openedShell = api_client()->post(
'/edge-agent/internal/shell-sessions/opened',
[
'token' => $shellToken,
'connection_id' => 'shell-connection-1',
],
edge_test_broker_headers()
);
$openedShell
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($openedShell->data())
->toHaveKey('status', 'OPEN')
->toHaveKey('connection_id', 'shell-connection-1');
$closedShell = api_client()->post(
'/edge-agent/internal/shell-sessions/close',
[
'token' => $shellToken,
'transcript' => "edge-broker-shell\n",
'reason' => 'agent_exit',
'message' => 'Shell exited cleanly.',
'code' => 0,
'stage' => 'shell_active',
'ws_url' => 'wss://broker.example.test/ws/browser-shell?token=raw-shell-token',
],
edge_test_broker_headers()
);
$closedShell
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($closedShell->data())
->toHaveKey('status', 'COMPLETED')
->and($closedShell->data()['transcript'] ?? null)
->toBe("edge-broker-shell\n")
->and($closedShell->data()['metadata']['close_reason'] ?? null)
->toBe('agent_exit')
->and($closedShell->data()['metadata']['close_message'] ?? null)
->toBe('Shell exited cleanly.')
->and($closedShell->data()['metadata']['close_code'] ?? null)
->toBe(0)
->and($closedShell->data()['metadata']['close_stage'] ?? null)
->toBe('shell_active')
->and($closedShell->data()['metadata']['close_diagnostics']['ws_url'] ?? null)
->toBe('wss://broker.example.test/ws/browser-shell?token=***');
$logsPage = api_client()->get('/edge-gateways/' . (int)$gateway['id'] . '/logs', $session['headers']);
$logsPage
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect(collect_gateway_messages($logsPage->data()['log_entries'] ?? []))
->toContain('Broker forwarded a live gateway log.');
expect(collect_gateway_messages($logsPage->data()['timeline'] ?? []))
->toContain('GATEWAY_SHELL_SESSION_OPENED')
->toContain('GATEWAY_SHELL_SESSION_CLOSED');
expect($logsPage->data()['shell_sessions'][0]['transcript'] ?? null)
->toBe("edge-broker-shell\n");
$statisticsPage = api_client()->get('/edge-gateways/' . (int)$gateway['id'] . '/statistics', $session['headers']);
$statisticsPage
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($statisticsPage->data())
->toHaveKey('system_metrics')
->and($statisticsPage->data()['system_metrics']['cpu_load'] ?? null)
->toBe(0.42);
});
it('rejects shell session creation while broker presence is unavailable', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Edge Broker Shell Readiness Department',
]);
$session = api_fixtures()->createEdgeOperatorSession((int)$department['id']);
$gateway = api_fixtures()->createClaimedEdgeGateway([
'department_id' => (int)$department['id'],
'label' => 'Disconnected Broker Gateway',
]);
$response = api_client()->post(
'/edge-gateways/' . (int)$gateway['id'] . '/shell-sessions',
['reason' => 'Should fail without broker presence'],
$session['headers']
);
$response
->assertStatus(409)
->assertEnvelope()
->assertSuccess(false);
expect($response->data())
->toHaveKey('error_code', 'BROKER_DISCONNECTED')
->and($response->data()['diagnostics']['ready'] ?? true)
->toBeFalse()
->and($response->data()['diagnostics']['broker_presence']['connected'] ?? true)
->toBeFalse();
});
it('builds broker backlog and completes gateway operations through broker endpoints', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Edge Broker Backlog Department',
]);
$session = api_fixtures()->createEdgeOperatorSession((int)$department['id']);
$gateway = api_fixtures()->createClaimedEdgeGateway([
'department_id' => (int)$department['id'],
'label' => 'Broker Backlog Gateway',
]);
$queuedOperation = api_client()->post('/edge-gateways/' . (int)$gateway['id'] . '/operations', [
'type' => 'DISCOVERY',
'request' => [
'inventory' => edge_broker_test_inventory('backlog'),
],
], $session['headers']);
$queuedOperation
->assertStatus(201)
->assertEnvelope()
->assertSuccess();
$operationId = (int)($queuedOperation->data()['operation']['id'] ?? 0);
expect($operationId)->toBeGreaterThan(0);
$backlog = api_client()->post(
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/backlog',
['agent_instance_id' => 'broker-agent-1'],
edge_test_broker_headers()
);
$backlog
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($backlog->data()['dispatch'] ?? [])
->toBeArray()
->not->toBeEmpty()
->and($backlog->data()['dispatch'][0]['type'] ?? null)
->toBe('TASK_DISPATCH')
->and((int)($backlog->data()['dispatch'][0]['operation']['id'] ?? 0))
->toBe($operationId);
$event = api_client()->post(
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/events',
[
'level' => 'INFO',
'code' => 'BROKER_EXECUTING',
'message' => 'Broker is executing the operation.',
'context' => [
'progress' => 50,
],
],
edge_test_broker_headers()
);
$event
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$complete = api_client()->post(
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/complete',
[
'ok' => true,
'result' => [
'inventory' => edge_broker_test_inventory('completed'),
],
],
edge_test_broker_headers()
);
$complete
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($complete->data())
->toHaveKey('status', 'COMPLETED');
$operations = api_client()->get('/edge-gateways/' . (int)$gateway['id'] . '/operations', $session['headers']);
$operations
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect(($operations->data()[0]['status'] ?? null))
->toBe('COMPLETED');
$events = api_client()->get(
'/edge-gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/events',
$session['headers']
);
$events
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect(collect_gateway_messages($events->data()))
->toContain('Broker is executing the operation.')
->toContain('Operation completed successfully');
$tasksPage = api_client()->get('/edge-gateways/' . (int)$gateway['id'] . '/tasks', $session['headers']);
$tasksPage
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($tasksPage->data()['recent_operations_summary']['completed'] ?? 0)
->toBeGreaterThanOrEqual(1);
});
it('rejects invalid edge broker shared secrets', function (): void {
$department = api_fixtures()->createDepartment([
'name' => 'Edge Broker Forbidden Department',
]);
$gateway = api_fixtures()->createClaimedEdgeGateway([
'department_id' => (int)$department['id'],
]);
$response = api_client()->post(
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/presence',
['status' => 'connected'],
['X-Edge-Broker-Secret' => 'wrong-secret']
);
$response
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Invalid edge broker secret');
});
function edge_broker_test_inventory(string $suffix): array
{
return [[
'device_id' => 'broker-' . $suffix,
'local_ip' => '10.40.50.60',
'model' => 'TruckWash Edge Broker',
'channel_count' => 1,
'online' => true,
'capabilities' => [
'relay_commands' => true,
],
'metadata' => [
'hostname' => 'broker-' . $suffix,
],
]];
}
/**
* @param mixed $items
* @return array<int, string>
*/
function collect_gateway_messages(mixed $items): array
{
if (!is_array($items)) {
return [];
}
$messages = [];
foreach ($items as $item) {
if (is_array($item) && isset($item['message']) && is_string($item['message'])) {
$messages[] = $item['message'];
}
}
return $messages;
}