collabSupport/tests/dectRelayHub.test.js
Joseph McQueen 96b26a5aca DECT relay Phase 1: WSS hub + agent + /phonestatus follow-up
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).
2026-07-02 17:03:32 -04:00

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();
}
});