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).
169 lines
6.2 KiB
JavaScript
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);
|
|
});
|