collabSupport/tests/dectDiscovery.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

169 lines
6.2 KiB
JavaScript

// Unit tests for services/dectDiscovery.js. Pure — no network, no fs.
// The interesting cases are all around the 10.x guardrail and the
// dedup/priority logic when Meraki and Webex report different IPs
// for the same base.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
discoverDectBases,
isTenDotIp,
normalizeMac,
} from '../services/dectDiscovery.js';
// ─── Helpers ────────────────────────────────────────────────────────
const fixture = (overrides = {}) => ({
dectBasestations: [],
...overrides,
});
const base = (attrs = {}) => ({
mac: '6c:ab:05:f6:28:19',
name: 'Basestation A',
ipAddress: '—',
meraki: {},
...attrs,
});
// ─── isTenDotIp ─────────────────────────────────────────────────────
test('isTenDotIp: accepts 10.x/8 addresses', () => {
assert.equal(isTenDotIp('10.0.0.1'), true);
assert.equal(isTenDotIp('10.255.255.254'), true);
assert.equal(isTenDotIp('10.4.11.87'), true);
});
test('isTenDotIp: rejects non-10.x addresses', () => {
assert.equal(isTenDotIp('192.168.1.164'), false);
assert.equal(isTenDotIp('172.16.0.1'), false);
assert.equal(isTenDotIp('11.0.0.1'), false);
assert.equal(isTenDotIp('100.0.0.1'), false);
});
test('isTenDotIp: rejects malformed input', () => {
assert.equal(isTenDotIp(null), false);
assert.equal(isTenDotIp(''), false);
assert.equal(isTenDotIp('10.'), false);
assert.equal(isTenDotIp('10.0.0'), false);
assert.equal(isTenDotIp('10.0.0.256'), false);
assert.equal(isTenDotIp('10.0.0.1.5'), false);
assert.equal(isTenDotIp('not-an-ip'), false);
assert.equal(isTenDotIp(10), false);
});
// ─── normalizeMac ───────────────────────────────────────────────────
test('normalizeMac: handles various input formats', () => {
assert.equal(normalizeMac('6cab05f62819'), '6c:ab:05:f6:28:19');
assert.equal(normalizeMac('6C:AB:05:F6:28:19'), '6c:ab:05:f6:28:19');
assert.equal(normalizeMac('6c-ab-05-f6-28-19'), '6c:ab:05:f6:28:19');
assert.equal(normalizeMac('6cab.05f6.2819'), '6c:ab:05:f6:28:19');
});
test('normalizeMac: rejects bad input', () => {
assert.equal(normalizeMac(null), null);
assert.equal(normalizeMac(''), null);
assert.equal(normalizeMac('not-a-mac'), null);
assert.equal(normalizeMac('6cab05f62819aa'), null); // 14 hex chars
});
// ─── discoverDectBases ──────────────────────────────────────────────
test('discoverDectBases: empty input returns empty result (never throws)', () => {
assert.deepEqual(discoverDectBases({}), { bases: [], warnings: [] });
assert.deepEqual(discoverDectBases(null), { bases: [], warnings: [] });
assert.deepEqual(discoverDectBases({ dectBasestations: null }), { bases: [], warnings: [] });
});
test('discoverDectBases: happy path — one Meraki-enriched base on 10.x', () => {
const result = discoverDectBases(fixture({
dectBasestations: [
base({ meraki: { ip: '10.4.11.87' } }),
],
}));
assert.equal(result.bases.length, 1);
assert.equal(result.warnings.length, 0);
assert.deepEqual(result.bases[0], {
mac: '6c:ab:05:f6:28:19',
ip: '10.4.11.87',
name: 'Basestation A',
source: 'meraki',
});
});
test('discoverDectBases: prefers Meraki IP over Webex IP', () => {
const result = discoverDectBases(fixture({
dectBasestations: [
base({
ipAddress: '10.4.11.100', // Webex-reported (potentially stale)
meraki: { ip: '10.4.11.87' }, // Meraki-reported (live)
}),
],
}));
assert.equal(result.bases[0].ip, '10.4.11.87');
assert.equal(result.bases[0].source, 'meraki');
});
test('discoverDectBases: falls back to Webex IP when no Meraki data', () => {
const result = discoverDectBases(fixture({
dectBasestations: [
base({ ipAddress: '10.4.11.87', meraki: {} }),
],
}));
assert.equal(result.bases[0].ip, '10.4.11.87');
assert.equal(result.bases[0].source, 'webex');
});
test('discoverDectBases: 10.x guardrail rejects non-corporate IPs with a warning', () => {
const result = discoverDectBases(fixture({
dectBasestations: [
base({ mac: 'aa:bb:cc:dd:ee:01', meraki: { ip: '192.168.1.164' } }),
base({ mac: 'aa:bb:cc:dd:ee:02', meraki: { ip: '172.16.0.100' } }),
base({ mac: 'aa:bb:cc:dd:ee:03', meraki: { ip: '10.4.11.87' } }),
],
}));
assert.equal(result.bases.length, 1);
assert.equal(result.bases[0].ip, '10.4.11.87');
assert.equal(result.warnings.length, 2);
// Both warnings should reference the offending IPs and the 10.x rule.
assert.match(result.warnings[0].reason, /192\.168\.1\.164/);
assert.match(result.warnings[0].reason, /10\.0\.0\.0\/8/);
assert.match(result.warnings[1].reason, /172\.16\.0\.100/);
});
test('discoverDectBases: rejects bases with no MAC (inventory bug)', () => {
const result = discoverDectBases(fixture({
dectBasestations: [
base({ mac: null, meraki: { ip: '10.4.11.87' } }),
],
}));
assert.equal(result.bases.length, 0);
assert.equal(result.warnings.length, 1);
assert.match(result.warnings[0].reason, /no MAC/i);
});
test('discoverDectBases: rejects bases with no IP anywhere', () => {
const result = discoverDectBases(fixture({
dectBasestations: [
base({ ipAddress: '—', meraki: {} }),
],
}));
assert.equal(result.bases.length, 0);
assert.equal(result.warnings.length, 1);
assert.match(result.warnings[0].reason, /no IP address/i);
});
test('discoverDectBases: dedups by IP and MAC (keeps first)', () => {
const result = discoverDectBases(fixture({
dectBasestations: [
base({ mac: 'aa:bb:cc:dd:ee:01', meraki: { ip: '10.4.11.87' }, name: 'first' }),
base({ mac: 'aa:bb:cc:dd:ee:02', meraki: { ip: '10.4.11.87' }, name: 'duplicate-ip' }),
base({ mac: 'aa:bb:cc:dd:ee:01', meraki: { ip: '10.4.11.88' }, name: 'duplicate-mac' }),
],
}));
assert.equal(result.bases.length, 1);
assert.equal(result.bases[0].name, 'first');
assert.equal(result.warnings.length, 2);
});