- Implement `diagnoseBrokerConfiguration` to validate broker URLs, shared secrets, and connection health. - Add diagnostic methods for shared secret validation, including legacy sync support. - Extend relay logging with descriptive context (`relay_name`, `relay_role`) and dynamic messaging. - Update tests to cover broker health and shared secret diagnostics.
759 lines
26 KiB
JavaScript
759 lines
26 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import net from "node:net";
|
|
import WebSocket from "ws";
|
|
|
|
import { createBrokerServer } from "../server.mjs";
|
|
|
|
function collectMessages(socket) {
|
|
const messages = [];
|
|
socket.on("message", (raw) => {
|
|
messages.push(JSON.parse(raw.toString()));
|
|
});
|
|
return messages;
|
|
}
|
|
|
|
function waitForClose(socket) {
|
|
return new Promise((resolve) => {
|
|
socket.once("close", resolve);
|
|
});
|
|
}
|
|
|
|
function rawUpgradeRequest(port, path) {
|
|
return new Promise((resolve, reject) => {
|
|
const socket = net.createConnection({ host: "127.0.0.1", port }, () => {
|
|
socket.write(
|
|
[
|
|
`GET ${path} HTTP/1.1`,
|
|
`Host: 127.0.0.1:${port}`,
|
|
"Connection: Upgrade",
|
|
"Upgrade: websocket",
|
|
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
|
|
"Sec-WebSocket-Version: 13",
|
|
"",
|
|
"",
|
|
].join("\r\n")
|
|
);
|
|
});
|
|
let response = "";
|
|
socket.setEncoding("utf8");
|
|
socket.on("data", (chunk) => {
|
|
response += chunk;
|
|
});
|
|
socket.on("end", () => resolve(response));
|
|
socket.on("error", reject);
|
|
});
|
|
}
|
|
|
|
async function waitFor(predicate, { timeoutMs = 1000, intervalMs = 10, description = "condition" } = {}) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
|
while (Date.now() < deadline) {
|
|
if (predicate()) {
|
|
return;
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
}
|
|
|
|
throw new Error(`Timed out waiting for ${description}`);
|
|
}
|
|
|
|
test("broker defaults to manager auth and fails closed when manager URL is missing", async () => {
|
|
const previousEnv = {
|
|
EDGE_AUTH_MODE: process.env.EDGE_AUTH_MODE,
|
|
EDGE_MANAGER_URL: process.env.EDGE_MANAGER_URL,
|
|
EDGE_PUBLIC_API_URL: process.env.EDGE_PUBLIC_API_URL,
|
|
};
|
|
delete process.env.EDGE_AUTH_MODE;
|
|
delete process.env.EDGE_MANAGER_URL;
|
|
delete process.env.EDGE_PUBLIC_API_URL;
|
|
|
|
let broker;
|
|
try {
|
|
broker = createBrokerServer({ sharedSecret: "secret" });
|
|
assert.equal(broker.state.authMode, "manager");
|
|
assert.equal(broker.state.managerUrl, "");
|
|
|
|
const address = await broker.listen(0);
|
|
const port = address.port;
|
|
const shellResponse = await rawUpgradeRequest(port, "/ws/browser-shell?token=session-token");
|
|
const agentResponse = await rawUpgradeRequest(port, "/ws/agent?gatewayId=701&token=agent-token");
|
|
|
|
assert.doesNotMatch(shellResponse, /101 Switching Protocols/);
|
|
assert.match(shellResponse, /^HTTP\/1\.1 401 Unauthorized/m);
|
|
assert.match(shellResponse, /"error_code":"shell_session_invalid"/);
|
|
assert.match(shellResponse, /Edge manager URL is not configured/);
|
|
|
|
assert.doesNotMatch(agentResponse, /101 Switching Protocols/);
|
|
assert.match(agentResponse, /^HTTP\/1\.1 503 Service Unavailable/m);
|
|
assert.match(agentResponse, /"error_code":"agent_validation_failed"/);
|
|
assert.match(agentResponse, /"stage":"agent_validate"/);
|
|
assert.match(agentResponse, /Edge manager URL is not configured/);
|
|
} finally {
|
|
if (broker) {
|
|
await broker.close();
|
|
}
|
|
for (const [key, value] of Object.entries(previousEnv)) {
|
|
if (value === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test("broker dispatches commands to connected agents", async () => {
|
|
const broker = createBrokerServer({ authMode: "stub", sharedSecret: "secret", commandTimeoutMs: 2000 });
|
|
const address = await broker.listen(0);
|
|
const port = address.port;
|
|
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
|
|
|
|
await new Promise((resolve) => agent.once("open", resolve));
|
|
agent.on("message", (raw) => {
|
|
const message = JSON.parse(raw.toString());
|
|
if (message.type === "COMMAND") {
|
|
agent.send(JSON.stringify({
|
|
type: "COMMAND_RESULT",
|
|
commandId: message.commandId,
|
|
ok: true,
|
|
payload: { online: true, on: true },
|
|
}));
|
|
}
|
|
});
|
|
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/gateways/701/commands`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-edge-broker-secret": "secret",
|
|
},
|
|
body: JSON.stringify({
|
|
commandType: "GET_RELAY_STATUS",
|
|
payload: { relayId: "M-7" },
|
|
}),
|
|
});
|
|
const json = await response.json();
|
|
|
|
assert.equal(response.status, 200);
|
|
assert.equal(json.ok, true);
|
|
assert.equal(json.payload.on, true);
|
|
|
|
agent.terminate();
|
|
await broker.close();
|
|
});
|
|
|
|
test("broker exposes health and shared-secret diagnostics", async () => {
|
|
const broker = createBrokerServer({ authMode: "manager", sharedSecret: "secret", managerUrl: "http://manager.test" });
|
|
const address = await broker.listen(0);
|
|
const port = address.port;
|
|
|
|
const healthResponse = await fetch(`http://127.0.0.1:${port}/api/health`);
|
|
const healthJson = await healthResponse.json();
|
|
|
|
assert.equal(healthResponse.status, 200);
|
|
assert.equal(healthJson.ok, true);
|
|
assert.equal(healthJson.service, "edge-broker");
|
|
assert.equal(healthJson.auth_mode, "manager");
|
|
assert.equal(healthJson.manager_url_configured, true);
|
|
assert.equal(healthJson.shared_secret_configured, true);
|
|
|
|
const invalidSecretResponse = await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
|
|
method: "POST",
|
|
headers: {
|
|
"x-edge-broker-secret": "wrong-secret",
|
|
},
|
|
});
|
|
const invalidSecretJson = await invalidSecretResponse.json();
|
|
|
|
assert.equal(invalidSecretResponse.status, 403);
|
|
assert.equal(invalidSecretJson.ok, false);
|
|
assert.equal(invalidSecretJson.shared_secret_required, true);
|
|
|
|
const validSecretResponse = await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
|
|
method: "POST",
|
|
headers: {
|
|
"x-edge-broker-secret": "secret",
|
|
},
|
|
});
|
|
const validSecretJson = await validSecretResponse.json();
|
|
|
|
assert.equal(validSecretResponse.status, 200);
|
|
assert.equal(validSecretJson.ok, true);
|
|
assert.equal(validSecretJson.shared_secret_required, true);
|
|
|
|
await broker.close();
|
|
});
|
|
|
|
test("broker bridges browser shell sessions through the connected agent", async () => {
|
|
const closedSessions = [];
|
|
const broker = createBrokerServer({
|
|
authMode: "stub",
|
|
validateShellSession: async () => ({ id: "shell-1", gateway_id: "701", reason: "diagnostic" }),
|
|
closeShellSession: async (_id, _token, transcript, reason) => {
|
|
closedSessions.push({ transcript, reason });
|
|
},
|
|
});
|
|
const address = await broker.listen(0);
|
|
const port = address.port;
|
|
|
|
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
|
|
await new Promise((resolve) => agent.once("open", resolve));
|
|
|
|
agent.on("message", (raw) => {
|
|
const message = JSON.parse(raw.toString());
|
|
if (message.type === "OPEN_ROOT_SHELL") {
|
|
agent.send(JSON.stringify({ type: "SHELL_OPENED", sessionId: "shell-1" }));
|
|
agent.send(JSON.stringify({ type: "SHELL_OUTPUT", sessionId: "shell-1", data: "root@pi:~# " }));
|
|
agent.send(JSON.stringify({ type: "SHELL_EXIT", sessionId: "shell-1", code: 0 }));
|
|
}
|
|
});
|
|
|
|
const browser = new WebSocket(`ws://127.0.0.1:${port}/ws/browser-shell?token=session-token`);
|
|
const browserMessages = collectMessages(browser);
|
|
await new Promise((resolve) => browser.once("open", resolve));
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
|
assert.ok(browserMessages.some((message) => message.type === "opened"));
|
|
assert.ok(browserMessages.some((message) => message.type === "output" && /root@pi/.test(message.data)));
|
|
assert.ok(browserMessages.some((message) => message.type === "closed" && message.reason === "agent_exit" && message.code === 0));
|
|
assert.equal(closedSessions.length, 1);
|
|
assert.equal(closedSessions[0].reason, "agent_exit");
|
|
|
|
browser.terminate();
|
|
agent.terminate();
|
|
await broker.close();
|
|
});
|
|
|
|
test("broker forwards browser shell input, resize, and close events to the agent", async () => {
|
|
const broker = createBrokerServer({
|
|
authMode: "stub",
|
|
validateShellSession: async () => ({ id: "shell-2", gateway_id: "701", reason: "diagnostic" }),
|
|
});
|
|
const address = await broker.listen(0);
|
|
const port = address.port;
|
|
|
|
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
|
|
await new Promise((resolve) => agent.once("open", resolve));
|
|
|
|
const agentMessages = collectMessages(agent);
|
|
const browser = new WebSocket(`ws://127.0.0.1:${port}/ws/browser-shell?token=session-token`);
|
|
await new Promise((resolve) => browser.once("open", resolve));
|
|
await waitFor(
|
|
() => agentMessages.some((message) => message.type === "OPEN_ROOT_SHELL" && message.payload.sessionId === "shell-2"),
|
|
{ description: "agent shell open request" }
|
|
);
|
|
|
|
browser.send(JSON.stringify({ type: "input", data: "ls\r" }));
|
|
browser.send(JSON.stringify({ type: "resize", cols: 140, rows: 44 }));
|
|
browser.send(JSON.stringify({ type: "close" }));
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
assert.ok(agentMessages.some((message) => message.type === "OPEN_ROOT_SHELL" && message.payload.sessionId === "shell-2"));
|
|
assert.ok(
|
|
agentMessages.some(
|
|
(message) =>
|
|
message.type === "SHELL_INPUT" &&
|
|
message.payload.sessionId === "shell-2" &&
|
|
message.payload.data === "ls\r"
|
|
)
|
|
);
|
|
assert.ok(
|
|
agentMessages.some(
|
|
(message) =>
|
|
message.type === "RESIZE_ROOT_SHELL" &&
|
|
message.payload.sessionId === "shell-2" &&
|
|
message.payload.cols === 140 &&
|
|
message.payload.rows === 44
|
|
)
|
|
);
|
|
assert.ok(agentMessages.some((message) => message.type === "CLOSE_ROOT_SHELL" && message.payload.sessionId === "shell-2"));
|
|
|
|
browser.terminate();
|
|
agent.terminate();
|
|
await broker.close();
|
|
});
|
|
|
|
test("broker closes browser shell sessions immediately when no agent is connected", async () => {
|
|
const closedSessions = [];
|
|
const broker = createBrokerServer({
|
|
authMode: "stub",
|
|
validateShellSession: async () => ({ id: "shell-3", gateway_id: "701", reason: "diagnostic" }),
|
|
closeShellSession: async (_id, _token, transcript, reason) => {
|
|
closedSessions.push({ transcript, reason });
|
|
},
|
|
});
|
|
const address = await broker.listen(0);
|
|
const port = address.port;
|
|
|
|
const browser = new WebSocket(`ws://127.0.0.1:${port}/ws/browser-shell?token=session-token`);
|
|
const browserMessages = collectMessages(browser);
|
|
await new Promise((resolve) => browser.once("open", resolve));
|
|
await waitForClose(browser);
|
|
await waitFor(() => closedSessions.length === 1, { description: "offline shell session close callback" });
|
|
|
|
assert.ok(
|
|
browserMessages.some(
|
|
(message) =>
|
|
message.type === "closed" &&
|
|
message.reason === "agent_offline" &&
|
|
/not connected to the broker/i.test(message.message)
|
|
)
|
|
);
|
|
assert.deepEqual(closedSessions, [{ transcript: "", reason: "agent_offline" }]);
|
|
|
|
await broker.close();
|
|
});
|
|
|
|
test("broker rejects browser shell upgrades without a token using HTTP diagnostics", async () => {
|
|
const broker = createBrokerServer({ authMode: "stub" });
|
|
const address = await broker.listen(0);
|
|
const port = address.port;
|
|
|
|
const response = await rawUpgradeRequest(port, "/ws/browser-shell");
|
|
|
|
assert.match(response, /^HTTP\/1\.1 400 Bad Request/m);
|
|
assert.match(response, /"error_code":"shell_session_token_missing"/);
|
|
assert.match(response, /"message":"Missing shell session token\."/);
|
|
|
|
await broker.close();
|
|
});
|
|
|
|
test("broker rejects invalid browser shell upgrades without leaking the token", async () => {
|
|
const broker = createBrokerServer({
|
|
authMode: "stub",
|
|
validateShellSession: async () => {
|
|
const error = new Error("Shell session expired");
|
|
error.status = 401;
|
|
error.code = "shell_session_expired";
|
|
throw error;
|
|
},
|
|
});
|
|
const address = await broker.listen(0);
|
|
const port = address.port;
|
|
const rawToken = "session-token-secret";
|
|
|
|
const response = await rawUpgradeRequest(port, `/ws/browser-shell?token=${rawToken}`);
|
|
|
|
assert.match(response, /^HTTP\/1\.1 401 Unauthorized/m);
|
|
assert.match(response, /"error_code":"shell_session_expired"/);
|
|
assert.doesNotMatch(response, new RegExp(rawToken));
|
|
|
|
await broker.close();
|
|
});
|
|
|
|
test("broker closes browser shell sessions when the agent never reports shell opened", async () => {
|
|
const closedSessions = [];
|
|
const broker = createBrokerServer({
|
|
authMode: "stub",
|
|
shellOpenTimeoutMs: 30,
|
|
validateShellSession: async () => ({ id: "shell-timeout", gateway_id: "701", reason: "diagnostic" }),
|
|
closeShellSession: async (_id, _token, transcript, reason, details) => {
|
|
closedSessions.push({ transcript, reason, details });
|
|
},
|
|
});
|
|
const address = await broker.listen(0);
|
|
const port = address.port;
|
|
|
|
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
|
|
const agentMessages = collectMessages(agent);
|
|
await new Promise((resolve) => agent.once("open", resolve));
|
|
|
|
const browser = new WebSocket(`ws://127.0.0.1:${port}/ws/browser-shell?token=session-token`);
|
|
const browserMessages = collectMessages(browser);
|
|
await new Promise((resolve) => browser.once("open", resolve));
|
|
|
|
await waitFor(
|
|
() =>
|
|
agentMessages.some(
|
|
(message) => message.type === "OPEN_ROOT_SHELL" && message.payload.sessionId === "shell-timeout"
|
|
),
|
|
{ description: "agent shell open request before timeout" }
|
|
);
|
|
await waitForClose(browser);
|
|
await waitFor(() => closedSessions.length === 1, { description: "timeout shell session close callback" });
|
|
|
|
assert.ok(browserMessages.some((message) => message.type === "closed" && message.reason === "shell_open_timeout"));
|
|
assert.equal(closedSessions[0].reason, "shell_open_timeout");
|
|
assert.equal(closedSessions[0].details.stage, "shell_open");
|
|
assert.equal(closedSessions[0].details.code, 1011);
|
|
|
|
agent.terminate();
|
|
await broker.close();
|
|
});
|
|
|
|
test("broker closes browser shell sessions when the agent disconnects before shell open", async () => {
|
|
const closedSessions = [];
|
|
const broker = createBrokerServer({
|
|
authMode: "stub",
|
|
validateShellSession: async () => ({ id: "shell-4", gateway_id: "701", reason: "diagnostic" }),
|
|
closeShellSession: async (_id, _token, transcript, reason) => {
|
|
closedSessions.push({ transcript, reason });
|
|
},
|
|
});
|
|
const address = await broker.listen(0);
|
|
const port = address.port;
|
|
|
|
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
|
|
await new Promise((resolve) => agent.once("open", resolve));
|
|
|
|
const agentMessages = collectMessages(agent);
|
|
const browser = new WebSocket(`ws://127.0.0.1:${port}/ws/browser-shell?token=session-token`);
|
|
const browserMessages = collectMessages(browser);
|
|
await new Promise((resolve) => browser.once("open", resolve));
|
|
await waitFor(
|
|
() => agentMessages.some((message) => message.type === "OPEN_ROOT_SHELL" && message.payload.sessionId === "shell-4"),
|
|
{ description: "agent shell open request before disconnect" }
|
|
);
|
|
|
|
agent.terminate();
|
|
await waitForClose(browser);
|
|
await waitFor(() => closedSessions.length === 1, { description: "disconnect shell session close callback" });
|
|
|
|
assert.ok(agentMessages.some((message) => message.type === "OPEN_ROOT_SHELL" && message.payload.sessionId === "shell-4"));
|
|
assert.ok(browserMessages.some((message) => message.type === "closed" && message.reason === "agent_disconnected"));
|
|
assert.deepEqual(closedSessions, [{ transcript: "", reason: "agent_disconnected" }]);
|
|
|
|
await broker.close();
|
|
});
|
|
|
|
test("broker sends an agent welcome before connection progress and backlog dispatch", async () => {
|
|
const broker = createBrokerServer({
|
|
authMode: "stub",
|
|
reportGatewayPresence: async () => ({}),
|
|
requestGatewayBacklog: async () => ({
|
|
dispatch: [
|
|
{
|
|
type: "TASK_DISPATCH",
|
|
taskType: "OPERATION",
|
|
operation: {
|
|
id: 91,
|
|
type: "DISCOVERY",
|
|
request: {},
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
const address = await broker.listen(0);
|
|
const port = address.port;
|
|
|
|
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
|
|
const messages = collectMessages(agent);
|
|
await new Promise((resolve) => agent.once("open", resolve));
|
|
|
|
await waitFor(
|
|
() => messages.some((message) => message.type === "TASK_DISPATCH" && message.operation?.id === 91),
|
|
{ description: "backlog dispatch after connection welcome" }
|
|
);
|
|
|
|
assert.equal(messages[0].type, "WELCOME");
|
|
assert.equal(messages[0].gatewayId, "701");
|
|
assert.equal(typeof messages[0].connectionId, "string");
|
|
assert.ok(messages[0].connectionId.length > 0);
|
|
|
|
const firstProgressIndex = messages.findIndex((message) => message.type === "CONNECTION_PROGRESS");
|
|
const dispatchIndex = messages.findIndex((message) => message.type === "TASK_DISPATCH");
|
|
assert.ok(firstProgressIndex > 0);
|
|
assert.ok(dispatchIndex > firstProgressIndex);
|
|
assert.ok(
|
|
messages.some(
|
|
(message) =>
|
|
message.type === "CONNECTION_PROGRESS" &&
|
|
message.stage === "presence" &&
|
|
message.status === "started"
|
|
)
|
|
);
|
|
assert.ok(
|
|
messages.some(
|
|
(message) =>
|
|
message.type === "CONNECTION_PROGRESS" &&
|
|
message.stage === "backlog" &&
|
|
message.status === "started"
|
|
)
|
|
);
|
|
assert.ok(
|
|
messages.some(
|
|
(message) =>
|
|
message.type === "CONNECTION_PROGRESS" &&
|
|
message.stage === "backlog" &&
|
|
message.status === "succeeded" &&
|
|
message.dispatchCount === 1
|
|
)
|
|
);
|
|
|
|
agent.terminate();
|
|
await broker.close();
|
|
});
|
|
|
|
test("broker sends connection errors to agents after the welcome message", async () => {
|
|
const broker = createBrokerServer({
|
|
authMode: "stub",
|
|
reportGatewayPresence: async () => {
|
|
throw new Error("presence callback unavailable");
|
|
},
|
|
requestGatewayBacklog: async () => {
|
|
throw new Error("backlog sync unavailable");
|
|
},
|
|
});
|
|
const address = await broker.listen(0);
|
|
const port = address.port;
|
|
|
|
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
|
|
const messages = collectMessages(agent);
|
|
await new Promise((resolve) => agent.once("open", resolve));
|
|
|
|
await waitFor(
|
|
() => messages.filter((message) => message.type === "CONNECTION_ERROR").length >= 2,
|
|
{ description: "agent connection error notifications" }
|
|
);
|
|
|
|
assert.equal(messages[0].type, "WELCOME");
|
|
assert.ok(
|
|
messages.some(
|
|
(message) =>
|
|
message.type === "CONNECTION_ERROR" &&
|
|
message.stage === "presence" &&
|
|
message.error === "presence callback unavailable"
|
|
)
|
|
);
|
|
assert.ok(
|
|
messages.some(
|
|
(message) =>
|
|
message.type === "CONNECTION_ERROR" &&
|
|
message.stage === "backlog" &&
|
|
message.error === "backlog sync unavailable"
|
|
)
|
|
);
|
|
|
|
agent.terminate();
|
|
await broker.close();
|
|
});
|
|
|
|
test("broker syncs queued gateway backlog on agent connect and manual sync", async () => {
|
|
const backlogRequests = [];
|
|
const broker = createBrokerServer({
|
|
authMode: "stub",
|
|
sharedSecret: "secret",
|
|
requestGatewayBacklog: async (gatewayId, payload) => {
|
|
backlogRequests.push({ gatewayId, payload });
|
|
return {
|
|
dispatch: [
|
|
{
|
|
type: "TASK_DISPATCH",
|
|
taskType: "OPERATION",
|
|
operation: {
|
|
id: 91,
|
|
type: "DISCOVERY",
|
|
request: {},
|
|
},
|
|
},
|
|
],
|
|
};
|
|
},
|
|
});
|
|
const address = await broker.listen(0);
|
|
const port = address.port;
|
|
|
|
const agent = new WebSocket(
|
|
`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token&agentInstanceId=instance-1`
|
|
);
|
|
const messages = collectMessages(agent);
|
|
await new Promise((resolve) => agent.once("open", resolve));
|
|
|
|
await waitFor(
|
|
() => messages.some((message) => message.type === "TASK_DISPATCH" && message.operation?.id === 91),
|
|
{ description: "initial backlog dispatch" }
|
|
);
|
|
assert.equal(backlogRequests.length, 1);
|
|
assert.equal(backlogRequests[0].gatewayId, "701");
|
|
assert.equal(backlogRequests[0].payload.agent_instance_id, "instance-1");
|
|
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/gateways/701/sync`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-edge-broker-secret": "secret",
|
|
},
|
|
body: JSON.stringify({ gatewayId: 701 }),
|
|
});
|
|
const json = await response.json();
|
|
|
|
assert.equal(response.status, 200);
|
|
assert.equal(json.ok, true);
|
|
await waitFor(() => backlogRequests.length >= 2, { description: "manual sync backlog request" });
|
|
|
|
agent.terminate();
|
|
await broker.close();
|
|
});
|
|
|
|
test("broker fans out telemetry, task, log, and presence updates to browser gateway streams", async () => {
|
|
const telemetryPayloads = [];
|
|
const broker = createBrokerServer({
|
|
authMode: "stub",
|
|
validateAgent: async () => ({ id: "701", gateway_id: "701", label: "CPH Edge 01" }),
|
|
validateBrowserStream: async () => ({
|
|
id: "stream-1",
|
|
gateway_id: "701",
|
|
scopes: ["overview", "tasks", "logs", "statistics"],
|
|
}),
|
|
ingestTelemetry: async (_gatewayId, payload) => {
|
|
telemetryPayloads.push(payload);
|
|
return { gateway: { id: 701, metadata: payload.metadata || {} } };
|
|
},
|
|
ingestTaskEvent: async (_gatewayId, operationId, payload) => ({
|
|
id: operationId,
|
|
status: "IN_PROGRESS",
|
|
latest_event: payload,
|
|
}),
|
|
ingestLogEntry: async (_gatewayId, payload) => ({
|
|
id: 5001,
|
|
...payload,
|
|
}),
|
|
});
|
|
const address = await broker.listen(0);
|
|
const port = address.port;
|
|
|
|
const browser = new WebSocket(`ws://127.0.0.1:${port}/ws/browser-gateway-stream?token=stream-token`);
|
|
const browserMessages = collectMessages(browser);
|
|
await new Promise((resolve) => browser.once("open", resolve));
|
|
|
|
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
|
|
await new Promise((resolve) => agent.once("open", resolve));
|
|
|
|
agent.send(
|
|
JSON.stringify({
|
|
type: "TELEMETRY",
|
|
payload: {
|
|
status: "ONLINE",
|
|
metadata: {
|
|
broker_connected: true,
|
|
},
|
|
},
|
|
})
|
|
);
|
|
agent.send(
|
|
JSON.stringify({
|
|
type: "TASK_EVENT",
|
|
operationId: 41,
|
|
payload: {
|
|
level: "INFO",
|
|
code: "DISCOVERY_RUNNING",
|
|
message: "Discovery is running",
|
|
},
|
|
})
|
|
);
|
|
agent.send(
|
|
JSON.stringify({
|
|
type: "LOG_FRAME",
|
|
payload: {
|
|
level: "INFO",
|
|
stream: "agent",
|
|
source: "EDGE_AGENT",
|
|
message: "Gateway heartbeat acknowledged",
|
|
},
|
|
})
|
|
);
|
|
|
|
await waitFor(
|
|
() => browserMessages.some((message) => message.type === "presence.changed" && message.status === "connected"),
|
|
{ description: "presence update" }
|
|
);
|
|
assert.ok(browserMessages.some((message) => message.type === "gateway.telemetry"));
|
|
assert.ok(browserMessages.some((message) => message.type === "stats.updated"));
|
|
assert.ok(browserMessages.some((message) => message.type === "task.updated" && message.operationId === 41));
|
|
assert.ok(browserMessages.some((message) => message.type === "log.append" && /heartbeat/.test(message.entry?.message)));
|
|
assert.equal(telemetryPayloads[0]?.broker_connection_id?.length > 0, true);
|
|
|
|
browser.terminate();
|
|
agent.terminate();
|
|
await broker.close();
|
|
});
|
|
|
|
test("broker survives telemetry ingestion failures for stale gateways", async () => {
|
|
const broker = createBrokerServer({
|
|
authMode: "stub",
|
|
validateAgent: async () => ({ id: "701", gateway_id: "701", label: "CPH Edge 01" }),
|
|
ingestTelemetry: async () => {
|
|
throw new Error("Edge gateway not found");
|
|
},
|
|
});
|
|
const address = await broker.listen(0);
|
|
const port = address.port;
|
|
|
|
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
|
|
await new Promise((resolve) => agent.once("open", resolve));
|
|
|
|
agent.send(
|
|
JSON.stringify({
|
|
type: "TELEMETRY",
|
|
payload: {
|
|
status: "ONLINE",
|
|
},
|
|
})
|
|
);
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
assert.equal(broker.server.listening, true);
|
|
|
|
agent.terminate();
|
|
await broker.close();
|
|
});
|
|
|
|
test("broker still fans out telemetry when manager ingestion fails", async () => {
|
|
const broker = createBrokerServer({
|
|
authMode: "stub",
|
|
validateAgent: async () => ({ id: "701", gateway_id: "701", label: "CPH Edge 01" }),
|
|
validateBrowserStream: async () => ({
|
|
id: "stream-telemetry-fallback",
|
|
gateway_id: "701",
|
|
scopes: ["overview", "statistics"],
|
|
}),
|
|
ingestTelemetry: async () => {
|
|
throw new Error("manager unavailable");
|
|
},
|
|
});
|
|
const address = await broker.listen(0);
|
|
const port = address.port;
|
|
|
|
const browser = new WebSocket(`ws://127.0.0.1:${port}/ws/browser-gateway-stream?token=stream-token`);
|
|
const browserMessages = collectMessages(browser);
|
|
await new Promise((resolve) => browser.once("open", resolve));
|
|
|
|
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
|
|
await new Promise((resolve) => agent.once("open", resolve));
|
|
|
|
agent.send(
|
|
JSON.stringify({
|
|
type: "TELEMETRY",
|
|
payload: {
|
|
status: "ONLINE",
|
|
metadata: {
|
|
system_metrics: {
|
|
cpu_usage_pct: 31,
|
|
},
|
|
container_health: {
|
|
state: "ONLINE",
|
|
summary: "6/6 containers healthy",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
);
|
|
|
|
await waitFor(
|
|
() => browserMessages.some((message) => message.type === "gateway.telemetry" && message.error === "manager unavailable"),
|
|
{ description: "telemetry fanout after ingest failure" }
|
|
);
|
|
assert.ok(
|
|
browserMessages.some(
|
|
(message) => message.type === "stats.updated" && message.statistics?.system_metrics?.cpu_usage_pct === 31
|
|
)
|
|
);
|
|
|
|
browser.terminate();
|
|
agent.terminate();
|
|
await broker.close();
|
|
});
|