Add Edge Gateway tests for unit, API, and integration scenarios

This commit is contained in:
Jeppe Bundgaard
2026-04-23 15:48:51 +02:00
parent c38a4379bd
commit b9ddc585db
23 changed files with 3579 additions and 117 deletions
+46
View File
@@ -120,6 +120,52 @@ jobs:
working-directory: services/edge-broker
run: npm test
edge-gateway-backend:
name: Edge Gateway Backend (required)
runs-on: [self-hosted, Linux, X64, default]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Materialize compose env files
env:
COMPOSE_ENV: ${{ secrets.COMPOSE_ENV }}
COMPOSE_ENV_STAGING: ${{ secrets.COMPOSE_ENV_STAGING }}
run: |
set -euo pipefail
if [ -z "${COMPOSE_ENV}" ]; then
echo "Required GitHub secret COMPOSE_ENV is not configured." >&2
exit 1
fi
if [ -z "${COMPOSE_ENV_STAGING}" ]; then
echo "Required GitHub secret COMPOSE_ENV_STAGING is not configured." >&2
exit 1
fi
printf '%s\n' "$COMPOSE_ENV" > .env
printf '%s\n' "$COMPOSE_ENV_STAGING" > .env.staging
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Boot local stack
run: docker compose up -d traefik redis mysql-debug edge-broker php1 caddy
- name: Run edge gateway API tests
run: docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:api:edge"
- name: Run edge gateway integration tests
run: docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:integration:edge"
- name: Run edge gateway E2E smoke
run: node scripts/edge-gateway-e2e.mjs
- name: Tear down local stack
if: always()
run: docker compose down -v
integration:
name: Integration (advisory)
runs-on: [self-hosted, Linux, X64, default]
+1
View File
@@ -11,3 +11,4 @@
.env
/services/caddy/logs*
/.tmp/
/.env.staging
+24
View File
@@ -91,6 +91,8 @@ For local Docker development, run the PHP suites inside `php1`:
docker exec php1 sh -lc "cd /var/www/html && composer test:unit"
docker exec php1 sh -lc "cd /var/www/html && composer test:integration"
docker exec php1 sh -lc "cd /var/www/html && composer test:api"
docker exec php1 sh -lc "cd /var/www/html && composer test:api:edge"
docker exec php1 sh -lc "cd /var/www/html && composer test:integration:edge"
```
Integration tests are opt-in and should be run with required services available:
@@ -100,6 +102,28 @@ $env:RUN_INTEGRATION_TESTS='1'
composer test:integration
```
### Edge Gateway Regression Coverage
The dedicated backend regression lane for the PHP edge gateway stack is split into:
- API contract tests for operator, agent, and broker-facing routes
- DB-backed integration tests for install sessions, heartbeats, tasks, logs, statistics, and shell persistence
- a local dockerized smoke that runs the real PHP edge agent against the local backend and broker
Run the targeted PHP suites inside `php1`:
```powershell
docker exec php1 sh -lc "cd /var/www/html && composer test:api:edge"
docker exec php1 sh -lc "cd /var/www/html && composer test:integration:edge"
```
Run the full local smoke from `backend-php` on the host:
```powershell
node .\scripts\edge-gateway-e2e.mjs
```
The E2E smoke expects the local compose stack and Docker daemon to be available. It boots a disposable gateway container, waits for a real heartbeat, validates live operations and telemetry, and verifies browser shell transcript persistence.
### Public Staging Edge-Gateway Smoke
`api.truckwash.io:4433` is the public staging ingress. For Edge Gateways v2, the router must serve the canonical artifacts from `services/nginx/app/resources/edge-gateway-agent`, not from the legacy `dist/agent.mjs` output or a separate runtime mount.
+561
View File
@@ -0,0 +1,561 @@
import assert from "node:assert/strict";
import { execFile as execFileCallback, spawn as spawnCallback } from "node:child_process";
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { DEFAULT_CONFIG_FILE_NAME, DEFAULT_HOST_API_URL } from "./test-gateway.mjs";
const execFile = promisify(execFileCallback);
const COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "php1", "caddy"];
async function runCommand(command, args, { cwd, allowFailure = false, stdio = "pipe" } = {}) {
if (stdio === "inherit") {
await new Promise((resolve, reject) => {
const child = spawnCallback(command, args, {
cwd,
stdio: "inherit",
windowsHide: true,
});
child.on("exit", (code) => {
if (code === 0 || allowFailure) {
resolve();
return;
}
reject(new Error(`${command} ${args.join(" ")} failed with exit code ${code}`));
});
child.on("error", reject);
});
return { stdout: "", stderr: "", code: 0 };
}
try {
const result = await execFile(command, args, {
cwd,
windowsHide: true,
encoding: "utf8",
});
return { stdout: result.stdout, stderr: result.stderr, code: 0 };
} catch (error) {
if (!allowFailure) {
throw error;
}
return {
stdout: error.stdout || "",
stderr: error.stderr || "",
code: typeof error.code === "number" ? error.code : 1,
};
}
}
function normalizeBaseUrl(url) {
return String(url || "").replace(/\/+$/, "");
}
async function waitForCondition(predicate, { timeoutMs = 30_000, intervalMs = 500, message = "Timed out" } = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(message);
}
async function ensureComposeServices(rootDir) {
await runCommand("docker", ["compose", "up", "-d", ...COMPOSE_SERVICES], {
cwd: rootDir,
stdio: "inherit",
});
}
async function waitForApiReady(baseUrl, attempts = 60) {
const root = normalizeBaseUrl(baseUrl);
let lastError = "API never responded";
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
const response = await fetch(`${root}/ping`);
if (response.ok) {
return;
}
lastError = `Unexpected ping status ${response.status}`;
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new Error(`API did not become ready at ${root}/ping: ${lastError}`);
}
function parseLastJsonLine(output) {
const lines = String(output || "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
for (let index = lines.length - 1; index >= 0; index -= 1) {
try {
return JSON.parse(lines[index]);
} catch {
// Continue scanning backwards for the JSON payload.
}
}
throw new Error(`Unable to parse JSON from command output:\n${output}`);
}
async function runPhpFixture(rootDir, action, payload = null) {
const encodedPayload = payload === null
? ""
: ` ${Buffer.from(JSON.stringify(payload), "utf8").toString("base64url")}`;
const command = `cd /var/www/html && CONFIG_DB_TARGET=debug php tests/Support/EdgeGatewayE2eFixture.php ${action}${encodedPayload}`;
const result = await runCommand("docker", [
"compose",
"exec",
"-T",
"php1",
"sh",
"-lc",
command,
], {
cwd: rootDir,
});
return parseLastJsonLine(result.stdout);
}
async function apiRequest(baseUrl, method, endpoint, { token = null, body = null, headers = {} } = {}) {
const response = await fetch(`${normalizeBaseUrl(baseUrl)}${endpoint}`, {
method,
headers: {
...(body === null ? {} : { "content-type": "application/json" }),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...headers,
},
body: body === null ? undefined : JSON.stringify(body),
});
const rawBody = await response.text();
let json = null;
if (rawBody !== "") {
try {
json = JSON.parse(rawBody);
} catch {
json = null;
}
}
if (!response.ok) {
const message =
json?.data?.message ||
json?.error ||
rawBody ||
`HTTP ${response.status}`;
throw new Error(`${method} ${endpoint} failed: ${message}`);
}
return json;
}
async function loadWebSocketImplementation() {
if (typeof WebSocket !== "undefined") {
return WebSocket;
}
const module = await import("ws");
return module.default;
}
function onSocket(socket, eventName, handler) {
if (typeof socket.addEventListener === "function") {
socket.addEventListener(eventName, (event) => {
if (eventName === "message") {
handler(event.data);
return;
}
handler(event);
});
return;
}
socket.on(eventName, handler);
}
function collectSocketMessages(socket) {
const messages = [];
onSocket(socket, "message", (payload) => {
const text = typeof payload === "string"
? payload
: Buffer.isBuffer(payload)
? payload.toString("utf8")
: typeof payload?.toString === "function"
? payload.toString()
: "";
if (text === "") {
return;
}
try {
messages.push(JSON.parse(text));
} catch {
// Ignore non-JSON frames.
}
});
return messages;
}
async function waitForSocketOpen(socket) {
if (socket.readyState === 1) {
return;
}
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Timed out waiting for websocket open.")), 10_000);
const onOpen = () => {
clearTimeout(timeout);
resolve();
};
const onError = (error) => {
clearTimeout(timeout);
reject(error instanceof Error ? error : new Error(String(error)));
};
onSocket(socket, "open", onOpen);
onSocket(socket, "error", onError);
});
}
async function waitForSocketMessage(messages, predicate, options) {
await waitForCondition(() => messages.some(predicate), options);
}
function buildSocketUrl(wsUrl, token) {
const url = new URL(String(wsUrl));
url.searchParams.set("token", token);
return url.toString();
}
function closeSocket(socket) {
if (!socket || typeof socket.close !== "function") {
return;
}
const readyState = typeof socket.readyState === "number" ? socket.readyState : null;
if (readyState !== null && readyState >= 2) {
return;
}
socket.close();
}
function collectMessages(rows) {
return Array.isArray(rows)
? rows
.map((row) => (row && typeof row === "object" ? row.message : null))
.filter((value) => typeof value === "string")
: [];
}
async function main() {
const scriptPath = fileURLToPath(import.meta.url);
const rootDir = path.resolve(path.dirname(scriptPath), "..");
const runId = randomUUID().slice(0, 8);
const containerName = `truckwash-edge-e2e-${runId}`;
const configDir = path.join(rootDir, ".tmp", "edge-gateway-e2e", runId);
const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
const baseUrl = process.env.EDGE_GATEWAY_E2E_BASE_URL || DEFAULT_HOST_API_URL;
let fixture = null;
let gatewayId = null;
let streamSocket = null;
let shellSocket = null;
try {
await ensureComposeServices(rootDir);
await waitForApiReady(baseUrl);
fixture = await runPhpFixture(rootDir, "create");
const authToken = String(fixture.auth_token || "");
const departmentId = Number(fixture.department_id || 0);
assert.ok(authToken !== "", "Fixture helper did not return an auth token.");
assert.ok(departmentId > 0, "Fixture helper did not return a department id.");
const installTokenResponse = await apiRequest(baseUrl, "POST", "/edge-gateways/install-token", {
token: authToken,
body: {
department_id: departmentId,
label: `Edge Gateway E2E ${runId}`,
},
});
const installToken = String(installTokenResponse?.data?.token || "");
assert.ok(installToken !== "", "Install token creation did not return a token.");
await runCommand(process.execPath, [
"scripts/test-gateway.mjs",
"start",
"--install-token",
installToken,
"--container-name",
containerName,
"--config-dir",
configDir,
"--heartbeat-seconds",
"3",
"--skip-compose-up",
], {
cwd: rootDir,
stdio: "inherit",
});
await waitForCondition(
async () => {
try {
await fs.access(configFilePath);
return true;
} catch {
return false;
}
},
{ message: `Gateway config file was not created at ${configFilePath}` }
);
const config = JSON.parse(await fs.readFile(configFilePath, "utf8"));
gatewayId = Number(config.gatewayId || 0);
assert.ok(gatewayId > 0, "Gateway config did not include a gateway id.");
await waitForCondition(
async () => {
const result = await runCommand("docker", [
"exec",
containerName,
"test",
"-f",
"/opt/truckwash-edge-agent/runtime/last-heartbeat-ok.txt",
], {
allowFailure: true,
});
return result.code === 0;
},
{
timeoutMs: 60_000,
message: "Gateway never wrote the successful heartbeat marker.",
}
);
await waitForCondition(
async () => {
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
token: authToken,
});
return detail?.data?.status === "ONLINE"
&& Object.keys(detail?.data?.metadata?.system_metrics || {}).length > 0;
},
{
timeoutMs: 60_000,
message: "Gateway detail never transitioned to ONLINE with fresh system metrics after install.",
}
);
const WebSocketImpl = await loadWebSocketImplementation();
const streamSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/stream-session`, {
token: authToken,
body: {
scopes: ["overview", "tasks", "logs", "statistics"],
},
});
const streamWsUrl = buildSocketUrl(String(streamSession?.data?.ws_url || ""), String(streamSession?.data?.token || ""));
streamSocket = new WebSocketImpl(streamWsUrl);
const streamMessages = collectSocketMessages(streamSocket);
await waitForSocketOpen(streamSocket);
await waitForSocketMessage(
streamMessages,
(message) => message?.type === "gateway.stream.ready",
{ timeoutMs: 15_000, message: "Gateway stream never became ready." }
);
const operationResponse = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/operations`, {
token: authToken,
body: {
type: "DISCOVERY",
request: {
inventory: [{
device_id: `edge-e2e-${runId}`,
local_ip: "10.70.80.90",
model: "TruckWash Edge E2E",
channel_count: 1,
online: true,
capabilities: {
gateway_management_v2: true,
},
metadata: {
hostname: `edge-e2e-${runId}`,
},
}],
},
},
});
const operationId = Number(operationResponse?.data?.operation?.id || 0);
assert.ok(operationId > 0, "Operation creation did not return an operation id.");
await waitForSocketMessage(
streamMessages,
(message) => message?.type === "task.updated" && Number(message?.operationId || 0) === operationId,
{ timeoutMs: 30_000, message: "Live gateway stream never emitted task.updated for the queued operation." }
);
await waitForCondition(
async () => {
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
token: authToken,
});
const operation = Array.isArray(operations?.data)
? operations.data.find((item) => Number(item?.id || 0) === operationId)
: null;
return operation?.status === "COMPLETED";
},
{ timeoutMs: 30_000, message: "Gateway operation never completed through the live agent." }
);
await waitForSocketMessage(
streamMessages,
(message) => message?.type === "gateway.telemetry" || message?.type === "stats.updated",
{ timeoutMs: 15_000, message: "Live gateway stream never emitted telemetry or statistics updates." }
);
await waitForCondition(
async () => {
const logs = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
token: authToken,
});
return Array.isArray(logs?.data?.log_entries) && logs.data.log_entries.length > 0;
},
{ timeoutMs: 20_000, message: "Gateway logs page never received live log entries from the running agent." }
);
const statistics = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/statistics`, {
token: authToken,
});
assert.ok(
Object.keys(statistics?.data?.system_metrics || {}).length > 0,
"Gateway statistics page did not expose system metrics after live telemetry."
);
const shellSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/shell-sessions`, {
token: authToken,
body: {
reason: "Edge gateway E2E shell validation",
cwd: "/opt/truckwash-edge-agent",
cols: 120,
rows: 40,
},
});
const shellWsUrl = buildSocketUrl(String(shellSession?.data?.ws_url || ""), String(shellSession?.data?.token || ""));
shellSocket = new WebSocketImpl(shellWsUrl);
const shellMessages = collectSocketMessages(shellSocket);
await waitForSocketOpen(shellSocket);
await waitForSocketMessage(
shellMessages,
(message) => message?.type === "opened",
{ timeoutMs: 20_000, message: "Browser shell never opened against the live gateway." }
);
shellSocket.send(JSON.stringify({
type: "input",
data: "printf 'edge-e2e-shell\\n'; exit\r",
}));
await waitForSocketMessage(
shellMessages,
(message) => message?.type === "output" && String(message?.data || "").includes("edge-e2e-shell"),
{ timeoutMs: 20_000, message: "Browser shell never returned the expected command output." }
);
await waitForSocketMessage(
shellMessages,
(message) => message?.type === "closed",
{ timeoutMs: 20_000, message: "Browser shell never closed cleanly." }
);
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
token: authToken,
});
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
: [];
assert.ok(
shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell")),
"Gateway logs page did not persist the shell transcript."
);
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
assert.ok(
timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED"),
"Gateway logs page did not include the shell close audit event."
);
process.stdout.write("Edge gateway E2E smoke completed successfully.\n");
} finally {
closeSocket(shellSocket);
closeSocket(streamSocket);
if (gatewayId !== null && fixture?.auth_token) {
await apiRequest(baseUrl, "DELETE", `/edge-gateways/${gatewayId}`, {
token: String(fixture.auth_token),
}).catch(() => {});
}
await runCommand(process.execPath, [
"scripts/test-gateway.mjs",
"stop",
"--container-name",
containerName,
], {
cwd: rootDir,
allowFailure: true,
}).catch(() => {});
if (fixture !== null) {
await runPhpFixture(rootDir, "cleanup", fixture).catch(() => {});
}
await fs.rm(configDir, { recursive: true, force: true }).catch(() => {});
}
}
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});
File diff suppressed because one or more lines are too long
@@ -9,6 +9,8 @@ class shelly_relay_inventory
private ?shelly $client;
/** @var callable|null */
private $inventory_fetcher = null;
/** @var callable|null */
private $device_list_fetcher = null;
public function __construct(?shelly $client = null)
{
@@ -21,6 +23,12 @@ class shelly_relay_inventory
return $this;
}
public function setDeviceListFetcher(callable $fetcher): self
{
$this->device_list_fetcher = $fetcher;
return $this;
}
/**
* @return array<int,array<string,mixed>>
* @throws Exception
@@ -28,10 +36,21 @@ class shelly_relay_inventory
public function listRelayOptions(): array
{
$devices_status = $this->fetchOwnedDevicesStatus();
$device_catalog = $this->fetchOwnedDeviceCatalog();
$options_by_id = [];
foreach ($devices_status as $device) {
$option = $this->buildRelayOption($this->normalizeToArray($device));
$normalized_device = $this->normalizeToArray($device);
$device_id = $this->extractFirstString([
$normalized_device['_dev_info']['id'] ?? null,
$normalized_device['id'] ?? null,
]);
$catalog_entry = $device_id !== '' ? ($device_catalog[$device_id] ?? null) : null;
$option = $this->buildRelayOption(
$normalized_device,
is_array($catalog_entry) ? $catalog_entry : null
);
if ($option === null) {
continue;
}
@@ -41,9 +60,10 @@ class shelly_relay_inventory
$options = array_values($options_by_id);
usort($options, static function (array $left, array $right): int {
$online_compare = ((int)!($left['online'] ?? false)) <=> ((int)!($right['online'] ?? false));
if ($online_compare !== 0) {
return $online_compare;
$status_compare = self::statusSortWeight((string)($left['status_color'] ?? ''))
<=> self::statusSortWeight((string)($right['status_color'] ?? ''));
if ($status_compare !== 0) {
return $status_compare;
}
$name_compare = strcasecmp((string)($left['name'] ?? ''), (string)($right['name'] ?? ''));
@@ -57,6 +77,48 @@ class shelly_relay_inventory
return $options;
}
/**
* @return array<string,array<string,mixed>>
*/
private function fetchOwnedDeviceCatalog(): array
{
try {
$payload = is_callable($this->device_list_fetcher)
? ($this->device_list_fetcher)()
: $this->getClient()->sendGetRequest('/interface/device/list', [
'no_shared' => 'true',
]);
} catch (\Throwable) {
return [];
}
$normalized = $this->normalizeToArray($payload);
if (($normalized['isok'] ?? true) === false) {
return [];
}
$devices = $normalized['data']['devices'] ?? null;
if (!is_array($devices)) {
return [];
}
$catalog_by_id = [];
foreach ($devices as $device_key => $device) {
$normalized_device = $this->normalizeToArray($device);
$device_id = $this->extractFirstString([
$normalized_device['id'] ?? null,
is_string($device_key) ? $device_key : null,
]);
if ($device_id === '') {
continue;
}
$catalog_by_id[$device_id] = $normalized_device;
}
return $catalog_by_id;
}
/**
* @return array<string,mixed>
* @throws Exception
@@ -98,7 +160,7 @@ class shelly_relay_inventory
* @param array<string,mixed> $device
* @return array<string,mixed>|null
*/
private function buildRelayOption(array $device): ?array
private function buildRelayOption(array $device, ?array $catalog_entry = null): ?array
{
if ($device === [] || !$this->isRelayCapableDevice($device)) {
return null;
@@ -112,7 +174,10 @@ class shelly_relay_inventory
return null;
}
$device_name = $this->extractFirstString([
$cloud_name = $this->extractFirstString([
$catalog_entry['name'] ?? null,
]);
$local_device_name = $this->extractFirstString([
$device['name'] ?? null,
$device['_dev_info']['name'] ?? null,
$device['settings']['name'] ?? null,
@@ -120,18 +185,38 @@ class shelly_relay_inventory
$device['status']['name'] ?? null,
$device['status']['sys']['device']['name'] ?? null,
]);
$device_name = $cloud_name !== '' ? $cloud_name : $local_device_name;
$device_code = $this->extractFirstString([
$device['_dev_info']['code'] ?? null,
$device['code'] ?? null,
]);
$device_type = $this->extractDeviceType($device, $device_code, $catalog_entry);
$control_type = $this->extractControlType($device);
$control_name = $this->extractControlName($device);
$online = $this->extractOnlineState($device);
if ($online === null) {
$online = $this->normalizeBoolean($catalog_entry['cloud_online'] ?? null);
}
$status_color = $this->extractStatusColor($online);
return [
'id' => $device_id,
'name' => $this->buildRelayLabel($device_id, $device_name, $device_code),
'name' => $this->buildRelayLabel(
$device_type,
$cloud_name,
$local_device_name,
$control_name,
$device_id
),
'device_id' => $device_id,
'device_name' => $device_name !== '' ? $device_name : null,
'cloud_name' => $cloud_name !== '' ? $cloud_name : null,
'device_type' => $device_type,
'code' => $device_code !== '' ? $device_code : null,
'online' => $this->extractOnlineState($device),
'control_type' => $control_type,
'control_name' => $control_name !== '' ? $control_name : null,
'status_color' => $status_color,
'online' => $online,
];
}
@@ -176,7 +261,7 @@ class shelly_relay_inventory
/**
* @param array<string,mixed> $device
*/
private function extractOnlineState(array $device): bool
private function extractOnlineState(array $device): ?bool
{
$online = $device['_dev_info']['online'] ?? $device['online'] ?? null;
if (is_bool($online)) {
@@ -187,21 +272,106 @@ class shelly_relay_inventory
return (int)$online === 1;
}
return false;
return null;
}
private function buildRelayLabel(string $device_id, string $device_name, string $device_code): string
private function extractStatusColor(?bool $online): string
{
$label = $device_id;
if ($device_name !== '' && strcasecmp($device_name, $device_id) !== 0) {
$label = $device_name . ' (' . $device_id . ')';
if ($online === true) {
return 'Green';
}
if ($device_code !== '') {
return $label . ' · ' . $device_code;
if ($online === false) {
return 'Red';
}
return $label;
return 'Yellow';
}
/**
* @param array<string,mixed> $device
*/
private function extractDeviceType(array $device, string $device_code, ?array $catalog_entry = null): ?string
{
$device_type = $this->extractFirstString([
$device['_dev_info']['model'] ?? null,
$device['_dev_info']['type'] ?? null,
$device['model'] ?? null,
$device['type'] ?? null,
$device['settings']['device']['type'] ?? null,
$catalog_entry['type'] ?? null,
$device_code,
]);
return $device_type !== '' ? $device_type : null;
}
/**
* @param array<string,mixed> $device
*/
private function extractControlType(array $device): string
{
$status = $this->normalizeToArray($device['status'] ?? null);
$settings = $this->normalizeToArray($device['settings'] ?? null);
foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) {
if (array_key_exists($switch_key, $device) || array_key_exists($switch_key, $status)) {
return 'Switch';
}
}
if ((isset($device['switches']) && is_array($device['switches']) && $device['switches'] !== [])
|| (isset($settings['switches']) && is_array($settings['switches']) && $settings['switches'] !== [])) {
return 'Switch';
}
if ((isset($device['relays']) && is_array($device['relays']) && $device['relays'] !== [])
|| (isset($settings['relays']) && is_array($settings['relays']) && $settings['relays'] !== [])) {
return 'Relay';
}
return 'Device';
}
/**
* @param array<string,mixed> $device
*/
private function extractControlName(array $device): string
{
$status = $this->normalizeToArray($device['status'] ?? null);
$settings = $this->normalizeToArray($device['settings'] ?? null);
return $this->extractFirstString([
$status['switch:0']['name'] ?? null,
$status['switch_0']['name'] ?? null,
$status['switch0']['name'] ?? null,
$device['switches'][0]['name'] ?? null,
$settings['switches'][0]['name'] ?? null,
$device['relays'][0]['name'] ?? null,
$settings['relays'][0]['name'] ?? null,
]);
}
private function buildRelayLabel(
?string $device_type,
string $cloud_name,
string $local_device_name,
string $control_name,
string $device_id
): string {
if ($cloud_name !== '') {
$display_name = $cloud_name;
} elseif ($local_device_name !== '' && $control_name !== '' && strcasecmp($local_device_name, $control_name) !== 0) {
$display_name = $local_device_name . ' / ' . $control_name;
} else {
$display_name = $control_name !== '' ? $control_name : ($local_device_name !== '' ? $local_device_name : $device_id);
}
if ($device_type !== null && $device_type !== '') {
return $display_name . ' (' . $device_type . ')';
}
return $display_name;
}
/**
@@ -219,6 +389,29 @@ class shelly_relay_inventory
return '';
}
private function normalizeBoolean(mixed $value): ?bool
{
if (is_bool($value)) {
return $value;
}
if (is_numeric($value)) {
return (int)$value === 1;
}
return null;
}
private static function statusSortWeight(string $status_color): int
{
return match (strtolower($status_color)) {
'green' => 0,
'yellow' => 1,
'red' => 2,
default => 3,
};
}
/**
* @return array<string,mixed>
*/
+8
View File
@@ -7,6 +7,14 @@
"Composer\\Config::disableProcessTimeout",
"@php -r \"putenv('RUN_API_TESTS=1'); passthru('vendor/bin/pest --testsuite=Api --colors=always', $exitCode); exit($exitCode);\""
],
"test:api:edge": [
"Composer\\Config::disableProcessTimeout",
"@php -r \"putenv('RUN_API_TESTS=1'); putenv('API_TEST_BOOTSTRAP_SCHEMA=1'); putenv('CONFIG_DB_TARGET=debug'); passthru('vendor/bin/pest tests/Api/EdgeGateway*ApiTest.php --colors=always', $exitCode); exit($exitCode);\""
],
"test:integration:edge": [
"Composer\\Config::disableProcessTimeout",
"@php -r \"putenv('RUN_INTEGRATION_TESTS=1'); putenv('CONFIG_DB_TARGET=debug'); passthru('vendor/bin/pest tests/Integration/EdgeGateway --colors=always', $exitCode); exit($exitCode);\""
],
"test:coverage": [
"@php -r \"is_dir('build/logs') || mkdir('build/logs', 0777, true);\"",
"@php -r \"if (extension_loaded('pcov')) { passthru('php -d pcov.enabled=1 vendor/bin/pest --testsuite=Unit --coverage --coverage-clover build/logs/clover.xml --colors=always', $exitCode); exit($exitCode); } if (extension_loaded('xdebug')) { passthru('php -d xdebug.mode=coverage vendor/bin/pest --testsuite=Unit --coverage --coverage-clover build/logs/clover.xml --colors=always', $exitCode); exit($exitCode); } fwrite(STDERR, 'No coverage driver available. Enable pcov or xdebug for test:coverage.' . PHP_EOL); exit(1);\""
@@ -3959,11 +3959,19 @@ BASH;
$channelStatus = self::deriveChannelStatus($gateway, $effectiveStatus, $now);
$relayHealth = self::buildRelayHealth($gateway, $effectiveStatus, $now);
$fallbackSummary = self::buildFallbackSummary($relayHealth);
$gateway['outbox_status'] = self::buildOutboxStatusSummary($gateway, $now);
$lastSyncAt = self::resolveLastSyncAt($gateway);
$gateway['channel_status'] = $channelStatus;
$gateway['relay_health'] = $relayHealth;
$gateway['fallback_summary'] = $fallbackSummary;
$gateway['transport_health'] = self::deriveTransportHealth($effectiveStatus, $channelStatus, $fallbackSummary);
$gateway['transport_health'] = self::deriveTransportHealth(
$gateway,
$effectiveStatus,
$channelStatus,
$fallbackSummary,
$lastSyncAt
);
$gateway['last_successful_command_at'] = $gateway['operational_snapshot']['last_successful_command_at']
?? self::findLatestCompletionTimestamp((array)($gateway['recent_commands'] ?? []), null);
$gateway['last_successful_discovery_at'] = $gateway['operational_snapshot']['last_successful_discovery_at']
@@ -3976,8 +3984,7 @@ BASH;
$gateway['version_drift'] = self::buildVersionDriftSummary($gateway);
$gateway['credential_freshness'] = self::buildCredentialFreshnessSummary($gateway, $now);
$gateway['container_health'] = self::buildContainerHealthSummary($gateway, $effectiveStatus);
$gateway['outbox_status'] = self::buildOutboxStatusSummary($gateway, $now);
$gateway['last_sync_at'] = self::resolveLastSyncAt($gateway);
$gateway['last_sync_at'] = $lastSyncAt;
$gateway['update_window'] = self::buildUpdateWindowSummary($gateway);
$gateway['staged_version'] = self::buildStagedVersionSummary($gateway);
$gateway['rollback_status'] = self::buildRollbackStatusSummary($gateway);
@@ -4150,8 +4157,18 @@ BASH;
return $summary;
}
private static function deriveTransportHealth(string $effectiveStatus, array $channelStatus, array $fallbackSummary): array
private static function deriveTransportHealth(
array $gateway,
string $effectiveStatus,
array $channelStatus,
array $fallbackSummary,
?string $lastSyncAt
): array
{
$metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : [];
$controlPlaneStatus = isset($metadata['control_plane_status']) && is_array($metadata['control_plane_status'])
? (array)$metadata['control_plane_status']
: [];
$brokerState = (string)($channelStatus['broker']['state'] ?? self::STATUS_OFFLINE);
$affectedRelayCount = count((array)($fallbackSummary['affected_relays'] ?? []));
$transportState = $effectiveStatus;
@@ -4169,6 +4186,13 @@ BASH;
? 'Broker fast path er aktiv med API polling som fallback'
: 'API polling er aktiv som primær kontrolkanal'),
'recommended_action' => $fallbackSummary['recommended_action'] ?? ($channelStatus['broker']['last_error'] ?? null),
'last_successful_sync_at' => $lastSyncAt,
'last_transport_failure_at' => isset($controlPlaneStatus['last_transport_failure_at'])
? (string)$controlPlaneStatus['last_transport_failure_at']
: null,
'last_transport_error' => isset($controlPlaneStatus['last_transport_error'])
? (string)$controlPlaneStatus['last_transport_error']
: null,
];
}
@@ -4320,17 +4344,23 @@ BASH;
private static function resolveLastSyncAt(array $gateway): ?string
{
$metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : [];
if (!empty($metadata['last_sync_at'])) {
return (string)$metadata['last_sync_at'];
}
$controlPlaneStatus = isset($metadata['control_plane_status']) && is_array($metadata['control_plane_status'])
? (array)$metadata['control_plane_status']
: [];
$outbox = isset($gateway['outbox_status']) && is_array($gateway['outbox_status'])
? (array)$gateway['outbox_status']
: [];
$outboxMetadata = isset($metadata['outbox_status']) && is_array($metadata['outbox_status'])
? (array)$metadata['outbox_status']
: [];
return isset($outbox['last_replayed_at']) && $outbox['last_replayed_at'] !== null
? (string)$outbox['last_replayed_at']
: null;
return self::latestTimestamp([
$controlPlaneStatus['last_successful_sync_at'] ?? null,
$metadata['last_sync_at'] ?? null,
$outbox['last_replayed_at'] ?? null,
$outboxMetadata['last_replayed_at'] ?? null,
$gateway['last_heartbeat_at'] ?? null,
]);
}
private static function buildUpdateWindowSummary(array $gateway): array
@@ -4551,6 +4581,33 @@ BASH;
return null;
}
/**
* @param array<int,mixed> $timestamps
*/
private static function latestTimestamp(array $timestamps): ?string
{
$latestValue = null;
$latestEpoch = 0;
foreach ($timestamps as $timestamp) {
if (!is_string($timestamp) || trim($timestamp) === '') {
continue;
}
$epoch = strtotime($timestamp);
if ($epoch === false) {
continue;
}
if ($latestValue === null || $epoch >= $latestEpoch) {
$latestValue = $timestamp;
$latestEpoch = $epoch;
}
}
return $latestValue;
}
private static function normalizeFallbackMode(?string $fallbackMode): string
{
$normalized = strtoupper(trim((string)$fallbackMode));
@@ -998,6 +998,8 @@ final class TruckwashEdgeAgent
private const DEFAULT_UPDATE_WINDOW = '02:00-04:00';
private const OPERATION_COMPLETE_TIMEOUT_SECONDS = 120;
private const BROKER_MESSAGE_PUMP_LIMIT = 12;
private const LOOP_STALE_AFTER_SECONDS = 30;
private const CONTROL_PLANE_SYNC_STALE_AFTER_SECONDS = 90;
private AgentConfig $config;
private HttpJsonClient $http;
@@ -1011,6 +1013,7 @@ final class TruckwashEdgeAgent
private string $statePath;
private string $lastOperationSnapshotPath;
private string $lastHeartbeatMarkerPath;
private string $controlPlaneStatusPath;
private string $stagedUpdatePath;
private int $lastHeartbeatAt = 0;
private string $agentInstanceId;
@@ -1031,12 +1034,14 @@ final class TruckwashEdgeAgent
$this->statePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'current-operation.json';
$this->lastOperationSnapshotPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'last-operation.json';
$this->lastHeartbeatMarkerPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'last-heartbeat-ok.txt';
$this->controlPlaneStatusPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'control-plane-status.json';
$this->stagedUpdatePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'staged-update.json';
$this->agentInstanceId = $this->ensureAgentInstanceId();
$this->brokerClient = new BrokerWebSocketClient($this->logger);
$this->shellBridge = new AgentShellBridge($this->installDir);
$this->logger->setSink(fn(string $level, string $message): bool => $this->emitLogFrame($level, $message));
$this->configureBrokerClient();
$this->initializeControlPlaneStatus();
}
public function run(): void
@@ -1046,6 +1051,7 @@ final class TruckwashEdgeAgent
while (true) {
try {
$this->touchLoopHeartbeat();
$this->reloadConfigFromDisk();
$this->ensureClaimed();
$this->configureBrokerClient();
@@ -1081,23 +1087,29 @@ final class TruckwashEdgeAgent
}
$installedVersion = (string)$this->config->get('installedVersion', 'compose-php-agent-v2');
$response = $this->http->post('/edge-agent/claim', [
'token' => (string)$this->config->get('installToken'),
'hostname' => gethostname() ?: 'truckwash-edge',
'installed_version' => $installedVersion,
'metadata' => [
'runtime' => 'compose-php',
'runtime_mode' => 'compose',
'php_version' => PHP_VERSION,
'agent_instance_id' => $this->agentInstanceId,
'update_window' => (string)$this->config->get('updateWindow', self::DEFAULT_UPDATE_WINDOW),
'container_health' => $this->buildContainerHealth(),
'outbox_status' => $this->buildOutboxStatus(),
'last_sync_at' => $this->stateStore->getJson('last_sync_at'),
'rollback_status' => $this->readRollbackStatus(),
'staged_version' => $this->currentStagedUpdate(),
],
]);
try {
$response = $this->http->post('/edge-agent/claim', [
'token' => (string)$this->config->get('installToken'),
'hostname' => gethostname() ?: 'truckwash-edge',
'installed_version' => $installedVersion,
'metadata' => [
'runtime' => 'compose-php',
'runtime_mode' => 'compose',
'php_version' => PHP_VERSION,
'agent_instance_id' => $this->agentInstanceId,
'update_window' => (string)$this->config->get('updateWindow', self::DEFAULT_UPDATE_WINDOW),
'container_health' => $this->buildContainerHealth(),
'outbox_status' => $this->buildOutboxStatus(),
'last_sync_at' => $this->currentLastSuccessfulSyncAt(),
'control_plane_status' => $this->buildControlPlaneStatusPayload(),
'rollback_status' => $this->readRollbackStatus(),
'staged_version' => $this->currentStagedUpdate(),
],
]);
} catch (Throwable $throwable) {
$this->recordTransportFailure('Gateway claim failed', $throwable);
throw $throwable;
}
$payload = $response['data'] ?? [];
$gateway = is_array($payload['gateway'] ?? null) ? (array)$payload['gateway'] : [];
@@ -1110,7 +1122,7 @@ final class TruckwashEdgeAgent
$this->config->set('targetVersion', (string)($gateway['target_version'] ?? $installedVersion));
$this->config->set('agentInstanceId', $this->agentInstanceId);
$this->config->save();
$this->stateStore->setJson('last_sync_at', date('c'));
$this->recordSuccessfulSync();
$this->logger->info('Claimed gateway ' . (string)($gateway['id'] ?? 'unknown') . '.');
}
@@ -1145,7 +1157,7 @@ final class TruckwashEdgeAgent
{
$sent = $this->brokerClient->send($message);
if ($sent) {
$this->stateStore->setJson('last_sync_at', date('c'));
$this->recordSuccessfulSync();
}
return $sent;
}
@@ -1224,6 +1236,7 @@ final class TruckwashEdgeAgent
return;
}
$this->recordHeartbeatAttempt();
$operationState = $this->readOperationState();
$payload = [
'agent_token' => (string)$this->config->get('agentToken'),
@@ -1242,7 +1255,8 @@ final class TruckwashEdgeAgent
'system_metrics' => $this->buildSystemMetrics(),
'container_health' => $this->buildContainerHealth(),
'outbox_status' => $this->buildOutboxStatus(),
'last_sync_at' => $this->stateStore->getJson('last_sync_at'),
'last_sync_at' => $this->currentLastSuccessfulSyncAt(),
'control_plane_status' => $this->buildControlPlaneStatusPayload(),
'update_window' => (string)$this->config->get('updateWindow', self::DEFAULT_UPDATE_WINDOW),
'staged_version' => $this->currentStagedUpdate(),
'rollback_status' => $this->readRollbackStatus(),
@@ -1265,10 +1279,14 @@ final class TruckwashEdgeAgent
return;
}
$heartbeatSucceededAt = date('c');
$this->lastHeartbeatAt = time();
$this->recordSuccessfulSync($heartbeatSucceededAt, [
'last_heartbeat_success_at' => $heartbeatSucceededAt,
]);
file_put_contents($this->lastHeartbeatMarkerPath, json_encode([
'gateway_id' => $gatewayId,
'at' => date('c'),
'at' => $heartbeatSucceededAt,
'agent_instance_id' => $this->agentInstanceId,
], JSON_UNESCAPED_SLASHES) . PHP_EOL);
}
@@ -1991,6 +2009,143 @@ final class TruckwashEdgeAgent
];
}
private function initializeControlPlaneStatus(): void
{
$this->writeControlPlaneStatus();
}
private function touchLoopHeartbeat(): void
{
$this->writeControlPlaneStatus([
'last_loop_at' => date('c'),
]);
}
private function currentLastSuccessfulSyncAt(): ?string
{
$current = $this->readControlPlaneStatus()['last_successful_sync_at'] ?? $this->stateStore->getJson('last_sync_at');
return $this->normalizeControlPlaneStatusTimestamp($current);
}
private function recordHeartbeatAttempt(): string
{
$attemptedAt = date('c');
$this->writeControlPlaneStatus([
'last_heartbeat_attempt_at' => $attemptedAt,
]);
return $attemptedAt;
}
private function recordSuccessfulSync(?string $at = null, array $statusOverrides = []): void
{
$syncedAt = $this->normalizeControlPlaneStatusTimestamp($at ?? date('c')) ?? date('c');
$this->stateStore->setJson('last_sync_at', $syncedAt);
$statusOverrides['last_successful_sync_at'] = $syncedAt;
$this->writeControlPlaneStatus($statusOverrides);
}
private function recordTransportFailure(string $context, Throwable $throwable): void
{
$message = $this->normalizeControlPlaneStatusString($throwable->getMessage()) ?? $throwable::class;
$this->writeControlPlaneStatus([
'last_transport_failure_at' => date('c'),
'last_transport_error' => $message,
]);
$this->logger->warning($context . ': ' . $message);
}
/**
* @return array<string,mixed>
*/
private function buildControlPlaneStatusPayload(): array
{
return $this->writeControlPlaneStatus();
}
/**
* @return array<string,mixed>
*/
private function readControlPlaneStatus(): array
{
if (!is_file($this->controlPlaneStatusPath)) {
return [];
}
$decoded = json_decode((string)file_get_contents($this->controlPlaneStatusPath), true);
return is_array($decoded) ? $decoded : [];
}
/**
* @param array<string,mixed> $overrides
* @return array<string,mixed>
*/
private function writeControlPlaneStatus(array $overrides = []): array
{
$current = $this->readControlPlaneStatus();
$outboxSummary = $this->stateStore->outboxSummary();
$lastSuccessfulSyncAt = array_key_exists('last_successful_sync_at', $overrides)
? $overrides['last_successful_sync_at']
: ($current['last_successful_sync_at'] ?? $this->stateStore->getJson('last_sync_at'));
$lastTransportError = array_key_exists('last_transport_error', $overrides)
? $overrides['last_transport_error']
: ($current['last_transport_error'] ?? null);
$lastTransportFailureAt = array_key_exists('last_transport_failure_at', $overrides)
? $overrides['last_transport_failure_at']
: ($current['last_transport_failure_at'] ?? null);
$lastHeartbeatAttemptAt = array_key_exists('last_heartbeat_attempt_at', $overrides)
? $overrides['last_heartbeat_attempt_at']
: ($current['last_heartbeat_attempt_at'] ?? null);
$lastHeartbeatSuccessAt = array_key_exists('last_heartbeat_success_at', $overrides)
? $overrides['last_heartbeat_success_at']
: ($current['last_heartbeat_success_at'] ?? null);
$lastLoopAt = array_key_exists('last_loop_at', $overrides)
? $overrides['last_loop_at']
: ($current['last_loop_at'] ?? null);
$status = [
'started_at' => $this->normalizeControlPlaneStatusTimestamp($current['started_at'] ?? null) ?? date('c'),
'agent_instance_id' => $this->agentInstanceId,
'last_loop_at' => $this->normalizeControlPlaneStatusTimestamp($lastLoopAt),
'last_heartbeat_attempt_at' => $this->normalizeControlPlaneStatusTimestamp($lastHeartbeatAttemptAt),
'last_heartbeat_success_at' => $this->normalizeControlPlaneStatusTimestamp($lastHeartbeatSuccessAt),
'last_successful_sync_at' => $this->normalizeControlPlaneStatusTimestamp($lastSuccessfulSyncAt),
'outbox_queued' => array_key_exists('outbox_queued', $overrides)
? max(0, (int)$overrides['outbox_queued'])
: max(0, (int)($outboxSummary['queued'] ?? 0)),
'broker_connected' => array_key_exists('broker_connected', $overrides)
? (bool)$overrides['broker_connected']
: $this->isBrokerConnected(),
'last_transport_error' => $this->normalizeControlPlaneStatusString($lastTransportError),
'last_transport_failure_at' => $this->normalizeControlPlaneStatusTimestamp($lastTransportFailureAt),
];
file_put_contents(
$this->controlPlaneStatusPath,
json_encode($status, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL
);
return $status;
}
private function normalizeControlPlaneStatusTimestamp(mixed $value): ?string
{
return is_string($value) && trim($value) !== '' ? trim($value) : null;
}
private function normalizeControlPlaneStatusString(mixed $value): ?string
{
if (is_string($value)) {
$normalized = trim($value);
return $normalized !== '' ? $normalized : null;
}
if (is_scalar($value)) {
$normalized = trim((string)$value);
return $normalized !== '' ? $normalized : null;
}
return null;
}
private function flushOutbox(): void
{
$items = $this->stateStore->queuedItems(25);
@@ -2006,9 +2161,12 @@ final class TruckwashEdgeAgent
$this->http->post($endpoint, $payload, $timeoutSeconds);
}
$this->stateStore->removeOutboxItem((int)$item['id']);
$this->stateStore->setJson('last_sync_at', date('c'));
$this->recordSuccessfulSync();
} catch (Throwable $throwable) {
$this->logger->warning('Outbox replay blocked on ' . (string)$item['type'] . ': ' . $throwable->getMessage());
$this->recordTransportFailure(
'Outbox replay blocked on ' . (string)$item['type'] . ' for ' . (string)$item['endpoint'],
$throwable
);
break;
}
}
@@ -2106,17 +2264,19 @@ final class TruckwashEdgeAgent
{
$brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload);
if ($brokerDispatch === true) {
$this->stateStore->setJson('last_sync_at', date('c'));
return true;
}
try {
$this->http->post($endpoint, $payload, 20);
$this->stateStore->setJson('last_sync_at', date('c'));
$this->recordSuccessfulSync();
return true;
} catch (Throwable $throwable) {
$this->stateStore->enqueue($type, $endpoint, $payload);
$this->logger->warning('Queued ' . $type . ' to local outbox after transport failure: ' . $throwable->getMessage());
$this->recordTransportFailure(
'Queued ' . $type . ' to local outbox after transport failure on ' . $endpoint,
$throwable
);
return false;
}
}
@@ -2125,17 +2285,19 @@ final class TruckwashEdgeAgent
{
$brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload);
if ($brokerDispatch === true) {
$this->stateStore->setJson('last_sync_at', date('c'));
return ['data' => ['status' => 'ACKNOWLEDGED']];
}
try {
$response = $this->http->post($endpoint, $payload, $timeoutSeconds);
$this->stateStore->setJson('last_sync_at', date('c'));
$this->recordSuccessfulSync();
return is_array($response) ? $response : null;
} catch (Throwable $throwable) {
$this->stateStore->enqueue($type, $endpoint, $payload);
$this->logger->warning('Queued ' . $type . ' to local outbox after transport failure: ' . $throwable->getMessage());
$this->recordTransportFailure(
'Queued ' . $type . ' to local outbox after transport failure on ' . $endpoint,
$throwable
);
return null;
}
}
@@ -2356,22 +2518,27 @@ final class TruckwashEdgeAgent
$brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload);
if ($brokerDispatch === true) {
$this->stateStore->setJson('last_sync_at', date('c'));
return true;
}
try {
$this->http->post($endpoint, $payload, self::OPERATION_COMPLETE_TIMEOUT_SECONDS);
$this->stateStore->setJson('last_sync_at', date('c'));
$this->recordSuccessfulSync();
return true;
} catch (Throwable $throwable) {
if ($queueOnFailure) {
$this->stateStore->enqueue('operation_complete', $endpoint, $payload);
$this->logger->warning('Queued operation_complete to local outbox after completion acknowledgement failure: ' . $throwable->getMessage());
$this->recordTransportFailure(
'Queued operation_complete to local outbox after completion acknowledgement failure on ' . $endpoint,
$throwable
);
return false;
}
$this->logger->warning('Retrying backend completion acknowledgement later: ' . $throwable->getMessage());
$this->recordTransportFailure(
'Retrying backend completion acknowledgement later for ' . $endpoint,
$throwable
);
return false;
}
}
@@ -89,7 +89,11 @@ services:
- ./config.json:/config/config.json
- ./runtime:/opt/truckwash-edge-agent/runtime
healthcheck:
test: ["CMD-SHELL", "kill -0 1"]
test:
[
"CMD-SHELL",
"php -r '$$path=\"/opt/truckwash-edge-agent/runtime/control-plane-status.json\"; if (!is_file($$path)) { exit(1); } $$data=json_decode((string)file_get_contents($$path), true); if (!is_array($$data)) { exit(1); } $$loopAt=strtotime((string)($$data[\"last_loop_at\"] ?? \"\")); $$syncAt=strtotime((string)($$data[\"last_successful_sync_at\"] ?? $$data[\"started_at\"] ?? \"\")); if ($$loopAt === false || $$syncAt === false) { exit(1); } $$now=time(); exit((($$now - $$loopAt) <= 30 && ($$now - $$syncAt) <= 90) ? 0 : 1);'",
]
interval: 30s
timeout: 5s
retries: 3
@@ -302,11 +302,11 @@ class departmentLanesRoute
// Get the request data
$name = $response->getRequestParameter('name') ?? null;
$department = $response->getRequestParameter('department') ?? null;
$relay_in_id = $response->getRequestParameter('relay_in_id') ?? null;
$relay_out_id = $response->getRequestParameter('relay_out_id') ?? null;
$relay_machine_id = $response->getRequestParameter('relay_machine_id') ?? null;
$relay_machine_program_picker_id = $response->getRequestParameter('relay_machine_program_picker_id') ?? null;
$relay_machine_cleaner_id = $response->getRequestParameter('relay_machine_cleaner_id') ?? null;
$relay_in_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_in_id') ?? null);
$relay_out_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_out_id') ?? null);
$relay_machine_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_id') ?? null);
$relay_machine_program_picker_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_program_picker_id') ?? null);
$relay_machine_cleaner_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_cleaner_id') ?? null);
$dynamic_image_id = $response->getRequestParameter('dynamic_image_id') ?? null;
$machine_type_id = $response->getRequestParameter('machine_type_id') ?? null;
@@ -323,7 +323,6 @@ class departmentLanesRoute
} else {
$machine_type_id = null;
}
// Remove spaces from the relay_in_id and relay_out_id
// Check if the required fields are set
if ($name && $department) {
// Add the department lane
@@ -362,11 +361,11 @@ class departmentLanesRoute
$id = $response->getRequestParameter('id') ?? null;
$name = $response->getRequestParameter('name') ?? null;
$department = $response->getRequestParameter('department') ?? null;
$relay_in_id = $response->getRequestParameter('relay_in_id') ?? null;
$relay_out_id = $response->getRequestParameter('relay_out_id') ?? null;
$relay_machine_id = $response->getRequestParameter('relay_machine_id') ?? null;
$relay_machine_program_picker_id = $response->getRequestParameter('relay_machine_program_picker_id') ?? null;
$relay_machine_cleaner_id = $response->getRequestParameter('relay_machine_cleaner_id') ?? null;
$relay_in_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_in_id') ?? null);
$relay_out_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_out_id') ?? null);
$relay_machine_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_id') ?? null);
$relay_machine_program_picker_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_program_picker_id') ?? null);
$relay_machine_cleaner_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_cleaner_id') ?? null);
$dynamic_image_id = $response->getRequestParameter('dynamic_image_id') ?? null;
$machine_type_id = $response->getRequestParameter('machine_type_id') ?? null;
@@ -387,19 +386,19 @@ class departmentLanesRoute
$department_lane->department->set((int)$department);
}
if (self::isParametersSet(['relay_in_id'])) {
$department_lane->relay_in_id->set((string)$relay_in_id);
self::syncDepartmentLaneRelayValue($department_lane->relay_in_id, $relay_in_id);
}
if (self::isParametersSet(['relay_out_id'])) {
$department_lane->relay_out_id->set((string)$relay_out_id);
self::syncDepartmentLaneRelayValue($department_lane->relay_out_id, $relay_out_id);
}
if (self::isParametersSet(['relay_machine_id'])) {
$department_lane->relay_machine_id->set((string)$relay_machine_id);
self::syncDepartmentLaneRelayValue($department_lane->relay_machine_id, $relay_machine_id);
}
if (self::isParametersSet(['relay_machine_program_picker_id'])) {
$department_lane->relay_machine_program_picker_id->set((string)$relay_machine_program_picker_id);
self::syncDepartmentLaneRelayValue($department_lane->relay_machine_program_picker_id, $relay_machine_program_picker_id);
}
if (self::isParametersSet(['relay_machine_cleaner_id'])) {
$department_lane->relay_machine_cleaner_id->set((string)$relay_machine_cleaner_id);
self::syncDepartmentLaneRelayValue($department_lane->relay_machine_cleaner_id, $relay_machine_cleaner_id);
}
if (self::isParametersSet(['dynamic_image_id'])) {
$param = $response->getRequestParameter('dynamic_image_id');
@@ -437,4 +436,25 @@ class departmentLanesRoute
]
);
}
private static function normalizeRelayRequestParameter(mixed $value): ?string
{
$normalized = trim((string)($value ?? ''));
if ($normalized === '' || strtolower($normalized) === 'null') {
return null;
}
return $normalized;
}
private static function syncDepartmentLaneRelayValue(mixed $field, mixed $value): void
{
$normalized = self::normalizeRelayRequestParameter($value);
if ($normalized === null) {
$field->nullify();
return;
}
$field->set($normalized);
}
}
@@ -0,0 +1,334 @@
<?php
declare(strict_types=1);
use classes\edge_gateway_manager;
usesApiSuite();
it('serves installer artifacts and recovers gateway runtime status after fresh heartbeats', function (): void {
$department = api_fixtures()->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('<?php');
$claimResponse = api_client()->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' => [
'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');
$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);
});
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();
$timestamp = date('Y-m-d H:i:s', time() - 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);
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,
],
]];
}
@@ -0,0 +1,389 @@
<?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();
$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',
],
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");
$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('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;
}
@@ -0,0 +1,418 @@
<?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']]);
});
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;
}
@@ -0,0 +1,421 @@
<?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'], $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'], $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');
$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('');
$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'
);
expect($closedShell)
->toHaveKey('status', 'COMPLETED')
->and($closedShell['transcript'] ?? null)
->toBe("edge-shell-output\n");
$context['manager']->appendGatewayLogEntry(
$gatewayId,
'Integration log line',
'INFO',
'agent',
'BROKER',
['source' => 'integration']
);
$context['manager']->recordBrokerPresence(
$gatewayId,
'connected',
'broker-connection-1',
null,
['transport' => 'ws']
);
$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();
}
});
/**
* @return array{cleanup:ApiCleanup,fixtures:ApiFixtures,manager:edge_gateway_manager,operations:edge_gateway_operation_service,mysqli:mysqli}
*/
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'],
];
}
/**
* @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, int $gatewayId, int $secondsAgo): void
{
$timestamp = date('Y-m-d H:i:s', time() - 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);
}
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;
}
@@ -445,6 +445,25 @@ final class ApiFixtures
];
}
/**
* @param array<int, string> $permissions
* @param array<string, mixed> $userAttributes
* @return array{user:array<string,mixed>,token:string,headers:array<string,string>}
*/
public function createEdgeOperatorSession(int $departmentId, array $permissions = [], array $userAttributes = []): array
{
if ($departmentId <= 0) {
throw new RuntimeException('Edge operator sessions require a positive department id.');
}
$permissions = array_values(array_unique(array_merge(
['modules_shelly_config', 'department_access_' . $departmentId],
$permissions
)));
return $this->createUserSession($permissions, $userAttributes);
}
/**
* @param array<int, string> $permissions
* @return array{user:array<string,mixed>,subuser:array<string,mixed>,token:string,headers:array<string,string>}
@@ -593,6 +612,11 @@ final class ApiFixtures
], $extraHeaders);
}
public function clearEdgeGatewayViewCache(): void
{
$this->deleteRedisPattern('edge_gateway:view:v1:*');
}
public function fetchRowById(string $table, int $id): ?array
{
$table = $this->sanitizeIdentifier($table);
@@ -600,6 +624,395 @@ final class ApiFixtures
return $this->queryOneBySql("SELECT * FROM `{$table}` WHERE id = {$id} LIMIT 1");
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createEdgeInstallToken(array $attributes): array
{
$departmentId = (int)($attributes['department_id'] ?? 0);
if ($departmentId <= 0) {
throw new RuntimeException('Edge install tokens require department_id.');
}
$token = (string)($attributes['token'] ?? (bin2hex(random_bytes(18)) . $this->uniqueSuffix()));
$createdAt = (string)($attributes['created_at'] ?? $this->now());
$expiresAt = (string)($attributes['expires_at'] ?? date('Y-m-d H:i:s', strtotime($createdAt) + 1800));
$installSession = [
'status' => (string)($attributes['status'] ?? 'PENDING'),
'step' => (string)($attributes['step'] ?? 'PENDING'),
'message' => (string)($attributes['message'] ?? 'Installer command generated. Run it on the gateway host.'),
'started_at' => $createdAt,
'updated_at' => $createdAt,
'terminal' => false,
'gateway_id' => $attributes['gateway_id'] ?? null,
'last_error' => $attributes['last_error'] ?? null,
'diagnostics' => isset($attributes['diagnostics']) && is_array($attributes['diagnostics'])
? (array)$attributes['diagnostics']
: [],
'events' => isset($attributes['events']) && is_array($attributes['events'])
? (array)$attributes['events']
: [
[
'status' => (string)($attributes['status'] ?? 'PENDING'),
'step' => (string)($attributes['step'] ?? 'PENDING'),
'message' => (string)($attributes['message'] ?? 'Installer command generated. Run it on the gateway host.'),
'at' => $createdAt,
],
],
];
$claimTokenId = $this->insertRow('edge_gateway_claim_tokens', [
'department_id' => $departmentId,
'label' => $attributes['label'] ?? ('Edge Install ' . $this->uniqueSuffix()),
'token_hash' => hash('sha256', $token),
'created_by' => $attributes['created_by'] ?? null,
'expires_at' => $expiresAt,
'used_at' => $attributes['used_at'] ?? null,
'metadata_json' => array_merge(
isset($attributes['metadata']) && is_array($attributes['metadata']) ? (array)$attributes['metadata'] : [],
['install_session' => $installSession]
),
'created_at' => $createdAt,
'updated_at' => $attributes['updated_at'] ?? $createdAt,
'deleted_at' => $attributes['deleted_at'] ?? null,
]);
$this->cleanup->add(fn() => $this->deleteById('edge_gateway_claim_tokens', $claimTokenId));
return [
'claim_token_id' => $claimTokenId,
'department_id' => $departmentId,
'label' => $attributes['label'] ?? null,
'token' => $token,
'expires_at' => $expiresAt,
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createClaimedEdgeGateway(array $attributes): array
{
$departmentId = (int)($attributes['department_id'] ?? 0);
if ($departmentId <= 0) {
throw new RuntimeException('Claimed edge gateways require department_id.');
}
$agentToken = (string)($attributes['agent_token'] ?? (bin2hex(random_bytes(24)) . $this->uniqueSuffix()));
$createdAt = (string)($attributes['created_at'] ?? $this->now());
$metadata = array_merge([
'credentials_rotated_at' => $createdAt,
'agent_runtime' => 'compose-php',
'runtime_mode' => 'compose',
'update_window' => '02:00-04:00',
'container_health' => [
'overall_status' => 'PENDING',
'services' => [],
],
'outbox_status' => [
'depth' => 0,
'oldest_age_seconds' => 0,
'last_flushed_at' => null,
'pending_types' => [],
],
'rollback_status' => [
'state' => 'NONE',
'reason' => null,
'at' => null,
],
'last_sync_at' => null,
], isset($attributes['metadata']) && is_array($attributes['metadata']) ? (array)$attributes['metadata'] : []);
$gatewayId = $this->insertRow('edge_gateways', [
'department_id' => $departmentId,
'label' => (string)($attributes['label'] ?? ('Edge Gateway ' . $this->uniqueSuffix())),
'hostname' => $attributes['hostname'] ?? ('edge-' . $this->uniqueSuffix()),
'agent_token_hash' => hash('sha256', $agentToken),
'status' => (string)($attributes['status'] ?? 'ONLINE'),
'transport_mode' => (string)($attributes['transport_mode'] ?? 'gateway'),
'release_channel' => (string)($attributes['release_channel'] ?? 'stable'),
'installed_version' => $attributes['installed_version'] ?? 'php-agent-v1',
'target_version' => $attributes['target_version'] ?? ($attributes['installed_version'] ?? 'php-agent-v1'),
'last_heartbeat_at' => $attributes['last_heartbeat_at'] ?? $createdAt,
'last_seen_ip' => $attributes['last_seen_ip'] ?? '127.0.0.1',
'discovery_status' => (string)($attributes['discovery_status'] ?? 'PENDING'),
'is_primary' => $attributes['is_primary'] ?? 1,
'metadata_json' => $metadata,
'created_at' => $createdAt,
'updated_at' => $attributes['updated_at'] ?? $createdAt,
'deleted_at' => $attributes['deleted_at'] ?? null,
]);
$this->cleanup->add(function () use ($gatewayId): void {
$this->deleteWhere('edge_gateway_operation_events', ['gateway_id' => $gatewayId]);
$this->deleteWhere('edge_gateway_operations', ['gateway_id' => $gatewayId]);
$this->deleteWhere('edge_gateway_command_jobs', ['gateway_id' => $gatewayId]);
$this->deleteWhere('edge_gateway_device_inventory', ['gateway_id' => $gatewayId]);
$this->deleteWhere('edge_gateway_relay_bindings', ['gateway_id' => $gatewayId]);
$this->deleteWhere('edge_gateway_log_entries', ['gateway_id' => $gatewayId]);
$this->deleteWhere('edge_gateway_shell_sessions', ['gateway_id' => $gatewayId]);
$this->deleteWhere('edge_gateway_audit_logs', ['gateway_id' => $gatewayId]);
$this->deleteById('edge_gateways', $gatewayId);
$this->clearEdgeGatewayViewCache();
});
return [
'id' => $gatewayId,
'department_id' => $departmentId,
'label' => (string)($attributes['label'] ?? ''),
'agent_token' => $agentToken,
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createEdgeCommandJob(array $attributes): array
{
$gatewayId = (int)($attributes['gateway_id'] ?? 0);
if ($gatewayId <= 0) {
throw new RuntimeException('Edge command jobs require gateway_id.');
}
$jobId = $this->insertRow('edge_gateway_command_jobs', [
'gateway_id' => $gatewayId,
'command_type' => (string)($attributes['command_type'] ?? 'DISCOVER_SHELLY'),
'status' => (string)($attributes['status'] ?? 'PENDING'),
'request_json' => isset($attributes['request']) && is_array($attributes['request']) ? (array)$attributes['request'] : [],
'response_json' => isset($attributes['response']) && is_array($attributes['response']) ? (array)$attributes['response'] : [],
'delivery_json' => isset($attributes['delivery']) && is_array($attributes['delivery']) ? (array)$attributes['delivery'] : [],
'correlation_id' => (string)($attributes['correlation_id'] ?? ('edge-command-' . $this->uniqueSuffix())),
'requested_by' => $attributes['requested_by'] ?? null,
'requested_at' => $attributes['requested_at'] ?? $this->now(),
'completed_at' => $attributes['completed_at'] ?? null,
'error_message' => $attributes['error_message'] ?? null,
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
'deleted_at' => $attributes['deleted_at'] ?? null,
]);
$this->cleanup->add(fn() => $this->deleteById('edge_gateway_command_jobs', $jobId));
return [
'id' => $jobId,
'gateway_id' => $gatewayId,
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createEdgeOperation(array $attributes): array
{
$gatewayId = (int)($attributes['gateway_id'] ?? 0);
if ($gatewayId <= 0) {
throw new RuntimeException('Edge operations require gateway_id.');
}
$operationId = $this->insertRow('edge_gateway_operations', [
'gateway_id' => $gatewayId,
'type' => (string)($attributes['type'] ?? 'DISCOVERY'),
'operation_type' => (string)($attributes['operation_type'] ?? ($attributes['type'] ?? 'DISCOVERY')),
'status' => (string)($attributes['status'] ?? 'PENDING'),
'request_json' => isset($attributes['request']) && is_array($attributes['request']) ? (array)$attributes['request'] : [],
'summary_json' => isset($attributes['summary']) && is_array($attributes['summary']) ? (array)$attributes['summary'] : [
'label' => 'Queued',
'progress' => 0,
'retryable' => true,
],
'result_json' => isset($attributes['result']) && is_array($attributes['result']) ? (array)$attributes['result'] : [],
'error_code' => $attributes['error_code'] ?? null,
'error_message' => $attributes['error_message'] ?? null,
'correlation_id' => (string)($attributes['correlation_id'] ?? ('edge-operation-' . $this->uniqueSuffix())),
'agent_instance_id' => $attributes['agent_instance_id'] ?? null,
'lease_expires_at' => $attributes['lease_expires_at'] ?? null,
'last_progress_at' => $attributes['last_progress_at'] ?? null,
'attempt_count' => $attributes['attempt_count'] ?? 0,
'requested_by' => $attributes['requested_by'] ?? null,
'requested_at' => $attributes['requested_at'] ?? $this->now(),
'started_at' => $attributes['started_at'] ?? null,
'completed_at' => $attributes['completed_at'] ?? null,
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
'deleted_at' => $attributes['deleted_at'] ?? null,
]);
$this->cleanup->add(function () use ($gatewayId, $operationId): void {
$this->deleteWhere('edge_gateway_operation_events', [
'gateway_id' => $gatewayId,
'operation_id' => $operationId,
]);
$this->deleteById('edge_gateway_operations', $operationId);
});
return [
'id' => $operationId,
'gateway_id' => $gatewayId,
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createEdgeOperationEvent(array $attributes): array
{
$gatewayId = (int)($attributes['gateway_id'] ?? 0);
$operationId = (int)($attributes['operation_id'] ?? 0);
if ($gatewayId <= 0 || $operationId <= 0) {
throw new RuntimeException('Edge operation events require gateway_id and operation_id.');
}
$eventId = $this->insertRow('edge_gateway_operation_events', [
'gateway_id' => $gatewayId,
'operation_id' => $operationId,
'stage' => (string)($attributes['stage'] ?? 'RECORDED'),
'level' => (string)($attributes['level'] ?? 'INFO'),
'code' => $attributes['code'] ?? null,
'message' => (string)($attributes['message'] ?? 'Edge operation event'),
'context_json' => isset($attributes['context']) && is_array($attributes['context']) ? (array)$attributes['context'] : [],
'counts_json' => isset($attributes['counts']) && is_array($attributes['counts']) ? (array)$attributes['counts'] : [],
'payload_json' => isset($attributes['payload']) && is_array($attributes['payload']) ? (array)$attributes['payload'] : [],
'created_at' => $attributes['created_at'] ?? $this->now(),
'deleted_at' => $attributes['deleted_at'] ?? null,
]);
$this->cleanup->add(fn() => $this->deleteById('edge_gateway_operation_events', $eventId));
return [
'id' => $eventId,
'gateway_id' => $gatewayId,
'operation_id' => $operationId,
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createEdgeAuditLog(array $attributes): array
{
$gatewayId = (int)($attributes['gateway_id'] ?? 0);
$departmentId = (int)($attributes['department_id'] ?? 0);
if ($gatewayId <= 0 || $departmentId <= 0) {
throw new RuntimeException('Edge audit logs require gateway_id and department_id.');
}
$auditLogId = $this->insertRow('edge_gateway_audit_logs', [
'gateway_id' => $gatewayId,
'department_id' => $departmentId,
'action' => (string)($attributes['action'] ?? 'EDGE_AUDIT'),
'actor_user_id' => $attributes['actor_user_id'] ?? null,
'actor_type' => (string)($attributes['actor_type'] ?? 'USER'),
'severity' => (string)($attributes['severity'] ?? 'INFO'),
'context_json' => isset($attributes['context']) && is_array($attributes['context']) ? (array)$attributes['context'] : [],
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
]);
$this->cleanup->add(fn() => $this->deleteById('edge_gateway_audit_logs', $auditLogId));
return [
'id' => $auditLogId,
'gateway_id' => $gatewayId,
'department_id' => $departmentId,
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createEdgeLogEntry(array $attributes): array
{
$gatewayId = (int)($attributes['gateway_id'] ?? 0);
if ($gatewayId <= 0) {
throw new RuntimeException('Edge log entries require gateway_id.');
}
$gatewayRow = $this->fetchRowById('edge_gateways', $gatewayId);
$departmentId = (int)($attributes['department_id'] ?? ($gatewayRow['department_id'] ?? 0));
$logEntryId = $this->insertRow('edge_gateway_log_entries', [
'gateway_id' => $gatewayId,
'department_id' => $departmentId > 0 ? $departmentId : null,
'level' => (string)($attributes['level'] ?? 'INFO'),
'stream' => (string)($attributes['stream'] ?? 'agent'),
'source' => (string)($attributes['source'] ?? 'BROKER'),
'message' => (string)($attributes['message'] ?? 'Edge gateway log entry'),
'context_json' => isset($attributes['context']) && is_array($attributes['context']) ? (array)$attributes['context'] : [],
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
]);
$this->cleanup->add(fn() => $this->deleteById('edge_gateway_log_entries', $logEntryId));
return [
'id' => $logEntryId,
'gateway_id' => $gatewayId,
];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createEdgeShellSession(array $attributes): array
{
$gatewayId = (int)($attributes['gateway_id'] ?? 0);
if ($gatewayId <= 0) {
throw new RuntimeException('Edge shell sessions require gateway_id.');
}
$gatewayRow = $this->fetchRowById('edge_gateways', $gatewayId);
$departmentId = (int)($attributes['department_id'] ?? ($gatewayRow['department_id'] ?? 0));
if ($departmentId <= 0) {
throw new RuntimeException('Edge shell sessions require department_id or a valid gateway row.');
}
$sessionToken = (string)($attributes['token'] ?? bin2hex(random_bytes(24)));
$createdAt = (string)($attributes['created_at'] ?? $this->now());
$expiresAt = (string)($attributes['expires_at'] ?? date('Y-m-d H:i:s', strtotime($createdAt) + 900));
$sessionId = $this->insertRow('edge_gateway_shell_sessions', [
'gateway_id' => $gatewayId,
'department_id' => $departmentId,
'actor_user_id' => $attributes['actor_user_id'] ?? null,
'session_token_hash' => hash('sha256', $sessionToken),
'status' => (string)($attributes['status'] ?? 'PENDING'),
'reason' => (string)($attributes['reason'] ?? 'Diagnostic shell session'),
'connection_id' => $attributes['connection_id'] ?? null,
'cwd' => $attributes['cwd'] ?? '/opt/truckwash-edge-agent',
'shell_command' => $attributes['shell_command'] ?? null,
'shell_args_json' => isset($attributes['shell_args']) && is_array($attributes['shell_args']) ? (array)$attributes['shell_args'] : [],
'cols' => $attributes['cols'] ?? 120,
'terminal_rows' => $attributes['rows'] ?? 32,
'transcript' => $attributes['transcript'] ?? null,
'metadata_json' => isset($attributes['metadata']) && is_array($attributes['metadata']) ? (array)$attributes['metadata'] : [],
'expires_at' => $expiresAt,
'approved_at' => $attributes['approved_at'] ?? $createdAt,
'opened_at' => $attributes['opened_at'] ?? null,
'closed_at' => $attributes['closed_at'] ?? null,
'created_at' => $createdAt,
'updated_at' => $attributes['updated_at'] ?? $createdAt,
'deleted_at' => $attributes['deleted_at'] ?? null,
]);
$this->cleanup->add(fn() => $this->deleteById('edge_gateway_shell_sessions', $sessionId));
return [
'id' => $sessionId,
'gateway_id' => $gatewayId,
'department_id' => $departmentId,
'token' => $sessionToken,
'expires_at' => $expiresAt,
];
}
public function cleanupDeleteById(string $table, int $id): void
{
$this->cleanup->add(fn() => $this->deleteById($table, $id));
@@ -49,3 +49,21 @@ function assert_api_envelope(ApiResponse $response): ApiResponse
{
return $response->assertEnvelope();
}
function edge_test_broker_secret(): string
{
$secret = trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: ''));
return $secret !== '' ? $secret : 'truckwash-edge-test-secret';
}
/**
* @param array<string, string> $extraHeaders
* @return array<string, string>
*/
function edge_test_broker_headers(array $extraHeaders = []): array
{
return array_merge([
'X-Edge-Broker-Secret' => edge_test_broker_secret(),
], $extraHeaders);
}
@@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
use classes\db;
use Tests\Support\Api\ApiCleanup;
use Tests\Support\Api\ApiFixtures;
use Tests\Support\Api\ApiSchemaBootstrap;
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/Api/ApiCleanup.php';
require_once __DIR__ . '/Api/ApiFixtures.php';
require_once __DIR__ . '/Api/ApiSchemaBootstrap.php';
app_require('classes/db.php');
app_require('classes/edge_gateway_manager.php');
$action = strtolower(trim((string)($argv[1] ?? '')));
if ($action === '') {
edge_gateway_e2e_fixture_fail('Missing action. Expected create or cleanup.');
}
try {
if ($action === 'create') {
$context = edge_gateway_e2e_fixture_context();
$fixtures = $context['fixtures'];
$department = $fixtures->createDepartment([
'name' => 'Edge Gateway E2E Department',
]);
$session = $fixtures->createEdgeOperatorSession((int)$department['id'], [], [
'display_name' => 'Edge Gateway E2E Operator',
]);
edge_gateway_e2e_fixture_output([
'department_id' => (int)$department['id'],
'user_id' => (int)$session['user']['id'],
'group_id' => (int)$session['user']['group_id'],
'customer_number' => (int)$session['user']['customer_number'],
'auth_token' => (string)$session['token'],
'broker_secret' => edge_test_broker_secret(),
]);
}
if ($action === 'cleanup') {
$encoded = trim((string)($argv[2] ?? ''));
if ($encoded === '') {
edge_gateway_e2e_fixture_fail('Cleanup requires a base64url payload argument.');
}
$payload = json_decode(base64_decode(strtr($encoded, '-_', '+/')) ?: '', true);
if (!is_array($payload)) {
edge_gateway_e2e_fixture_fail('Invalid cleanup payload.');
}
$context = edge_gateway_e2e_fixture_context();
$db = $context['mysqli'];
$departmentId = (int)($payload['department_id'] ?? 0);
$userId = (int)($payload['user_id'] ?? 0);
$groupId = (int)($payload['group_id'] ?? 0);
if ($departmentId > 0) {
$gatewayIds = edge_gateway_e2e_gateway_ids($db, $departmentId);
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_operation_events', $gatewayIds);
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_operations', $gatewayIds);
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_command_jobs', $gatewayIds);
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_log_entries', $gatewayIds);
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_shell_sessions', $gatewayIds);
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_device_inventory', $gatewayIds);
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_relay_bindings', $gatewayIds);
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_audit_logs', $gatewayIds);
$db->query('DELETE FROM edge_gateway_claim_tokens WHERE department_id = ' . $departmentId);
$db->query('DELETE FROM edge_gateways WHERE department_id = ' . $departmentId);
$db->query('DELETE FROM department_variables WHERE department_id = ' . $departmentId);
}
if ($userId > 0) {
$db->query('DELETE FROM tokens WHERE user_id = ' . $userId);
$db->query('DELETE FROM users WHERE id = ' . $userId);
}
if ($groupId > 0) {
$db->query('DELETE FROM groups_permissions WHERE group_id = ' . $groupId);
$db->query('DELETE FROM groups WHERE id = ' . $groupId);
}
if ($departmentId > 0) {
$db->query('DELETE FROM departments WHERE id = ' . $departmentId);
}
edge_gateway_e2e_fixture_output(['ok' => true]);
}
edge_gateway_e2e_fixture_fail('Unsupported action: ' . $action);
} catch (Throwable $throwable) {
edge_gateway_e2e_fixture_fail($throwable->getMessage());
}
/**
* @return array{mysqli:mysqli,fixtures:ApiFixtures}
*/
function edge_gateway_e2e_fixture_context(): array
{
static $context = null;
if ($context !== null) {
return $context;
}
$dbConfig = edge_gateway_e2e_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();
$context = [
'mysqli' => $mysqli,
'fixtures' => new ApiFixtures($mysqli, null, new ApiCleanup()),
];
return $context;
}
/**
* @return array{host:string,user:string,password:string,database:string,port:int}
*/
function edge_gateway_e2e_db_config(): array
{
$target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live')));
if ($target !== 'debug') {
$target = 'live';
}
$host = edge_gateway_e2e_config_value('CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', $target);
$user = edge_gateway_e2e_config_value('CONFIG_DB_USER', 'CONFIG_DB_DEBUG_USER', $target);
$password = edge_gateway_e2e_config_value('CONFIG_DB_PASSWORD', 'CONFIG_DB_DEBUG_PASSWORD', $target);
$database = edge_gateway_e2e_config_value('CONFIG_DB_DATABASE', 'CONFIG_DB_DEBUG_DATABASE', $target);
$port = (int)(edge_gateway_e2e_config_value('CONFIG_DB_PORT', 'CONFIG_DB_DEBUG_PORT', $target) ?: '3306');
if ($host === '' || $user === '' || $database === '') {
throw new RuntimeException('Missing database configuration for edge gateway E2E fixtures.');
}
return [
'host' => $host,
'user' => $user,
'password' => $password,
'database' => $database,
'port' => $port > 0 ? $port : 3306,
];
}
function edge_gateway_e2e_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;
}
/**
* @return array<int, int>
*/
function edge_gateway_e2e_gateway_ids(mysqli $db, int $departmentId): array
{
$ids = [];
$result = $db->query('SELECT id FROM edge_gateways WHERE department_id = ' . $departmentId);
if ($result === false) {
return [];
}
while ($row = $result->fetch_assoc()) {
if (isset($row['id']) && is_numeric($row['id'])) {
$ids[] = (int)$row['id'];
}
}
$result->free();
return $ids;
}
/**
* @param array<int, int> $gatewayIds
*/
function edge_gateway_e2e_delete_by_gateway_ids(mysqli $db, string $table, array $gatewayIds): void
{
$gatewayIds = array_values(array_unique(array_filter(array_map('intval', $gatewayIds), static fn(int $id): bool => $id > 0)));
if ($gatewayIds === []) {
return;
}
$db->query('DELETE FROM `' . $table . '` WHERE gateway_id IN (' . implode(', ', $gatewayIds) . ')');
}
/**
* @param array<string, mixed> $payload
*/
function edge_gateway_e2e_fixture_output(array $payload): void
{
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL;
exit(0);
}
function edge_gateway_e2e_fixture_fail(string $message): never
{
fwrite(STDERR, $message . PHP_EOL);
exit(1);
}
@@ -95,6 +95,14 @@ function integration_enabled(): bool
return getenv('RUN_INTEGRATION_TESTS') === '1';
}
$edgeBrokerSharedSecret = trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: ''));
if ($edgeBrokerSharedSecret === '') {
$edgeBrokerSharedSecret = 'truckwash-edge-test-secret';
putenv('EDGE_BROKER_SHARED_SECRET=' . $edgeBrokerSharedSecret);
$_ENV['EDGE_BROKER_SHARED_SECRET'] = $edgeBrokerSharedSecret;
$_SERVER['EDGE_BROKER_SHARED_SECRET'] = $edgeBrokerSharedSecret;
}
function run_legacy_script(string $relativeScriptPath): array
{
$script = app_path($relativeScriptPath);
@@ -0,0 +1,17 @@
<?php
it('nullifies department lane relay fields when blank select values are submitted', function (): void {
$route = file_get_contents(app_path('routes/departmentLanesRoute.php'));
expect($route)->not->toBeFalse();
expect($route)->toContain('private static function normalizeRelayRequestParameter');
expect($route)->toContain('private static function syncDepartmentLaneRelayValue');
expect($route)->toContain('$field->nullify();');
expect($route)->toContain('self::syncDepartmentLaneRelayValue($department_lane->relay_in_id, $relay_in_id);');
expect($route)->toContain('self::syncDepartmentLaneRelayValue($department_lane->relay_out_id, $relay_out_id);');
expect($route)->toContain('self::syncDepartmentLaneRelayValue($department_lane->relay_machine_id, $relay_machine_id);');
expect($route)->toContain(
'self::syncDepartmentLaneRelayValue($department_lane->relay_machine_program_picker_id, $relay_machine_program_picker_id);'
);
expect($route)->toContain('self::syncDepartmentLaneRelayValue($department_lane->relay_machine_cleaner_id, $relay_machine_cleaner_id);');
});
@@ -74,11 +74,17 @@ it('derives relay fallback and transport health details without shell or update
'last_heartbeat_at' => '2026-04-08 10:04:30',
'department_transport_mode' => edge_gateway_manager::TRANSPORT_MODE_GATEWAY,
'metadata' => [
'last_sync_at' => '2026-04-08 10:04:20',
'broker_presence' => [
'connected' => false,
'last_seen_at' => '2026-04-08 10:03:00',
'last_error' => 'broker timeout',
],
'control_plane_status' => [
'last_successful_sync_at' => '2026-04-08 10:04:10',
'last_transport_failure_at' => '2026-04-08 10:04:12',
'last_transport_error' => 'POST https://api.truckwash.io/edge-agent/gateways/17/heartbeat returned HTTP 502',
],
],
'operational_snapshot' => [
'command_backlog' => 2,
@@ -132,6 +138,10 @@ it('derives relay fallback and transport health details without shell or update
expect($gateway['fallback_summary']['local_only_relays'])->toBe(1);
expect($gateway['transport_health']['status'])->toBe(edge_gateway_manager::STATUS_DEGRADED);
expect($gateway['transport_health']['recommended_action'])->toBe('retry_discovery');
expect($gateway['transport_health']['last_successful_sync_at'])->toBe('2026-04-08 10:04:30');
expect($gateway['transport_health']['last_transport_failure_at'])->toBe('2026-04-08 10:04:12');
expect($gateway['transport_health']['last_transport_error'])->toContain('returned HTTP 502');
expect($gateway['last_sync_at'])->toBe('2026-04-08 10:04:30');
expect($gateway['last_successful_discovery_at'])->toBe('2026-04-08 10:02:00');
expect($gateway['diagnostics'])->not->toBeEmpty();
expect($gateway['error_state']['code'])->toBe('EDGE_GATEWAY_OPERATION_TIMEOUT');
@@ -6,6 +6,7 @@ it('builds the installer around the compose stack artifacts and management polli
$stackServiceSource = file_get_contents(app_path('resources/edge-gateway-agent/truckwash-edge-gateway-stack.service'));
$launcherSource = file_get_contents(app_path('resources/edge-gateway-agent/gateway-launcher.sh'));
$composeSource = file_get_contents(app_path('resources/edge-gateway-agent/docker-compose.gateway.yml'));
$agentSource = file_get_contents(app_path('resources/edge-gateway-agent/agent.php'));
$edgeDockerfileSource = file_get_contents(app_path('resources/edge-gateway-agent/Dockerfile.edge-agent'));
$workerDockerfileSource = file_get_contents(app_path('resources/edge-gateway-agent/Dockerfile.lan-worker'));
$autoUpdaterSource = file_get_contents(app_path('resources/edge-gateway-agent/auto-updater.php'));
@@ -14,6 +15,7 @@ it('builds the installer around the compose stack artifacts and management polli
expect($managerSource)->not->toBeFalse();
expect($launcherSource)->not->toBeFalse();
expect($composeSource)->not->toBeFalse();
expect($agentSource)->not->toBeFalse();
expect($edgeDockerfileSource)->not->toBeFalse();
expect($workerDockerfileSource)->not->toBeFalse();
expect($autoUpdaterSource)->not->toBeFalse();
@@ -66,7 +68,11 @@ it('builds the installer around the compose stack artifacts and management polli
expect($composeSource)->toContain('condition: service_healthy');
expect($composeSource)->toContain("minio:\n condition: service_started");
expect($composeSource)->toContain("mariadb:\n condition: service_started");
expect($composeSource)->toContain("test: [\"CMD-SHELL\", \"kill -0 1\"]");
expect($composeSource)->toContain('/opt/truckwash-edge-agent/runtime/control-plane-status.json');
expect($composeSource)->toContain('$$data[\\"last_loop_at\\"]');
expect($composeSource)->toContain('$$data[\\"last_successful_sync_at\\"]');
expect($composeSource)->toContain('<= 30');
expect($composeSource)->toContain('<= 90');
expect($composeSource)->toContain("http://127.0.0.1:8090/health");
expect($composeSource)->toContain('$$json=@file_get_contents(\'http://127.0.0.1:8090/health\');');
expect($composeSource)->toContain('$$path=\"/opt/truckwash-edge-agent/runtime/auto-updater-heartbeat.json\"');
@@ -76,6 +82,15 @@ it('builds the installer around the compose stack artifacts and management polli
expect($composeSource)->toContain('container_name: truckwash-mariadb');
expect($composeSource)->toContain('container_name: truckwash-minio');
expect($composeSource)->toContain('container_name: truckwash-auto-updater');
expect($agentSource)->toContain('private string $controlPlaneStatusPath;');
expect($agentSource)->toContain('control-plane-status.json');
expect($agentSource)->toContain("'control_plane_status' => \$this->buildControlPlaneStatusPayload()");
expect($agentSource)->toContain("'last_heartbeat_attempt_at'");
expect($agentSource)->toContain("'last_heartbeat_success_at'");
expect($agentSource)->toContain("'last_successful_sync_at'");
expect($agentSource)->toContain("'last_transport_failure_at'");
expect($agentSource)->toContain("'last_transport_error'");
expect($agentSource)->toContain('private function recordTransportFailure(string $context, Throwable $throwable): void');
expect($edgeDockerfileSource)->toContain('FROM ${BASE_IMAGE}');
expect($edgeDockerfileSource)->toContain('COPY agent.php /opt/truckwash-edge-agent/agent.php');
expect($edgeDockerfileSource)->not->toContain('docker-php-ext-install');
@@ -5,69 +5,181 @@ app_require('classes/shelly_relay_inventory.php');
use classes\shelly_relay_inventory;
it('normalizes owned Shelly devices into relay select options', function (): void {
$inventory = (new shelly_relay_inventory())->setInventoryFetcher(static function (): array {
return [
'isok' => true,
'data' => [
'devices_status' => [
'device-key-1' => [
'_dev_info' => [
'id' => 'shelly-plus-01',
'code' => 'SPSW-001PE16EU',
'online' => 1,
$inventory = (new shelly_relay_inventory())
->setInventoryFetcher(static function (): array {
return [
'isok' => true,
'data' => [
'devices_status' => [
'device-key-1' => [
'_dev_info' => [
'id' => 'shelly-plus-01',
'code' => 'SPSW-001PE16EU',
'model' => 'Shelly Plus 1PM',
'online' => 1,
],
'name' => 'Entry Gate',
'status' => [
'switch:0' => [
'output' => false,
'name' => 'Entrance Relay',
],
],
],
'name' => 'Entry Gate',
'status' => [
'switch:0' => [
'output' => false,
'device-key-2' => [
'_dev_info' => [
'id' => 'shelly-pro-03',
'code' => 'SPSW-201XE16EU',
'model' => 'Shelly Pro 2PM',
],
'name' => 'Machine Cabinet',
'relays' => [
['ison' => false, 'name' => 'Program Picker'],
],
],
'device-key-3' => [
'_dev_info' => [
'id' => 'shelly-legacy-02',
'code' => 'SHSW-1',
'model' => 'Shelly 1',
'online' => 0,
],
'relays' => [
['ison' => false],
],
],
'device-key-4' => [
'_dev_info' => [
'id' => 'shelly-sensor-01',
'code' => 'SHHT-1',
'model' => 'Shelly H&T',
'online' => 1,
],
'sensor' => [
'temperature' => 21.5,
],
],
],
'device-key-2' => [
'_dev_info' => [
'id' => 'shelly-legacy-02',
'code' => 'SHSW-1',
'online' => 0,
],
];
})
->setDeviceListFetcher(static function (): array {
return [
'isok' => true,
'data' => [
'devices' => [
'shelly-plus-01' => [
'id' => 'shelly-plus-01',
'name' => 'Gate From Shelly Cloud',
'type' => 'SPSW-001PE16EU',
'cloud_online' => true,
],
'relays' => [
['ison' => false],
],
],
'device-key-3' => [
'_dev_info' => [
'id' => 'shelly-sensor-01',
'code' => 'SHHT-1',
'online' => 1,
],
'sensor' => [
'temperature' => 21.5,
'shelly-pro-03' => [
'id' => 'shelly-pro-03',
'name' => 'Program Picker From Shelly Cloud',
'type' => 'SPSW-201XE16EU',
'cloud_online' => false,
],
],
],
],
];
});
];
});
expect($inventory->listRelayOptions())->toBe([
[
'id' => 'shelly-plus-01',
'name' => 'Entry Gate (shelly-plus-01) · SPSW-001PE16EU',
'name' => 'Gate From Shelly Cloud (Shelly Plus 1PM)',
'device_id' => 'shelly-plus-01',
'device_name' => 'Entry Gate',
'device_name' => 'Gate From Shelly Cloud',
'cloud_name' => 'Gate From Shelly Cloud',
'device_type' => 'Shelly Plus 1PM',
'code' => 'SPSW-001PE16EU',
'control_type' => 'Switch',
'control_name' => 'Entrance Relay',
'status_color' => 'Green',
'online' => true,
],
[
'id' => 'shelly-pro-03',
'name' => 'Program Picker From Shelly Cloud (Shelly Pro 2PM)',
'device_id' => 'shelly-pro-03',
'device_name' => 'Program Picker From Shelly Cloud',
'cloud_name' => 'Program Picker From Shelly Cloud',
'device_type' => 'Shelly Pro 2PM',
'code' => 'SPSW-201XE16EU',
'control_type' => 'Relay',
'control_name' => 'Program Picker',
'status_color' => 'Red',
'online' => false,
],
[
'id' => 'shelly-legacy-02',
'name' => 'shelly-legacy-02 · SHSW-1',
'name' => 'shelly-legacy-02 (Shelly 1)',
'device_id' => 'shelly-legacy-02',
'device_name' => null,
'cloud_name' => null,
'device_type' => 'Shelly 1',
'code' => 'SHSW-1',
'control_type' => 'Relay',
'control_name' => null,
'status_color' => 'Red',
'online' => false,
],
]);
});
it('falls back to local device and control names when Shelly cloud list metadata is unavailable', function (): void {
$inventory = (new shelly_relay_inventory())
->setInventoryFetcher(static function (): array {
return [
'isok' => true,
'data' => [
'devices_status' => [
'device-key-1' => [
'_dev_info' => [
'id' => 'shelly-plus-01',
'code' => 'SPSW-001PE16EU',
'model' => 'Shelly Plus 1PM',
'online' => 1,
],
'name' => 'Entry Gate',
'status' => [
'switch:0' => [
'output' => false,
'name' => 'Entrance Relay',
],
],
],
],
],
];
})
->setDeviceListFetcher(static function (): array {
return [
'isok' => false,
'errors' => [
'404' => 'Requested method was not found',
],
];
});
expect($inventory->listRelayOptions())->toBe([
[
'id' => 'shelly-plus-01',
'name' => 'Entry Gate / Entrance Relay (Shelly Plus 1PM)',
'device_id' => 'shelly-plus-01',
'device_name' => 'Entry Gate',
'cloud_name' => null,
'device_type' => 'Shelly Plus 1PM',
'code' => 'SPSW-001PE16EU',
'control_type' => 'Switch',
'control_name' => 'Entrance Relay',
'status_color' => 'Green',
'online' => true,
],
]);
});
it('fails fast when Shelly inventory does not include owned devices status', function (): void {
$inventory = (new shelly_relay_inventory())->setInventoryFetcher(static function (): array {
return [