1125 lines
33 KiB
JavaScript
1125 lines
33 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { execFile as execFileCallback } from "node:child_process";
|
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { promisify } from "node:util";
|
|
|
|
import {
|
|
buildStatusReport,
|
|
claimIfNeeded,
|
|
createShellBridge,
|
|
discoverShellyDevices,
|
|
finalizePendingUpdateOnStartup,
|
|
getAgentStatus,
|
|
getRelayStatus,
|
|
loadConfig,
|
|
parseCliArgs,
|
|
processPolledShellAction,
|
|
processPolledCommand,
|
|
runCli,
|
|
runUpdate,
|
|
setRelayState,
|
|
startAgent,
|
|
handleAgentCommand,
|
|
verifyPendingUpdate,
|
|
} from "../dist/agent.mjs";
|
|
|
|
const execFile = promisify(execFileCallback);
|
|
const agentEntryPath = fileURLToPath(new URL("../dist/agent.mjs", import.meta.url));
|
|
|
|
function makeFetchResponse(body) {
|
|
const bytes = Buffer.from(body);
|
|
return {
|
|
ok: true,
|
|
async arrayBuffer() {
|
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
},
|
|
};
|
|
}
|
|
|
|
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("claimIfNeeded persists claimed gateway credentials", async () => {
|
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-"));
|
|
const configPath = path.join(tempDir, "config.json");
|
|
await writeFile(configPath, JSON.stringify({
|
|
apiUrl: "https://api.example.test",
|
|
installToken: "claim-token",
|
|
}));
|
|
|
|
const requests = [];
|
|
const fakeFetch = async (url, options) => {
|
|
requests.push({ url, options });
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return {
|
|
data: {
|
|
gateway: { id: 9001 },
|
|
agent_token: "agent-token",
|
|
release_channel: "stable",
|
|
broker_url: "https://broker.example.test",
|
|
},
|
|
};
|
|
},
|
|
};
|
|
};
|
|
|
|
const config = await claimIfNeeded({
|
|
apiUrl: "https://api.example.test",
|
|
installToken: "claim-token",
|
|
}, configPath, fakeFetch);
|
|
|
|
assert.equal(requests.length, 1);
|
|
assert.equal(config.gatewayId, 9001);
|
|
assert.equal(config.agentToken, "agent-token");
|
|
assert.equal(config.brokerUrl, "https://broker.example.test");
|
|
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("relay status and switch commands support both Shelly RPC and legacy endpoints", async () => {
|
|
const fakeFetch = async (url) => {
|
|
if (String(url).includes("Switch.GetStatus")) {
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return { output: true };
|
|
},
|
|
};
|
|
}
|
|
if (String(url).includes("Switch.Set")) {
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return { output: false };
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected URL: ${url}`);
|
|
};
|
|
|
|
const status = await getRelayStatus({ localIp: "10.1.0.31", channel: 0 }, fakeFetch);
|
|
const switched = await setRelayState({ localIp: "10.1.0.31", channel: 0, on: false }, fakeFetch);
|
|
|
|
assert.equal(status.online, true);
|
|
assert.equal(status.on, true);
|
|
assert.equal(switched.on, false);
|
|
});
|
|
|
|
test("Shelly discovery infers Gen3 from S3 relay model codes when generation is omitted", async () => {
|
|
const inventory = await discoverShellyDevices({ candidateIps: ["192.168.1.2"] }, async (url) => {
|
|
assert.equal(String(url), "http://192.168.1.2/shelly");
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return {
|
|
id: "shelly1minig3-e4b3231f6410",
|
|
mac: "E4B3231F6410",
|
|
model: "S3SW-001X8EU",
|
|
type: "Shelly 1 Mini Gen3",
|
|
num_switches: 1,
|
|
};
|
|
},
|
|
};
|
|
});
|
|
|
|
assert.equal(inventory.length, 1);
|
|
assert.equal(inventory[0].device_id, "E4B3231F6410");
|
|
assert.equal(inventory[0].model, "S3SW-001X8EU");
|
|
assert.equal(inventory[0].capabilities.generation, 3);
|
|
});
|
|
|
|
test("relay status reads use the legacy endpoint when the RPC status endpoint stalls", async () => {
|
|
const urls = [];
|
|
const fakeFetch = async (url) => {
|
|
urls.push(String(url));
|
|
if (String(url).includes("Switch.GetStatus")) {
|
|
return new Promise(() => {});
|
|
}
|
|
|
|
if (String(url) === "http://10.1.0.31/relay/0") {
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return { ison: false };
|
|
},
|
|
};
|
|
}
|
|
|
|
throw new Error(`Unexpected URL: ${url}`);
|
|
};
|
|
const startedAt = Date.now();
|
|
|
|
const status = await getRelayStatus({ localIp: "10.1.0.31", channel: 0, timeoutMs: 50 }, fakeFetch);
|
|
|
|
assert.equal(status.online, true);
|
|
assert.equal(status.on, false);
|
|
assert.deepEqual(urls.sort(), [
|
|
"http://10.1.0.31/relay/0",
|
|
"http://10.1.0.31/rpc/Switch.GetStatus?id=0",
|
|
]);
|
|
assert.ok(Date.now() - startedAt < 250);
|
|
});
|
|
|
|
test("relay switch commands fail quickly when the local Shelly request stalls", async () => {
|
|
let calls = 0;
|
|
const hangingFetch = async () => {
|
|
calls += 1;
|
|
return new Promise(() => {});
|
|
};
|
|
const startedAt = Date.now();
|
|
|
|
await assert.rejects(
|
|
() => setRelayState({ localIp: "10.1.0.31", channel: 0, on: true, timeoutMs: 50 }, hangingFetch),
|
|
/HTTP request timed out after 50ms/
|
|
);
|
|
|
|
assert.equal(calls, 1);
|
|
assert.ok(Date.now() - startedAt < 500);
|
|
});
|
|
|
|
test("relay switch commands pass timer values to local Shelly APIs", async () => {
|
|
const legacyUrls = [];
|
|
const legacyFetch = async (url) => {
|
|
legacyUrls.push(String(url));
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return { ison: true, has_timer: true, timer_duration: 3 };
|
|
},
|
|
};
|
|
};
|
|
|
|
await setRelayState({
|
|
localIp: "10.1.0.31",
|
|
channel: 0,
|
|
on: true,
|
|
toggleAfter: 3,
|
|
deviceGeneration: 1,
|
|
}, legacyFetch);
|
|
|
|
assert.equal(
|
|
legacyUrls[0],
|
|
"http://10.1.0.31/relay/0?turn=on&timer=3"
|
|
);
|
|
|
|
const gen3Urls = [];
|
|
const gen3Fetch = async (url) => {
|
|
gen3Urls.push(String(url));
|
|
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return { output: true };
|
|
},
|
|
};
|
|
};
|
|
|
|
await setRelayState({
|
|
localIp: "10.1.0.31",
|
|
channel: 0,
|
|
on: true,
|
|
toggle_after: 3,
|
|
device_generation: 3,
|
|
}, gen3Fetch);
|
|
|
|
assert.equal(
|
|
gen3Urls[0],
|
|
"http://10.1.0.31/rpc/Switch.Set?id=0&on=true&toggle_after=3"
|
|
);
|
|
|
|
const fallbackUrls = [];
|
|
const legacyFallbackFetch = async (url) => {
|
|
fallbackUrls.push(String(url));
|
|
if (String(url).includes("/rpc/")) {
|
|
throw new Error("RPC switch endpoint unsupported");
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return { ison: true, has_timer: true, timer_duration: 3 };
|
|
},
|
|
};
|
|
};
|
|
|
|
await setRelayState({
|
|
localIp: "10.1.0.31",
|
|
channel: 0,
|
|
on: true,
|
|
toggle_after: 3,
|
|
device_model: "S3SW-001X8EU",
|
|
}, legacyFallbackFetch);
|
|
|
|
assert.deepEqual(fallbackUrls, [
|
|
"http://10.1.0.31/rpc/Switch.Set?id=0&on=true&toggle_after=3",
|
|
"http://10.1.0.31/relay/0?turn=on&timer=3",
|
|
]);
|
|
|
|
const cappedUrls = [];
|
|
const cappedFetch = async (url) => {
|
|
cappedUrls.push(String(url));
|
|
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return { output: true };
|
|
},
|
|
};
|
|
};
|
|
|
|
await setRelayState({
|
|
localIp: "10.1.0.31",
|
|
channel: 0,
|
|
on: true,
|
|
toggle_after: 999999999,
|
|
device_generation: 3,
|
|
}, cappedFetch);
|
|
|
|
assert.equal(
|
|
cappedUrls[0],
|
|
"http://10.1.0.31/rpc/Switch.Set?id=0&on=true&toggle_after=5"
|
|
);
|
|
});
|
|
|
|
test("runUpdate stages a pending verification restart after installing new artifacts", async () => {
|
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-update-"));
|
|
const configPath = path.join(tempDir, "config.json");
|
|
const liveConfig = {
|
|
apiUrl: "https://api.example.test",
|
|
gatewayId: 42,
|
|
agentToken: "agent-token",
|
|
installDir: tempDir,
|
|
restartMode: "spawn",
|
|
installedVersion: "1.0.0",
|
|
targetVersion: "1.0.0",
|
|
};
|
|
|
|
await writeFile(configPath, JSON.stringify(liveConfig, null, 2));
|
|
await writeFile(path.join(tempDir, "agent.mjs"), "// old agent\n");
|
|
await writeFile(path.join(tempDir, "package.json"), JSON.stringify({ name: "old-edge-agent" }, null, 2));
|
|
|
|
const execCalls = [];
|
|
const fakeExecFile = async (command, args, options) => {
|
|
execCalls.push({ command, args, options });
|
|
return { stdout: "{}" };
|
|
};
|
|
const fakeFetch = async (url) => {
|
|
if (String(url).endsWith("/agent.mjs")) {
|
|
return makeFetchResponse("// new agent\n");
|
|
}
|
|
if (String(url).endsWith("/package.json")) {
|
|
return makeFetchResponse(JSON.stringify({ name: "new-edge-agent" }, null, 2));
|
|
}
|
|
throw new Error(`Unexpected URL: ${url}`);
|
|
};
|
|
|
|
const result = await runUpdate({
|
|
artifactUrl: "https://api.example.test/edge-agent/artifacts/agent.mjs",
|
|
packageUrl: "https://api.example.test/edge-agent/artifacts/package.json",
|
|
targetVersion: "1.1.0",
|
|
releaseChannel: "stable",
|
|
restartMode: "spawn",
|
|
}, fakeFetch, {
|
|
configPath,
|
|
config: liveConfig,
|
|
liveConfig,
|
|
execFileImpl: fakeExecFile,
|
|
});
|
|
|
|
const persistedConfig = JSON.parse(await readFile(configPath, "utf8"));
|
|
|
|
assert.equal(result.__agentCommandEnvelope, true);
|
|
assert.equal(result.payload.verification_pending, true);
|
|
assert.equal(result.payload.target_version, "1.1.0");
|
|
assert.equal(liveConfig.targetVersion, "1.1.0");
|
|
assert.equal(liveConfig.installedVersion, "1.0.0");
|
|
assert.equal(persistedConfig.pendingUpdate.targetVersion, "1.1.0");
|
|
assert.equal(persistedConfig.pendingUpdate.previousVersion, "1.0.0");
|
|
assert.match(persistedConfig.pendingUpdate.backupDir, /\.updates/);
|
|
assert.equal(await readFile(path.join(tempDir, "agent.mjs"), "utf8"), "// new agent\n");
|
|
assert.equal(execCalls.length, 2);
|
|
assert.deepEqual(execCalls[0].args, ["install", "--omit=dev"]);
|
|
assert.deepEqual(execCalls[1].args, [path.join(tempDir, "agent.mjs"), "status", "--config", configPath]);
|
|
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("handleAgentCommand returns an uninstall follow-up envelope for gateway removal", async () => {
|
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-uninstall-envelope-"));
|
|
const configPath = path.join(tempDir, "config.json");
|
|
await writeFile(configPath, JSON.stringify({
|
|
apiUrl: "https://api.example.test",
|
|
gatewayId: 42,
|
|
agentToken: "agent-token",
|
|
installToken: "install-token",
|
|
brokerUrl: "https://broker.example.test",
|
|
restartMode: "spawn",
|
|
}));
|
|
|
|
const result = await handleAgentCommand(
|
|
{
|
|
commandType: "UNINSTALL_AGENT",
|
|
payload: {
|
|
serviceName: "truckwash-edge-agent.service",
|
|
},
|
|
},
|
|
{
|
|
configPath,
|
|
config: await loadConfig(configPath),
|
|
liveConfig: null,
|
|
}
|
|
);
|
|
|
|
assert.equal(result.__agentCommandEnvelope, true);
|
|
assert.equal(result.payload.uninstall_scheduled, true);
|
|
assert.equal(result.followUp.type, "UNINSTALL_AGENT");
|
|
assert.equal(result.followUp.uninstallPlan.configPath, configPath);
|
|
assert.equal(result.followUp.uninstallPlan.serviceName, "truckwash-edge-agent.service");
|
|
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("processPolledCommand acknowledges uninstall before clearing credentials and exiting", async () => {
|
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-uninstall-"));
|
|
const configPath = path.join(tempDir, "config.json");
|
|
const config = {
|
|
apiUrl: "https://api.example.test",
|
|
gatewayId: 42,
|
|
agentToken: "agent-token",
|
|
installToken: "install-token",
|
|
brokerUrl: "https://broker.example.test",
|
|
restartMode: "spawn",
|
|
};
|
|
await writeFile(configPath, JSON.stringify(config, null, 2));
|
|
|
|
const commandResultPosts = [];
|
|
const fakeFetch = async (url, options = {}) => {
|
|
const body = options.body ? JSON.parse(options.body) : {};
|
|
|
|
if (String(url).endsWith("/commands/77/result")) {
|
|
commandResultPosts.push({ url, body });
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return { data: { acknowledged: true } };
|
|
},
|
|
};
|
|
}
|
|
|
|
throw new Error(`Unexpected URL: ${url}`);
|
|
};
|
|
|
|
const exitCodes = [];
|
|
const result = await processPolledCommand(
|
|
{
|
|
...config,
|
|
configPath,
|
|
},
|
|
{
|
|
id: 77,
|
|
commandType: "UNINSTALL_AGENT",
|
|
payload: {
|
|
serviceName: "truckwash-edge-agent.service",
|
|
},
|
|
},
|
|
fakeFetch,
|
|
{
|
|
waitImpl: async () => {},
|
|
exitProcessImpl: (code) => {
|
|
exitCodes.push(code);
|
|
},
|
|
}
|
|
);
|
|
|
|
const persisted = JSON.parse(await readFile(configPath, "utf8"));
|
|
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.payload.uninstall_scheduled, true);
|
|
assert.equal(commandResultPosts.length, 1);
|
|
assert.equal(commandResultPosts[0].body.ok, true);
|
|
assert.equal(commandResultPosts[0].body.payload.uninstall_scheduled, true);
|
|
assert.deepEqual(exitCodes, [0]);
|
|
assert.equal(persisted.gatewayId, null);
|
|
assert.equal(persisted.agentToken, null);
|
|
assert.equal(persisted.installToken, null);
|
|
assert.equal(persisted.brokerUrl, null);
|
|
assert.equal(persisted.lastUninstall.state, "SCHEDULED");
|
|
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("finalizePendingUpdateOnStartup promotes the target version and clears the pending update marker", async () => {
|
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-startup-"));
|
|
const configPath = path.join(tempDir, "config.json");
|
|
const config = {
|
|
apiUrl: "https://api.example.test",
|
|
gatewayId: 42,
|
|
agentToken: "agent-token",
|
|
installDir: tempDir,
|
|
installedVersion: "1.0.0",
|
|
targetVersion: "1.1.0",
|
|
pendingUpdate: {
|
|
targetVersion: "1.1.0",
|
|
previousVersion: "1.0.0",
|
|
releaseChannel: "stable",
|
|
backupDir: path.join(tempDir, ".updates", "backup"),
|
|
},
|
|
};
|
|
await writeFile(configPath, JSON.stringify(config, null, 2));
|
|
|
|
const finalized = await finalizePendingUpdateOnStartup(config, configPath, {
|
|
liveConfig: config,
|
|
});
|
|
const persisted = JSON.parse(await readFile(configPath, "utf8"));
|
|
|
|
assert.equal(finalized.installedVersion, "1.1.0");
|
|
assert.equal(finalized.pendingUpdate, null);
|
|
assert.equal(finalized.lastUpdate.state, "COMPLETED");
|
|
assert.equal(persisted.installedVersion, "1.1.0");
|
|
assert.equal(persisted.pendingUpdate, null);
|
|
assert.equal(persisted.lastUpdate.targetVersion, "1.1.0");
|
|
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("verifyPendingUpdate rolls back the previous files and respawns the agent when startup verification times out", async () => {
|
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-rollback-"));
|
|
const updatesDir = path.join(tempDir, ".updates", "rollback-case");
|
|
const configPath = path.join(tempDir, "config.json");
|
|
|
|
await mkdir(updatesDir, { recursive: true });
|
|
await writeFile(path.join(tempDir, "agent.mjs"), "// broken new agent\n");
|
|
await writeFile(path.join(tempDir, "package.json"), JSON.stringify({ name: "broken-edge-agent" }, null, 2));
|
|
await writeFile(path.join(updatesDir, "agent.mjs"), "// old agent\n");
|
|
await writeFile(path.join(updatesDir, "package.json"), JSON.stringify({ name: "old-edge-agent" }, null, 2));
|
|
|
|
const config = {
|
|
apiUrl: "https://api.example.test",
|
|
gatewayId: 42,
|
|
agentToken: "agent-token",
|
|
installDir: tempDir,
|
|
restartMode: "spawn",
|
|
installedVersion: "1.0.0",
|
|
targetVersion: "1.1.0",
|
|
pendingUpdate: {
|
|
targetVersion: "1.1.0",
|
|
previousVersion: "1.0.0",
|
|
releaseChannel: "stable",
|
|
backupDir: updatesDir,
|
|
installDir: tempDir,
|
|
restartMode: "spawn",
|
|
verificationTimeoutSeconds: 5,
|
|
},
|
|
};
|
|
await writeFile(configPath, JSON.stringify(config, null, 2));
|
|
|
|
const execCalls = [];
|
|
const fakeExecFile = async (command, args, options) => {
|
|
execCalls.push({ command, args, options });
|
|
return { stdout: "{}" };
|
|
};
|
|
const spawnCalls = [];
|
|
const fakeSpawn = (command, args, options) => {
|
|
spawnCalls.push({ command, args, options });
|
|
return {
|
|
unref() {},
|
|
};
|
|
};
|
|
|
|
const result = await verifyPendingUpdate(configPath, {
|
|
execFileImpl: fakeExecFile,
|
|
spawnImpl: fakeSpawn,
|
|
timeoutMs: 1,
|
|
waitImpl: async () => {},
|
|
verifyIntervalMs: 1,
|
|
});
|
|
|
|
const persisted = JSON.parse(await readFile(configPath, "utf8"));
|
|
|
|
assert.equal(result.verified, false);
|
|
assert.equal(result.rolledBack, true);
|
|
assert.equal(await readFile(path.join(tempDir, "agent.mjs"), "utf8"), "// old agent\n");
|
|
assert.equal(persisted.pendingUpdate, null);
|
|
assert.equal(persisted.lastUpdate.state, "ROLLED_BACK");
|
|
assert.equal(persisted.installedVersion, "1.0.0");
|
|
assert.equal(execCalls.length, 1);
|
|
assert.deepEqual(execCalls[0].args, ["install", "--omit=dev"]);
|
|
assert.equal(spawnCalls.length, 1);
|
|
assert.equal(spawnCalls[0].command, process.execPath);
|
|
assert.deepEqual(spawnCalls[0].args, [path.join(tempDir, "agent.mjs"), "--config", configPath]);
|
|
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("shell bridge proxies PTY output, input, resize, close, and dispose events", async () => {
|
|
const messages = [];
|
|
const createdPtys = [];
|
|
|
|
const shell = createShellBridge(
|
|
(message) => messages.push(message),
|
|
{
|
|
createPtyProcess: async (options) => {
|
|
const listeners = {
|
|
data: null,
|
|
exit: null,
|
|
};
|
|
|
|
const pty = {
|
|
options,
|
|
writes: [],
|
|
resizes: [],
|
|
killed: false,
|
|
onData(callback) {
|
|
listeners.data = callback;
|
|
return {
|
|
dispose() {
|
|
listeners.data = null;
|
|
},
|
|
};
|
|
},
|
|
onExit(callback) {
|
|
listeners.exit = callback;
|
|
return {
|
|
dispose() {
|
|
listeners.exit = null;
|
|
},
|
|
};
|
|
},
|
|
write(data) {
|
|
this.writes.push(data);
|
|
},
|
|
resize(cols, rows) {
|
|
this.resizes.push({ cols, rows });
|
|
},
|
|
kill() {
|
|
this.killed = true;
|
|
listeners.exit?.({ exitCode: 0 });
|
|
},
|
|
emitData(data) {
|
|
listeners.data?.(data);
|
|
},
|
|
};
|
|
|
|
createdPtys.push(pty);
|
|
return pty;
|
|
},
|
|
}
|
|
);
|
|
|
|
await shell.open({
|
|
sessionId: "test-shell",
|
|
shellCommand: "/bin/bash",
|
|
shellArgs: ["-l"],
|
|
cols: 90,
|
|
rows: 24,
|
|
cwd: "/tmp",
|
|
});
|
|
|
|
createdPtys[0].emitData("root@pi:~# ");
|
|
shell.input({ sessionId: "test-shell", data: "ls\r" });
|
|
shell.resize({ sessionId: "test-shell", cols: 120, rows: 40 });
|
|
shell.dispose();
|
|
|
|
assert.equal(createdPtys.length, 1);
|
|
assert.equal(createdPtys[0].options.command, "/bin/bash");
|
|
assert.deepEqual(createdPtys[0].options.args, ["-l"]);
|
|
assert.equal(createdPtys[0].options.cols, 90);
|
|
assert.equal(createdPtys[0].options.rows, 24);
|
|
assert.equal(createdPtys[0].options.cwd, "/tmp");
|
|
assert.deepEqual(createdPtys[0].writes, ["ls\r"]);
|
|
assert.deepEqual(createdPtys[0].resizes, [{ cols: 120, rows: 40 }]);
|
|
assert.equal(createdPtys[0].killed, true);
|
|
assert.ok(messages.some((message) => message.type === "SHELL_OPENED" && message.sessionId === "test-shell"));
|
|
assert.ok(messages.some((message) => message.type === "SHELL_OUTPUT" && message.data.includes("root@pi")));
|
|
assert.ok(messages.some((message) => message.type === "SHELL_EXIT" && message.code === 0));
|
|
});
|
|
|
|
test("shell bridge reports structured spawn failures", async () => {
|
|
const messages = [];
|
|
const shell = createShellBridge(
|
|
(message) => messages.push(message),
|
|
{
|
|
createPtyProcess: async () => {
|
|
throw new Error("node-pty unavailable");
|
|
},
|
|
}
|
|
);
|
|
|
|
await shell.open({ sessionId: "spawn-failure-shell" });
|
|
|
|
assert.ok(
|
|
messages.some(
|
|
(message) =>
|
|
message.type === "SHELL_OUTPUT" &&
|
|
message.sessionId === "spawn-failure-shell" &&
|
|
/Failed to start root shell: node-pty unavailable/.test(message.data)
|
|
)
|
|
);
|
|
assert.ok(
|
|
messages.some(
|
|
(message) =>
|
|
message.type === "SHELL_EXIT" &&
|
|
message.sessionId === "spawn-failure-shell" &&
|
|
message.code === 1 &&
|
|
message.reason === "shell_spawn_failed" &&
|
|
message.message === "node-pty unavailable"
|
|
)
|
|
);
|
|
});
|
|
|
|
test("startAgent reports API polling metadata, executes polled commands, and uploads shell events", async () => {
|
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-"));
|
|
const configPath = path.join(tempDir, "config.json");
|
|
await writeFile(configPath, JSON.stringify({
|
|
apiUrl: "https://api.example.test",
|
|
gatewayId: 42,
|
|
agentToken: "agent-token",
|
|
heartbeatIntervalSeconds: 0.05,
|
|
commandPollTimeoutSeconds: 0,
|
|
commandPollRetryDelayMs: 5,
|
|
shellActionPollTimeoutSeconds: 0,
|
|
shellActionPollRetryDelayMs: 5,
|
|
enableShellAccess: true,
|
|
}));
|
|
|
|
const heartbeats = [];
|
|
const commandResultPosts = [];
|
|
const shellActionResults = [];
|
|
const shellEventPosts = [];
|
|
let polledCommandDelivered = false;
|
|
let openActionDelivered = false;
|
|
let closeActionDelivered = false;
|
|
let metricSample = 0;
|
|
|
|
const fakeFetch = async (url, options = {}) => {
|
|
const body = options.body ? JSON.parse(options.body) : {};
|
|
|
|
if (String(url).endsWith("/heartbeat")) {
|
|
heartbeats.push({ url, body });
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return { data: { ok: true } };
|
|
},
|
|
};
|
|
}
|
|
|
|
if (String(url).endsWith("/commands/poll")) {
|
|
if (!polledCommandDelivered) {
|
|
polledCommandDelivered = true;
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return {
|
|
data: {
|
|
id: 99,
|
|
commandType: "DISCOVER_SHELLY",
|
|
payload: {
|
|
candidateIps: ["10.1.0.31"],
|
|
},
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return { data: null };
|
|
},
|
|
};
|
|
}
|
|
|
|
if (String(url).endsWith("/commands/99/result")) {
|
|
commandResultPosts.push({ url, body });
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return { data: { acknowledged: true } };
|
|
},
|
|
};
|
|
}
|
|
|
|
if (String(url).endsWith("/shell-actions/poll")) {
|
|
if (!openActionDelivered) {
|
|
openActionDelivered = true;
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return {
|
|
data: {
|
|
id: 501,
|
|
actionType: "OPEN",
|
|
payload: {
|
|
sessionId: 44,
|
|
cols: 100,
|
|
rows: 30,
|
|
reason: "Investigate relay drift",
|
|
},
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
if (!closeActionDelivered) {
|
|
closeActionDelivered = true;
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return {
|
|
data: {
|
|
id: 502,
|
|
actionType: "CLOSE",
|
|
payload: {
|
|
sessionId: 44,
|
|
},
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return { data: null };
|
|
},
|
|
};
|
|
}
|
|
|
|
if (/\/shell-actions\/\d+\/result$/.test(String(url))) {
|
|
shellActionResults.push({ url, body });
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return { data: { acknowledged: true } };
|
|
},
|
|
};
|
|
}
|
|
|
|
if (String(url).includes("/shell-sessions/44/events")) {
|
|
shellEventPosts.push({ url, body });
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return { data: { acknowledged: true } };
|
|
},
|
|
};
|
|
}
|
|
|
|
if (String(url) === "http://10.1.0.31/shelly") {
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return {
|
|
mac: "AA:BB:CC:DD:EE:FF",
|
|
model: "Shelly Plus 1PM",
|
|
num_switches: 1,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
throw new Error(`Unexpected URL: ${url}`);
|
|
};
|
|
|
|
const collectMetricsImpl = async ({ latencyMs }) => {
|
|
metricSample += 1;
|
|
return {
|
|
cpuSnapshot: { idle: metricSample, total: metricSample + 10 },
|
|
metrics: {
|
|
latency_ms: latencyMs,
|
|
cpu_usage_pct: 27.5,
|
|
memory_usage_pct: 48.2,
|
|
memory_used_bytes: 2048,
|
|
memory_total_bytes: 4096,
|
|
disk_usage_pct: 61.4,
|
|
disk_used_bytes: 8192,
|
|
disk_total_bytes: 16384,
|
|
disk_mount: "/",
|
|
},
|
|
};
|
|
};
|
|
|
|
const shellCalls = [];
|
|
const createShellBridgeImpl = (sendMessage) => ({
|
|
async open(payload) {
|
|
shellCalls.push({ type: "open", payload });
|
|
sendMessage({ type: "SHELL_OPENED", sessionId: payload.sessionId });
|
|
sendMessage({ type: "SHELL_OUTPUT", sessionId: payload.sessionId, data: "root@pi:~# " });
|
|
},
|
|
input(payload) {
|
|
shellCalls.push({ type: "input", payload });
|
|
},
|
|
resize(payload) {
|
|
shellCalls.push({ type: "resize", payload });
|
|
},
|
|
close(payload) {
|
|
shellCalls.push({ type: "close", payload });
|
|
sendMessage({ type: "SHELL_EXIT", sessionId: payload.sessionId, code: 0 });
|
|
},
|
|
dispose() {
|
|
shellCalls.push({ type: "dispose" });
|
|
},
|
|
});
|
|
|
|
let agent = null;
|
|
try {
|
|
agent = await startAgent({
|
|
configPath,
|
|
fetchImpl: fakeFetch,
|
|
collectMetricsImpl,
|
|
createShellBridgeImpl,
|
|
});
|
|
|
|
assert.equal(heartbeats[0].body.status, "ONLINE");
|
|
assert.equal(heartbeats[0].body.metadata.command_transport, "API_POLLING");
|
|
assert.equal(heartbeats[0].body.metadata.shell_transport, "API_POLLING");
|
|
assert.equal(heartbeats[0].body.metadata.system_metrics.cpu_usage_pct, 27.5);
|
|
assert.equal(heartbeats[0].body.metadata.system_metrics.memory_usage_pct, 48.2);
|
|
assert.equal(heartbeats[0].body.metadata.system_metrics.disk_usage_pct, 61.4);
|
|
assert.equal(heartbeats[0].body.metadata.system_metrics.latency_ms, null);
|
|
assert.equal(heartbeats[0].body.metadata.broker_connected, false);
|
|
assert.equal(heartbeats[0].body.metadata.broker_url, null);
|
|
assert.equal("discovery_status" in heartbeats[0].body, false);
|
|
|
|
await waitFor(
|
|
() =>
|
|
commandResultPosts.length === 1 &&
|
|
shellActionResults.length === 2 &&
|
|
shellEventPosts.length > 0 &&
|
|
heartbeats.some(
|
|
(heartbeat) =>
|
|
heartbeat.body.metadata.system_metrics &&
|
|
typeof heartbeat.body.metadata.system_metrics.latency_ms === "number"
|
|
),
|
|
{ timeoutMs: 1000, description: "agent polling activity and follow-up heartbeat" }
|
|
);
|
|
|
|
assert.equal(commandResultPosts.length, 1);
|
|
assert.equal(commandResultPosts[0].body.ok, true);
|
|
assert.equal(Array.isArray(commandResultPosts[0].body.payload.inventory), true);
|
|
assert.equal(commandResultPosts[0].body.payload.inventory[0].device_id, "AA:BB:CC:DD:EE:FF");
|
|
assert.equal(commandResultPosts[0].body.payload.inventory[0].capabilities.generation, 2);
|
|
|
|
assert.ok(shellCalls.some((call) => call.type === "open"));
|
|
assert.ok(shellCalls.some((call) => call.type === "close"));
|
|
assert.equal(shellActionResults.length, 2);
|
|
assert.ok(shellActionResults.every((result) => result.body.ok === true));
|
|
assert.ok(
|
|
shellEventPosts.some((request) =>
|
|
request.body.events.some((event) => event.type === "OPENED")
|
|
)
|
|
);
|
|
assert.ok(
|
|
shellEventPosts.some((request) =>
|
|
request.body.events.some((event) => event.type === "OUTPUT")
|
|
)
|
|
);
|
|
assert.ok(
|
|
shellEventPosts.some((request) =>
|
|
request.body.events.some((event) => event.type === "CLOSED")
|
|
)
|
|
);
|
|
assert.ok(
|
|
heartbeats.some(
|
|
(heartbeat) =>
|
|
heartbeat.body.metadata.system_metrics &&
|
|
typeof heartbeat.body.metadata.system_metrics.latency_ms === "number"
|
|
)
|
|
);
|
|
} finally {
|
|
await agent?.stop();
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("processPolledShellAction denies shell access when locally disabled", async () => {
|
|
const submissions = [];
|
|
const fakeFetch = async (url, options = {}) => {
|
|
if (/\/shell-actions\/\d+\/result$/.test(String(url))) {
|
|
submissions.push({ url, body: JSON.parse(options.body) });
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return { data: { acknowledged: true } };
|
|
},
|
|
};
|
|
}
|
|
|
|
throw new Error(`Unexpected URL: ${url}`);
|
|
};
|
|
|
|
const shell = {
|
|
async open() {
|
|
throw new Error("should not run");
|
|
},
|
|
input() {
|
|
throw new Error("should not run");
|
|
},
|
|
resize() {
|
|
throw new Error("should not run");
|
|
},
|
|
close() {
|
|
throw new Error("should not run");
|
|
},
|
|
};
|
|
|
|
const result = await processPolledShellAction(
|
|
{
|
|
apiUrl: "https://api.example.test",
|
|
gatewayId: 42,
|
|
agentToken: "agent-token",
|
|
enableShellAccess: false,
|
|
},
|
|
{
|
|
id: 501,
|
|
actionType: "OPEN",
|
|
payload: {
|
|
sessionId: 44,
|
|
},
|
|
},
|
|
shell,
|
|
fakeFetch
|
|
);
|
|
|
|
assert.equal(result.ok, false);
|
|
assert.match(result.error, /Shell access is disabled/);
|
|
assert.equal(submissions.length, 1);
|
|
assert.equal(submissions[0].body.ok, false);
|
|
});
|
|
|
|
test("status helpers report config without exposing the agent token", async () => {
|
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-status-"));
|
|
const configPath = path.join(tempDir, "config.json");
|
|
await writeFile(configPath, JSON.stringify({
|
|
apiUrl: "https://api.example.test",
|
|
installToken: "install-token",
|
|
gatewayId: 42,
|
|
agentToken: "agent-token",
|
|
installedVersion: "1.2.3",
|
|
targetVersion: "1.2.4",
|
|
releaseChannel: "stable",
|
|
brokerUrl: "https://broker.example.test",
|
|
heartbeatIntervalSeconds: 30,
|
|
}));
|
|
|
|
const report = await getAgentStatus(configPath);
|
|
const built = buildStatusReport(configPath, {
|
|
apiUrl: "https://api.example.test",
|
|
installToken: "install-token",
|
|
gatewayId: 42,
|
|
agentToken: "agent-token",
|
|
brokerUrl: "https://broker.example.test",
|
|
});
|
|
|
|
assert.equal(report.command, "status");
|
|
assert.equal(report.configPath, configPath);
|
|
assert.equal(report.claimed, true);
|
|
assert.equal(report.state, "CLAIMED");
|
|
assert.equal(report.gatewayId, 42);
|
|
assert.equal(report.transport, "HYBRID");
|
|
assert.equal(report.brokerUrl, "https://broker.example.test");
|
|
assert.equal(report.hasInstallToken, true);
|
|
assert.equal("agentToken" in report, false);
|
|
assert.equal(built.state, "CLAIMED");
|
|
assert.equal(built.transport, "HYBRID");
|
|
assert.equal(built.brokerUrl, "https://broker.example.test");
|
|
assert.equal("agentToken" in built, false);
|
|
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("parseCliArgs understands the status command and config flag", () => {
|
|
assert.deepEqual(
|
|
parseCliArgs(["status", "--config", "/tmp/config.json"]),
|
|
{ command: "status", configPath: "/tmp/config.json" }
|
|
);
|
|
assert.deepEqual(
|
|
parseCliArgs(["--config", "/tmp/config.json"]),
|
|
{ command: "start", configPath: "/tmp/config.json" }
|
|
);
|
|
});
|
|
|
|
test("runCli emits a status report for the status command", async () => {
|
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-cli-"));
|
|
const configPath = path.join(tempDir, "config.json");
|
|
await writeFile(configPath, JSON.stringify({
|
|
apiUrl: "https://api.example.test",
|
|
installToken: "install-token",
|
|
}));
|
|
|
|
let stdout = "";
|
|
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
process.stdout.write = ((chunk, encoding, callback) => {
|
|
stdout += String(chunk);
|
|
if (typeof encoding === "function") {
|
|
encoding();
|
|
} else if (typeof callback === "function") {
|
|
callback();
|
|
}
|
|
return true;
|
|
});
|
|
|
|
try {
|
|
const report = await runCli(["status", "--config", configPath]);
|
|
assert.equal(report.state, "PENDING_CLAIM");
|
|
const parsed = JSON.parse(stdout);
|
|
assert.equal(parsed.command, "status");
|
|
assert.equal(parsed.claimed, false);
|
|
assert.equal(parsed.transport, "API_POLLING");
|
|
assert.equal("brokerWsUrl" in parsed, false);
|
|
} finally {
|
|
process.stdout.write = originalWrite;
|
|
}
|
|
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("agent CLI status command prints the report", async () => {
|
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-process-"));
|
|
const configPath = path.join(tempDir, "config.json");
|
|
await writeFile(configPath, JSON.stringify({
|
|
apiUrl: "https://api.example.test",
|
|
installToken: "install-token",
|
|
gatewayId: 7,
|
|
agentToken: "secret-token",
|
|
}));
|
|
|
|
const { stdout } = await execFile(
|
|
process.execPath,
|
|
[agentEntryPath, "status", "--config", configPath],
|
|
{ windowsHide: true, encoding: "utf8" }
|
|
);
|
|
const parsed = JSON.parse(stdout);
|
|
|
|
assert.equal(parsed.command, "status");
|
|
assert.equal(parsed.gatewayId, 7);
|
|
assert.equal(parsed.claimed, true);
|
|
assert.equal(parsed.transport, "API_POLLING");
|
|
assert.equal("agentToken" in parsed, false);
|
|
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
});
|