Files
api/services/nginx/app/tests/Integration/EdgeGateway/EdgeGatewayBackendIntegrationTest.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

510 lines
19 KiB
PHP

<?php
declare(strict_types=1);
use classes\db;
use classes\edge_gateway_manager;
use classes\edge_gateway_operation_service;
use Predis\Client as PredisClient;
use Tests\Support\Api\ApiCleanup;
use Tests\Support\Api\ApiFixtures;
use Tests\Support\Api\ApiSchemaBootstrap;
require_once dirname(__DIR__, 2) . '/Support/Api/ApiCleanup.php';
require_once dirname(__DIR__, 2) . '/Support/Api/ApiFixtures.php';
require_once dirname(__DIR__, 2) . '/Support/Api/ApiSchemaBootstrap.php';
app_require('classes/db.php');
app_require('classes/edge_gateway_manager.php');
app_require('classes/edge_gateway_operation_service.php');
it('persists install-session updates and derives gateway runtime status from heartbeats', function (): void {
$context = edge_gateway_integration_context();
try {
$department = $context['fixtures']->createDepartment([
'name' => 'Edge Integration Install Department',
]);
$token = $context['manager']->createInstallToken((int)$department['id'], 'Integration Install Token');
$claimTokenId = (int)($token['claim_token_id'] ?? 0);
$context['manager']->reportInstallTokenStatus((string)$token['token'], [
'status' => 'FAILED',
'step' => 'DOWNLOAD_FAILED',
'message' => 'The installer could not download the runtime bundle.',
'diagnostics' => [
'diag-1',
'diag-2',
'diag-3',
'diag-4',
'diag-5',
'diag-6',
'diag-7',
'diag-8',
],
]);
$failedStatus = $context['manager']->getInstallTokenStatus($claimTokenId);
expect($failedStatus)
->toHaveKey('status', 'FAILED')
->toHaveKey('step', 'DOWNLOAD_FAILED')
->toHaveKey('last_error', 'The installer could not download the runtime bundle.')
->and($failedStatus['diagnostics'] ?? [])
->toBeArray()
->toHaveCount(6);
$claimed = $context['manager']->claimGateway((string)$token['token'], 'edge-integration-host', 'php-agent-v1', [
'source' => 'integration-test',
]);
$gatewayId = (int)($claimed['gateway']['id'] ?? 0);
$agentToken = (string)($claimed['agent_token'] ?? '');
expect($gatewayId)->toBeGreaterThan(0)
->and($agentToken)->not->toBe('');
$claimedStatus = $context['manager']->getInstallTokenStatus($claimTokenId);
expect($claimedStatus)
->toHaveKey('status', 'CLAIMED')
->toHaveKey('gateway_id', $gatewayId)
->toHaveKey('last_error', null);
edge_gateway_integration_set_heartbeat_age($context['mysqli'], $context['redis'], $gatewayId, edge_gateway_manager::HEARTBEAT_DEGRADED_AFTER_SECONDS + 1);
$degraded = $context['manager']->getGateway($gatewayId);
expect($degraded)->toHaveKey('status', 'DEGRADED');
edge_gateway_integration_set_heartbeat_age($context['mysqli'], $context['redis'], $gatewayId, edge_gateway_manager::HEARTBEAT_OFFLINE_AFTER_SECONDS + 1);
$offline = $context['manager']->getGateway($gatewayId);
expect($offline)->toHaveKey('status', 'OFFLINE');
$context['manager']->recordHeartbeat($gatewayId, $agentToken, [
'status' => 'ONLINE',
'hostname' => 'edge-integration-host',
'metadata' => [
'system_metrics' => [
'cpu_percent' => 44,
],
],
]);
$online = $context['manager']->getGateway($gatewayId);
expect($online)
->toHaveKey('status', 'ONLINE')
->and($online['metadata']['system_metrics']['cpu_percent'] ?? null)
->toBe(44);
} finally {
$context['cleanup']->run();
}
});
it('assembles tasks, logs, statistics, operations, commands, and shell lifecycle state from persisted records', function (): void {
$context = edge_gateway_integration_context();
try {
$department = $context['fixtures']->createDepartment([
'name' => 'Edge Integration Runtime Department',
]);
$user = $context['fixtures']->createUser([
'display_name' => 'Edge Integration User',
]);
$token = $context['manager']->createInstallToken((int)$department['id'], 'Integration Runtime Token', (int)$user['id']);
$claimed = $context['manager']->claimGateway((string)$token['token'], 'edge-runtime-host', 'php-agent-v1');
$gatewayId = (int)($claimed['gateway']['id'] ?? 0);
$agentToken = (string)($claimed['agent_token'] ?? '');
$operation = $context['operations']->queueOperation(
$gatewayId,
edge_gateway_operation_service::TYPE_DISCOVERY,
['inventory' => edge_gateway_integration_inventory('runtime')],
(int)$user['id']
);
$operationId = (int)($operation['id'] ?? 0);
expect($operationId)->toBeGreaterThan(0);
$claimedOperation = $context['operations']->claimNextOperation($gatewayId, $agentToken, 0, 'integration-agent-1');
expect($claimedOperation)
->toBeArray()
->toHaveKey('status', 'IN_PROGRESS');
$context['operations']->appendAgentOperationEvent($gatewayId, $operationId, $agentToken, [
'level' => 'INFO',
'code' => 'DISCOVERY_RUNNING',
'message' => 'Integration discovery is executing.',
'context' => [
'progress' => 70,
],
]);
$context['operations']->completeAgentOperation($gatewayId, $operationId, $agentToken, [
'ok' => true,
'result' => [
'inventory' => edge_gateway_integration_inventory('completed'),
],
]);
$job = $context['fixtures']->createEdgeCommandJob([
'gateway_id' => $gatewayId,
'command_type' => 'GET_RELAY_STATUS',
'request' => [
'relayId' => 'relay-main',
],
'requested_by' => (int)$user['id'],
]);
$jobId = (int)($job['id'] ?? 0);
$polledCommand = $context['manager']->pollCommand($gatewayId, $agentToken, 0);
expect($polledCommand)
->toBeArray()
->toHaveKey('id', $jobId)
->toHaveKey('command_type', 'GET_RELAY_STATUS');
$commandResult = $context['manager']->submitCommandResult($gatewayId, $jobId, $agentToken, true, [
'relayId' => 'relay-main',
'online' => true,
]);
expect($commandResult)
->toHaveKey('acknowledged', true)
->and($commandResult['job']['status'] ?? null)
->toBe('COMPLETED');
$context['manager']->recordBrokerPresence(
$gatewayId,
'connected',
'broker-connection-1',
null,
['transport' => 'ws']
);
$shellSession = $context['manager']->createShellSession(
$gatewayId,
(int)$user['id'],
'Integration shell session',
120,
40,
'/opt/truckwash-edge-agent'
);
$shellToken = (string)($shellSession['token'] ?? '');
expect($shellToken)->not->toBe('');
expect($shellSession['diagnostics']['broker_presence']['connection_id'] ?? null)
->toBe('broker-connection-1');
$validatedShell = $context['manager']->validateShellSessionToken($shellToken);
expect($validatedShell)->toHaveKey('status', 'PENDING');
$openedShell = $context['manager']->markShellSessionOpened($shellToken, 'shell-connection-1');
expect($openedShell)->toHaveKey('status', 'OPEN');
$closedShell = $context['manager']->closeShellSessionByToken(
$shellToken,
"edge-shell-output\n",
'agent_exit',
[
'message' => 'Integration shell completed.',
'code' => 0,
'stage' => 'shell_active',
]
);
expect($closedShell)
->toHaveKey('status', 'COMPLETED')
->and($closedShell['transcript'] ?? null)
->toBe("edge-shell-output\n")
->and($closedShell['metadata']['close_reason'] ?? null)
->toBe('agent_exit')
->and($closedShell['metadata']['close_stage'] ?? null)
->toBe('shell_active');
$context['manager']->appendGatewayLogEntry(
$gatewayId,
'Integration log line',
'INFO',
'agent',
'BROKER',
['source' => 'integration']
);
$context['manager']->recordTelemetryFromBroker($gatewayId, [
'status' => 'ONLINE',
'metadata' => [
'system_metrics' => [
'cpu_percent' => 17,
'memory_mb' => 256,
],
],
'inventory' => edge_gateway_integration_inventory('telemetry'),
]);
$tasks = $context['manager']->buildGatewayTasksPage($gatewayId);
$logs = $context['manager']->buildGatewayLogsPage($gatewayId);
$statistics = $context['manager']->buildGatewayStatisticsPage($gatewayId);
expect($tasks['operations'] ?? [])
->toBeArray()
->not->toBeEmpty()
->and(($tasks['operations'][0]['status'] ?? null))
->toBe('COMPLETED');
expect($tasks['recent_commands'] ?? [])
->toBeArray()
->not->toBeEmpty()
->and(($tasks['recent_commands'][0]['status'] ?? null))
->toBe('COMPLETED');
expect(edge_gateway_integration_messages($logs['log_entries'] ?? []))
->toContain('Integration log line');
expect(edge_gateway_integration_messages($logs['timeline'] ?? []))
->toContain('Integration discovery is executing.');
expect($logs['shell_sessions'] ?? [])
->toBeArray()
->not->toBeEmpty()
->and(($logs['shell_sessions'][0]['transcript'] ?? null))
->toBe("edge-shell-output\n");
expect($statistics['system_metrics']['cpu_percent'] ?? null)
->toBe(17);
expect($statistics['gateway']['inventory'] ?? [])
->toBeArray()
->not->toBeEmpty();
} finally {
$context['cleanup']->run();
}
});
it('persists gateway cutover relay bindings used by self-serve Shelly dispatch', function (): void {
$context = edge_gateway_integration_context();
try {
$department = $context['fixtures']->createDepartment([
'name' => 'Edge Integration Self Serve Relay Department',
]);
$user = $context['fixtures']->createUser([
'display_name' => 'Edge Integration Relay User',
]);
$token = $context['manager']->createInstallToken((int)$department['id'], 'Integration Relay Token', (int)$user['id']);
$claimed = $context['manager']->claimGateway((string)$token['token'], 'edge-relay-host', 'php-agent-v1');
$gatewayId = (int)($claimed['gateway']['id'] ?? 0);
$mode = $context['manager']->setDepartmentTransportMode(
(int)$department['id'],
edge_gateway_manager::TRANSPORT_MODE_GATEWAY,
(int)$user['id']
);
$bindings = $context['manager']->setRelayBindings($gatewayId, [[
'relay_id' => 'relay-machine',
'device_id' => 'device-machine',
'local_ip' => '10.50.60.70',
'channel' => 0,
'metadata' => [
'fallback_mode' => edge_gateway_manager::RELAY_FALLBACK_PREFER_LOCAL,
],
]], (int)$user['id']);
$resolved = $context['manager']->resolveRelayBinding((int)$department['id'], 'relay-machine');
expect($mode)->toHaveKey('transport_mode', edge_gateway_manager::TRANSPORT_MODE_GATEWAY)
->and($bindings)->toHaveCount(1)
->and($resolved)->toMatchArray([
'gateway_id' => $gatewayId,
'department_id' => (int)$department['id'],
'relay_id' => 'relay-machine',
'device_id' => 'device-machine',
'local_ip' => '10.50.60.70',
'channel' => 0,
'fallback_mode' => edge_gateway_manager::RELAY_FALLBACK_PREFER_LOCAL,
])
->and((array)($resolved['metadata'] ?? []))
->toHaveKey('fallback_mode', edge_gateway_manager::RELAY_FALLBACK_PREFER_LOCAL)
->and($context['manager']->getDepartmentTransportMode((int)$department['id']))
->toBe(edge_gateway_manager::TRANSPORT_MODE_GATEWAY);
} finally {
$context['cleanup']->run();
}
});
/**
* @return array{cleanup:ApiCleanup,fixtures:ApiFixtures,manager:edge_gateway_manager,operations:edge_gateway_operation_service,mysqli:mysqli,redis:?PredisClient}
*/
function edge_gateway_integration_context(): array
{
if (!integration_enabled()) {
test()->markTestSkipped('Set RUN_INTEGRATION_TESTS=1 to run edge gateway integration tests.');
}
static $bootstrapped = null;
if ($bootstrapped === null) {
$dbConfig = edge_gateway_integration_db_config();
$GLOBALS['CONFIG_DB'] = $dbConfig;
$GLOBALS['response'] = new class {
public function internal_server_error(string $message): void
{
throw new RuntimeException($message);
}
};
$db = new db($dbConfig);
$db->connect();
$GLOBALS['db'] = $db;
$mysqli = $db->conn();
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
$mysqli->set_charset('utf8mb4');
(new ApiSchemaBootstrap($mysqli))->ensureSchema();
$bootstrapped = [
'mysqli' => $mysqli,
'redis' => edge_gateway_integration_redis_client(),
];
}
$cleanup = new ApiCleanup();
return [
'cleanup' => $cleanup,
'fixtures' => new ApiFixtures($bootstrapped['mysqli'], $bootstrapped['redis'], $cleanup),
'manager' => new edge_gateway_manager(),
'operations' => new edge_gateway_operation_service(),
'mysqli' => $bootstrapped['mysqli'],
'redis' => $bootstrapped['redis'],
];
}
/**
* @return array{host:string,user:string,password:string,database:string,port:int}
*/
function edge_gateway_integration_db_config(): array
{
$target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live')));
if ($target !== 'debug') {
$target = 'live';
}
$host = edge_gateway_integration_config_value('CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', $target);
$user = edge_gateway_integration_config_value('CONFIG_DB_USER', 'CONFIG_DB_DEBUG_USER', $target);
$password = edge_gateway_integration_config_value('CONFIG_DB_PASSWORD', 'CONFIG_DB_DEBUG_PASSWORD', $target);
$database = edge_gateway_integration_config_value('CONFIG_DB_DATABASE', 'CONFIG_DB_DEBUG_DATABASE', $target);
$port = (int)(edge_gateway_integration_config_value('CONFIG_DB_PORT', 'CONFIG_DB_DEBUG_PORT', $target) ?: '3306');
if ($host === '' || $user === '' || $database === '') {
test()->markTestSkipped('Edge gateway integration tests require configured database environment variables.');
}
return [
'host' => $host,
'user' => $user,
'password' => $password,
'database' => $database,
'port' => $port > 0 ? $port : 3306,
];
}
function edge_gateway_integration_redis_client(): ?PredisClient
{
$target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live')));
if ($target !== 'debug') {
$target = 'live';
}
$host = edge_gateway_integration_config_value('REDIS_CONFIG_HOST', 'REDIS_CONFIG_DEBUG_HOST', $target);
if ($host === '') {
return null;
}
$parameters = [
'scheme' => 'tcp',
'host' => $host,
'port' => (int)(edge_gateway_integration_config_value('REDIS_CONFIG_PORT', 'REDIS_CONFIG_DEBUG_PORT', $target) ?: '6379'),
'database' => (int)(edge_gateway_integration_config_value('REDIS_CONFIG_DATABASE', 'REDIS_CONFIG_DEBUG_DATABASE', $target) ?: '0'),
'password' => edge_gateway_integration_config_value('REDIS_CONFIG_PASSWORD', 'REDIS_CONFIG_DEBUG_PASSWORD', $target),
];
$user = edge_gateway_integration_config_value('REDIS_CONFIG_USER', 'REDIS_CONFIG_DEBUG_USER', $target);
if ($user !== '') {
$parameters['username'] = $user;
}
return new PredisClient($parameters);
}
function edge_gateway_integration_config_value(string $liveKey, string $debugKey, string $target): string
{
$liveValue = trim((string)(getenv($liveKey) ?: ''));
$debugValue = trim((string)(getenv($debugKey) ?: ''));
if ($target === 'debug' && $debugValue !== '') {
return $debugValue;
}
return $liveValue;
}
function edge_gateway_integration_set_heartbeat_age(mysqli $mysqli, ?PredisClient $redis, int $gatewayId, int $secondsAgo): void
{
$result = $mysqli->query("SELECT last_heartbeat_at, updated_at, created_at FROM edge_gateways WHERE id = " . (int)$gatewayId . " LIMIT 1");
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
if ($result instanceof mysqli_result) {
$result->free();
}
$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));
$escaped = $mysqli->real_escape_string($timestamp);
$mysqli->query("UPDATE edge_gateways SET last_heartbeat_at = '{$escaped}', status = 'ONLINE' WHERE id = " . (int)$gatewayId);
if ($redis !== null) {
foreach ([
'obj_prop:*:' . $gatewayId . ':status',
'obj_prop:*:' . $gatewayId . ':last_heartbeat_at',
'obj_prop:*:' . $gatewayId . ':updated_at',
'edge_gateway:view:v1:*',
] as $pattern) {
$keys = $redis->keys($pattern);
if (is_array($keys) && $keys !== []) {
$redis->del($keys);
}
}
}
}
function edge_gateway_integration_inventory(string $suffix): array
{
return [[
'device_id' => 'integration-' . $suffix,
'local_ip' => '10.50.60.70',
'model' => 'TruckWash Integration Gateway',
'channel_count' => 1,
'online' => true,
'capabilities' => [
'gateway_management_v2' => true,
],
'metadata' => [
'hostname' => 'integration-' . $suffix,
],
]];
}
/**
* @param mixed $rows
* @return array<int, string>
*/
function edge_gateway_integration_messages(mixed $rows): array
{
if (!is_array($rows)) {
return [];
}
$messages = [];
foreach ($rows as $row) {
if (is_array($row) && isset($row['message']) && is_string($row['message'])) {
$messages[] = $row['message'];
}
}
return $messages;
}