The bot runs in the public cloud and can't reach the 10.x/8 network
where DBS-210 bases live. This phase adds a data-center-resident relay
agent that dials outbound over WSS to the bot, and lets /phonestatus
post a follow-up message with per-base health after its main output
has already shipped.
Bot side (services/):
- dectRelayHub.js: WebSocket upgrade handler on /dect-relay/ws with
bearer-token auth (constant-time compare, header + Sec-WebSocket-
Protocol fallback for header-stripping proxies). Promise-based RPC
API with per-call timeouts, mid-flight-disconnect rejection, and
clean replacement of a stale agent socket when a newer one connects.
- dectDiscovery.js: pure filter that turns a phoneService result into
a list of reachable bases. Enforces the "must be on 10.0.0.0/8"
guardrail per requirements, dedups by IP + MAC, prefers Meraki-live
IP over Webex-cached IP.
- dectCollectorService.js: fan-out layer over the hub. collectAll()
runs one RPC per base in parallel with per-base error isolation —
one bad base never fails the batch.
Phone-status integration:
- Renderer gets a dectFollowUpBaseCount opt that emits an italic
"diagnostics loading for N base(s)..." hint inside the DECT section
of the main message.
- New exported renderDectDiagnosticsMarkdown() renders the follow-up
message: healthy/warning icon per base, uptime + firmware summary,
structured Power Loss reboot line, and per-base failure hints (e.g.
"relay accepted the request but the base did not respond in time").
- commands/phoneStatus.js discovers reachable bases synchronously
(pure), sends the main message, then fires collectAll() and posts
the follow-up as a separate message. Failures logged, never thrown
back to the user.
- Chat only: HTTP callers keep their single-message contract.
Agent side (dect-relay-agent/):
- Standalone Node process with its own package.json (only ws, axios,
dotenv). Reuses the shared integrations/cisco-dect/{client,probes,
statusXml}.js modules from the parent workspace so there's no code
duplication.
- Auto-reconnect with exponential backoff + jitter.
- Dispatches collect / reboot / force-reboot / reboot-chain /
force-reboot-chain / factory-reset / reconfigure-tree.
- DECT admin credentials live ONLY on the agent (never on the bot).
Shared bearer token gates the WSS handshake.
- README.md covers install, config, wire protocol, and safety model.
Env / infra:
- .env.example: adds DECT_RELAY_AGENT_TOKEN + optional DECT_RELAY_PATH
and DECT_COLLECT_TIMEOUT_MS. Reframes DECT_TEST_* as the local-dev
test harness rather than the production path.
- index.js: captures the http.Server from app.listen() and attaches
the relay hub when DECT_RELAY_AGENT_TOKEN is set; graceful shutdown
now closes the hub so in-flight RPCs get rejected cleanly.
- Adds "ws" to bot dependencies.
Tests (99 -> 113):
- tests/dectDiscovery.test.js: 13 cases covering the 10.x guardrail,
MAC normalization, IP source preference, dedup, and warning shape.
- tests/dectRelayHub.test.js: 14 integration cases using a real
ws pair on an ephemeral 127.0.0.1 port — auth (missing / wrong /
correct via header / correct via protocol fallback), hello frame,
RPC round-trip with correlation, agent error surfacing, concurrent
out-of-order replies, timeout, mid-flight disconnect, replacement
of a stale socket, and execAction routing.
- tests/renderers.test.js: 8 new cases for the DECT-follow-up loading
hint (plural / singular / off) and the diagnostics renderer (empty,
healthy, warning, power-loss dedup, active RTP, error hint, footer).
351 lines
12 KiB
JavaScript
351 lines
12 KiB
JavaScript
// Integration tests for services/dectRelayHub.js.
|
|
//
|
|
// These spin up a real HTTP server on an ephemeral port, attach the
|
|
// hub, and connect a real `ws` client that plays the role of the
|
|
// dect-relay-agent. This gives us end-to-end coverage of the auth
|
|
// path, the wire protocol, RPC correlation, timeouts, and clean
|
|
// disconnect handling — none of which we can meaningfully test with
|
|
// pure mocks.
|
|
//
|
|
// Every test creates its own hub + server so they can run in parallel
|
|
// without port conflicts. All servers are torn down in the test's
|
|
// finally block so a failing test can't leak file descriptors.
|
|
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import http from 'node:http';
|
|
import WebSocket from 'ws';
|
|
|
|
import { DectRelayHub, RelayErrorCodes, DectRelayError } from '../services/dectRelayHub.js';
|
|
|
|
const TEST_TOKEN = 'super-secret-token-for-tests';
|
|
|
|
// ─── Test harness ───────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Spin up an HTTP server with the hub attached on an ephemeral port.
|
|
* Returns { hub, port, closeAll }. Caller MUST call closeAll() (in a
|
|
* try/finally) to release the port + socket handles.
|
|
*/
|
|
async function makeHub(token = TEST_TOKEN) {
|
|
const server = http.createServer((req, res) => {
|
|
res.writeHead(404); res.end();
|
|
});
|
|
const hub = new DectRelayHub({ token });
|
|
hub.attachTo(server);
|
|
await new Promise((res) => server.listen(0, '127.0.0.1', res));
|
|
const { port } = server.address();
|
|
return {
|
|
hub,
|
|
port,
|
|
async closeAll() {
|
|
await hub.close();
|
|
await new Promise((res) => server.close(() => res()));
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Open a WebSocket client to the hub. Optional bearer overrides the
|
|
* default token — useful for the "wrong token" test.
|
|
*/
|
|
function connectAgent(port, { bearer = TEST_TOKEN, useProtocol = false } = {}) {
|
|
const url = `ws://127.0.0.1:${port}/dect-relay/ws`;
|
|
const opts = useProtocol
|
|
? { headers: {}, protocol: `bearer.${bearer}` }
|
|
: { headers: { Authorization: `Bearer ${bearer}` } };
|
|
return new WebSocket(url, opts.protocol ? opts.protocol : undefined, {
|
|
headers: opts.headers,
|
|
handshakeTimeout: 3000,
|
|
});
|
|
}
|
|
|
|
function waitOpen(ws) {
|
|
return new Promise((resolve, reject) => {
|
|
ws.once('open', resolve);
|
|
ws.once('error', reject);
|
|
});
|
|
}
|
|
|
|
function waitClose(ws) {
|
|
return new Promise((resolve) => ws.once('close', (code, reason) => resolve({ code, reason: reason?.toString() || '' })));
|
|
}
|
|
|
|
// A tiny agent that immediately replies to every command with the
|
|
// given handler. Handler receives the parsed inbound message and
|
|
// returns either { ok:true, result:{...} } or throws.
|
|
function attachAutoAgent(ws, handler) {
|
|
ws.on('message', async (raw) => {
|
|
const msg = JSON.parse(raw.toString('utf8'));
|
|
if (msg.type === 'ping') { ws.send(JSON.stringify({ type: 'pong', at: Date.now() })); return; }
|
|
if (!msg.id) return;
|
|
try {
|
|
const result = await handler(msg);
|
|
ws.send(JSON.stringify({ id: msg.id, ok: true, result, elapsedMs: 1 }));
|
|
} catch (err) {
|
|
ws.send(JSON.stringify({
|
|
id: msg.id, ok: false,
|
|
error: { code: err.code || 'AUTO_AGENT_ERR', message: err.message },
|
|
}));
|
|
}
|
|
});
|
|
}
|
|
|
|
// ─── isConnected / status ───────────────────────────────────────────
|
|
|
|
test('hub: isConnected is false with no agent', async () => {
|
|
const { hub, closeAll } = await makeHub();
|
|
try {
|
|
assert.equal(hub.isConnected(), false);
|
|
assert.equal(hub.status().connected, false);
|
|
assert.equal(hub.status().agent, null);
|
|
assert.equal(hub.status().inFlight, 0);
|
|
} finally {
|
|
await closeAll();
|
|
}
|
|
});
|
|
|
|
test('hub: rpc without connection rejects with NOT_CONNECTED', async () => {
|
|
const { hub, closeAll } = await makeHub();
|
|
try {
|
|
await assert.rejects(
|
|
hub.collect('10.0.0.100'),
|
|
(err) => err instanceof DectRelayError && err.code === RelayErrorCodes.NOT_CONNECTED,
|
|
);
|
|
} finally {
|
|
await closeAll();
|
|
}
|
|
});
|
|
|
|
// ─── Auth ──────────────────────────────────────────────────────────
|
|
|
|
test('hub: rejects upgrade with no bearer', async () => {
|
|
const { port, closeAll } = await makeHub();
|
|
try {
|
|
const ws = new WebSocket(`ws://127.0.0.1:${port}/dect-relay/ws`, {
|
|
handshakeTimeout: 3000,
|
|
});
|
|
// Server writes a raw 401 before the WS handshake completes.
|
|
// ws throws 'Unexpected server response: 401' as an error.
|
|
await assert.rejects(waitOpen(ws), /401/);
|
|
} finally {
|
|
await closeAll();
|
|
}
|
|
});
|
|
|
|
test('hub: rejects upgrade with wrong bearer', async () => {
|
|
const { port, closeAll } = await makeHub();
|
|
try {
|
|
const ws = connectAgent(port, { bearer: 'wrong-token' });
|
|
await assert.rejects(waitOpen(ws), /401/);
|
|
} finally {
|
|
await closeAll();
|
|
}
|
|
});
|
|
|
|
test('hub: accepts upgrade with correct bearer via Authorization header', async () => {
|
|
const { hub, port, closeAll } = await makeHub();
|
|
try {
|
|
const ws = connectAgent(port);
|
|
await waitOpen(ws);
|
|
// Give the hub a tick to record the adoption.
|
|
await new Promise((r) => setImmediate(r));
|
|
assert.equal(hub.isConnected(), true);
|
|
ws.close();
|
|
await waitClose(ws);
|
|
} finally {
|
|
await closeAll();
|
|
}
|
|
});
|
|
|
|
test('hub: accepts upgrade via Sec-WebSocket-Protocol fallback', async () => {
|
|
const { hub, port, closeAll } = await makeHub();
|
|
try {
|
|
const ws = connectAgent(port, { useProtocol: true });
|
|
await waitOpen(ws);
|
|
await new Promise((r) => setImmediate(r));
|
|
assert.equal(hub.isConnected(), true);
|
|
ws.close();
|
|
await waitClose(ws);
|
|
} finally {
|
|
await closeAll();
|
|
}
|
|
});
|
|
|
|
// ─── Hello frame ────────────────────────────────────────────────────
|
|
|
|
test('hub: records agent hello frame into status()', async () => {
|
|
const { hub, port, closeAll } = await makeHub();
|
|
try {
|
|
const ws = connectAgent(port);
|
|
await waitOpen(ws);
|
|
ws.send(JSON.stringify({
|
|
type: 'hello', agentVersion: '9.9.9', hostname: 'test-host',
|
|
capabilities: ['collect', 'reboot'],
|
|
}));
|
|
// Wait until hub processes it (message events are queued).
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
const s = hub.status();
|
|
assert.equal(s.connected, true);
|
|
assert.equal(s.agent.agentVersion, '9.9.9');
|
|
assert.equal(s.agent.hostname, 'test-host');
|
|
assert.deepEqual(s.agent.capabilities, ['collect', 'reboot']);
|
|
ws.close();
|
|
await waitClose(ws);
|
|
} finally {
|
|
await closeAll();
|
|
}
|
|
});
|
|
|
|
// ─── RPC correlation ────────────────────────────────────────────────
|
|
|
|
test('hub: RPC round-trip resolves with agent result', async () => {
|
|
const { hub, port, closeAll } = await makeHub();
|
|
try {
|
|
const ws = connectAgent(port);
|
|
await waitOpen(ws);
|
|
attachAutoAgent(ws, async (msg) => {
|
|
assert.equal(msg.type, 'collect');
|
|
assert.equal(msg.baseIp, '10.4.11.87');
|
|
return { parsed: { device: { macAddress: 'aa:bb:cc:dd:ee:ff' } }, verdict: { healthy: true } };
|
|
});
|
|
const { result } = await hub.collect('10.4.11.87');
|
|
assert.equal(result.parsed.device.macAddress, 'aa:bb:cc:dd:ee:ff');
|
|
assert.equal(result.verdict.healthy, true);
|
|
ws.close();
|
|
await waitClose(ws);
|
|
} finally {
|
|
await closeAll();
|
|
}
|
|
});
|
|
|
|
test('hub: RPC error from agent surfaces as DectRelayError with code', async () => {
|
|
const { hub, port, closeAll } = await makeHub();
|
|
try {
|
|
const ws = connectAgent(port);
|
|
await waitOpen(ws);
|
|
attachAutoAgent(ws, async () => {
|
|
const err = new Error('base rejected credentials');
|
|
err.code = 'DIGEST_401';
|
|
throw err;
|
|
});
|
|
await assert.rejects(
|
|
hub.collect('10.4.11.87'),
|
|
(err) => err instanceof DectRelayError && err.code === 'DIGEST_401'
|
|
&& /base rejected credentials/i.test(err.message),
|
|
);
|
|
} finally {
|
|
await closeAll();
|
|
}
|
|
});
|
|
|
|
test('hub: multiple concurrent RPCs correlate by id, not order', async () => {
|
|
const { hub, port, closeAll } = await makeHub();
|
|
try {
|
|
const ws = connectAgent(port);
|
|
await waitOpen(ws);
|
|
// Delay short IPs less than long IPs, deliberately reversing
|
|
// response order relative to send order.
|
|
ws.on('message', (raw) => {
|
|
const msg = JSON.parse(raw.toString('utf8'));
|
|
if (!msg.id) return;
|
|
const delay = msg.baseIp === '10.0.0.1' ? 40 : 5;
|
|
setTimeout(() => {
|
|
ws.send(JSON.stringify({
|
|
id: msg.id, ok: true, result: { echo: msg.baseIp }, elapsedMs: delay,
|
|
}));
|
|
}, delay);
|
|
});
|
|
const [a, b] = await Promise.all([
|
|
hub.collect('10.0.0.1'), // slower
|
|
hub.collect('10.0.0.2'), // faster
|
|
]);
|
|
assert.equal(a.result.echo, '10.0.0.1');
|
|
assert.equal(b.result.echo, '10.0.0.2');
|
|
} finally {
|
|
await closeAll();
|
|
}
|
|
});
|
|
|
|
// ─── Timeout ────────────────────────────────────────────────────────
|
|
|
|
test('hub: RPC that never gets a reply times out with TIMEOUT code', async () => {
|
|
const { hub, port, closeAll } = await makeHub();
|
|
try {
|
|
const ws = connectAgent(port);
|
|
await waitOpen(ws);
|
|
// Silent agent: acknowledge nothing.
|
|
ws.on('message', () => { /* intentionally do nothing */ });
|
|
await assert.rejects(
|
|
hub.collect('10.0.0.1', { timeoutMs: 50 }),
|
|
(err) => err instanceof DectRelayError && err.code === RelayErrorCodes.TIMEOUT,
|
|
);
|
|
} finally {
|
|
await closeAll();
|
|
}
|
|
});
|
|
|
|
// ─── Mid-flight disconnect ──────────────────────────────────────────
|
|
|
|
test('hub: agent disconnect mid-RPC rejects the pending promise', async () => {
|
|
const { hub, port, closeAll } = await makeHub();
|
|
try {
|
|
const ws = connectAgent(port);
|
|
await waitOpen(ws);
|
|
// Close the socket the moment we receive a command.
|
|
ws.on('message', () => ws.close(1000, 'test'));
|
|
await assert.rejects(
|
|
hub.collect('10.0.0.1', { timeoutMs: 2000 }),
|
|
(err) => err instanceof DectRelayError && err.code === RelayErrorCodes.DISCONNECTED,
|
|
);
|
|
} finally {
|
|
await closeAll();
|
|
}
|
|
});
|
|
|
|
// ─── Second agent replaces first ────────────────────────────────────
|
|
|
|
test('hub: second agent connection replaces the first (with clean close)', async () => {
|
|
const { hub, port, closeAll } = await makeHub();
|
|
try {
|
|
const wsA = connectAgent(port);
|
|
await waitOpen(wsA);
|
|
const closedA = waitClose(wsA);
|
|
|
|
const wsB = connectAgent(port);
|
|
await waitOpen(wsB);
|
|
|
|
// wsA should have been closed by the hub with reason "replaced".
|
|
const closeInfo = await closedA;
|
|
assert.equal(closeInfo.code, 1000);
|
|
assert.match(closeInfo.reason, /replaced/i);
|
|
|
|
// The hub is still connected — to wsB now.
|
|
assert.equal(hub.isConnected(), true);
|
|
wsB.close();
|
|
await waitClose(wsB);
|
|
} finally {
|
|
await closeAll();
|
|
}
|
|
});
|
|
|
|
// ─── execAction routing ────────────────────────────────────────────
|
|
|
|
test('hub: execAction routes action name into type field', async () => {
|
|
const { hub, port, closeAll } = await makeHub();
|
|
try {
|
|
const ws = connectAgent(port);
|
|
await waitOpen(ws);
|
|
let observed = null;
|
|
attachAutoAgent(ws, async (msg) => {
|
|
observed = msg;
|
|
return { ok: 'done' };
|
|
});
|
|
await hub.execAction('10.0.0.1', 'reboot', { forced: false });
|
|
assert.equal(observed.type, 'reboot');
|
|
assert.equal(observed.baseIp, '10.0.0.1');
|
|
assert.equal(observed.forced, false);
|
|
} finally {
|
|
await closeAll();
|
|
}
|
|
});
|