Introduces a full Palo Alto Prisma SD-WAN integration (dual-mode SASE OAuth 2.0 / legacy CloudGenix auth, pagination, 429 backoff, session priming) that surfaces per-path latency/jitter/loss/MOS, site healthscore, link state, and alarm data for a store. Wired into the /phonestatus WAN follow-up and eight new /voicediag WAN checks graded against ITU-T G.114 / RFC 3550 defaults (env-overridable via WAN_STANDARD_*). Also adds a shape-aware detail renderer for /voicediag (per-link tables with verdict icons instead of a stringified JSON dump) and a --window flag (15m / 1h / 6h / 24h / 1d, env default via WAN_STANDARD_WINDOW_MINUTES) so operators can widen the look-back without redeploying. scripts/prismaProbe.js is bundled as a CLI for schema iteration against a live tenant. Co-authored-by: Cursor <cursoragent@cursor.com>
348 lines
13 KiB
JavaScript
348 lines
13 KiB
JavaScript
// tests/voiceDiag.wan.test.js
|
|
//
|
|
// Unit coverage for the 8 WAN checks under services/voiceDiag/checks/wan/.
|
|
// Same pattern as tests/voiceDiag.checks.test.js — build a stub
|
|
// ctx with a `sdwanSite` + `sdwanData` (no HTTP, no Prisma) and
|
|
// assert each check's verdict.
|
|
//
|
|
// Every check gets:
|
|
// - happy path (data present, in the compliant range)
|
|
// - threshold breach (warn and/or error where applicable)
|
|
// - missing data (skipped with a clear reason)
|
|
// - kill-switch skip (WAN_STANDARD_ENABLED=false)
|
|
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { wanSiteCheck } from '../services/voiceDiag/checks/wan/wanSite.js';
|
|
import { wanHealthscoreCheck } from '../services/voiceDiag/checks/wan/wanHealthscore.js';
|
|
import { wanLinkStateCheck } from '../services/voiceDiag/checks/wan/wanLinkState.js';
|
|
import { wanLatencyCheck } from '../services/voiceDiag/checks/wan/wanLatency.js';
|
|
import { wanJitterCheck } from '../services/voiceDiag/checks/wan/wanJitter.js';
|
|
import { wanLossCheck } from '../services/voiceDiag/checks/wan/wanLoss.js';
|
|
import { wanMosCheck } from '../services/voiceDiag/checks/wan/wanMos.js';
|
|
import { wanAlarmsCheck } from '../services/voiceDiag/checks/wan/wanAlarms.js';
|
|
import { CHECKS } from '../services/voiceDiag/checks/index.js';
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────
|
|
|
|
function mkWanCtx({ site = { id: 'site-1', name: 'CG00782' }, links = [], healthscore = null, alarms = null, elements = [] } = {}) {
|
|
return {
|
|
storeNum: '782',
|
|
personId: null,
|
|
personLabel: 'store 782',
|
|
telephonyProfile: {},
|
|
phoneStatus: null,
|
|
sdwanSite: site,
|
|
sdwanData: site ? {
|
|
storeNum: '782',
|
|
site,
|
|
elements,
|
|
links,
|
|
healthscore,
|
|
alarms: alarms || { last1h: { critical: 0, major: 0, minor: 0 }, samples: [] },
|
|
errors: [],
|
|
} : null,
|
|
};
|
|
}
|
|
|
|
function link(overrides = {}) {
|
|
return {
|
|
interfaceId: 'if-mpls',
|
|
interfaceName: 'wan1',
|
|
elementId: 'el-1',
|
|
elementName: 'ION-A',
|
|
transportType: 'MPLS',
|
|
up: true,
|
|
latencyMs: 40,
|
|
jitterMs: 5,
|
|
lossPct: 0.1,
|
|
mos: 4.4,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function withKillSwitchOn(fn) {
|
|
const prev = process.env.WAN_STANDARD_ENABLED;
|
|
process.env.WAN_STANDARD_ENABLED = 'false';
|
|
try { await fn(); }
|
|
finally {
|
|
if (prev === undefined) delete process.env.WAN_STANDARD_ENABLED;
|
|
else process.env.WAN_STANDARD_ENABLED = prev;
|
|
}
|
|
}
|
|
|
|
// ─── wanSite ────────────────────────────────────────────────────────
|
|
|
|
test('wanSite: happy path → ok with site + element + link counts in message', async () => {
|
|
const ctx = mkWanCtx({
|
|
elements: [{ id: 'el-1', connected: true }, { id: 'el-2', connected: false }],
|
|
links: [link(), link({ interfaceId: 'if-bb' })],
|
|
});
|
|
const r = await wanSiteCheck.run(ctx);
|
|
assert.equal(r.status, 'ok');
|
|
assert.match(r.message, /CG00782/);
|
|
assert.equal(r.details.elementCount, 2);
|
|
assert.equal(r.details.connectedElementCount, 1);
|
|
assert.equal(r.details.linkCount, 2);
|
|
});
|
|
|
|
test('wanSite: kill-switch skips', async () => {
|
|
await withKillSwitchOn(async () => {
|
|
const r = await wanSiteCheck.run(mkWanCtx({ elements: [{ id: 'x', connected: true }] }));
|
|
assert.equal(r.status, 'skipped');
|
|
assert.match(r.message, /WAN_STANDARD_ENABLED/);
|
|
});
|
|
});
|
|
|
|
// ─── wanHealthscore ─────────────────────────────────────────────────
|
|
|
|
test('wanHealthscore: 92/100 → ok', async () => {
|
|
const r = await wanHealthscoreCheck.run(mkWanCtx({ healthscore: { value: 92, breakdown: {} } }));
|
|
assert.equal(r.status, 'ok');
|
|
});
|
|
|
|
test('wanHealthscore: 70/100 → warn (< default warn 80)', async () => {
|
|
const r = await wanHealthscoreCheck.run(mkWanCtx({ healthscore: { value: 70, breakdown: {} } }));
|
|
assert.equal(r.status, 'warn');
|
|
assert.equal(r.details.value, 70);
|
|
});
|
|
|
|
test('wanHealthscore: 40/100 → error (< default error 60)', async () => {
|
|
const r = await wanHealthscoreCheck.run(mkWanCtx({ healthscore: { value: 40, breakdown: {} } }));
|
|
assert.equal(r.status, 'error');
|
|
});
|
|
|
|
test('wanHealthscore: env override adjusts thresholds', async () => {
|
|
const prev = process.env.WAN_STANDARD_HEALTHSCORE_WARN;
|
|
process.env.WAN_STANDARD_HEALTHSCORE_WARN = '95';
|
|
try {
|
|
const r = await wanHealthscoreCheck.run(mkWanCtx({ healthscore: { value: 92 } }));
|
|
assert.equal(r.status, 'warn', '92 should warn once threshold is raised to 95');
|
|
} finally {
|
|
if (prev === undefined) delete process.env.WAN_STANDARD_HEALTHSCORE_WARN;
|
|
else process.env.WAN_STANDARD_HEALTHSCORE_WARN = prev;
|
|
}
|
|
});
|
|
|
|
test('wanHealthscore: missing → skipped', async () => {
|
|
const r = await wanHealthscoreCheck.run(mkWanCtx({ healthscore: null }));
|
|
assert.equal(r.status, 'skipped');
|
|
});
|
|
|
|
test('wanHealthscore: kill-switch skips', async () => {
|
|
await withKillSwitchOn(async () => {
|
|
const r = await wanHealthscoreCheck.run(mkWanCtx({ healthscore: { value: 40 } }));
|
|
assert.equal(r.status, 'skipped');
|
|
assert.match(r.message, /WAN_STANDARD_ENABLED/);
|
|
});
|
|
});
|
|
|
|
// ─── wanLinkState ───────────────────────────────────────────────────
|
|
|
|
test('wanLinkState: all up → ok', async () => {
|
|
const r = await wanLinkStateCheck.run(mkWanCtx({
|
|
links: [link({ interfaceId: 'a', up: true }), link({ interfaceId: 'b', up: true })],
|
|
}));
|
|
assert.equal(r.status, 'ok');
|
|
assert.equal(r.details.up, 2);
|
|
});
|
|
|
|
test('wanLinkState: one down → error, offender named', async () => {
|
|
const r = await wanLinkStateCheck.run(mkWanCtx({
|
|
links: [
|
|
link({ interfaceId: 'a', up: true }),
|
|
link({ interfaceId: 'b', up: false, interfaceName: 'bb1', transportType: 'BROADBAND' }),
|
|
],
|
|
}));
|
|
assert.equal(r.status, 'error');
|
|
assert.equal(r.details.down, 1);
|
|
assert.match(r.message, /bb1/);
|
|
});
|
|
|
|
test('wanLinkState: all unknown, none up → warn', async () => {
|
|
const r = await wanLinkStateCheck.run(mkWanCtx({
|
|
links: [link({ up: null }), link({ interfaceId: 'x', up: null })],
|
|
}));
|
|
assert.equal(r.status, 'warn');
|
|
assert.equal(r.details.unknown, 2);
|
|
});
|
|
|
|
test('wanLinkState: no links → skipped', async () => {
|
|
const r = await wanLinkStateCheck.run(mkWanCtx({ links: [] }));
|
|
assert.equal(r.status, 'skipped');
|
|
});
|
|
|
|
// ─── wanLatency ─────────────────────────────────────────────────────
|
|
|
|
test('wanLatency: all under 150ms → ok', async () => {
|
|
const r = await wanLatencyCheck.run(mkWanCtx({ links: [link({ latencyMs: 40 }), link({ latencyMs: 80 })] }));
|
|
assert.equal(r.status, 'ok');
|
|
});
|
|
|
|
test('wanLatency: one path 220ms → warn (> 150, < 400)', async () => {
|
|
const r = await wanLatencyCheck.run(mkWanCtx({
|
|
links: [link({ latencyMs: 40 }), link({ interfaceId: 'x', interfaceName: 'x1', latencyMs: 220 })],
|
|
}));
|
|
assert.equal(r.status, 'warn');
|
|
assert.match(r.message, /x1/);
|
|
assert.match(r.message, /220ms/);
|
|
});
|
|
|
|
test('wanLatency: one path 500ms → error (> 400)', async () => {
|
|
const r = await wanLatencyCheck.run(mkWanCtx({
|
|
links: [link({ latencyMs: 40 }), link({ interfaceId: 'x', interfaceName: 'x1', latencyMs: 500 })],
|
|
}));
|
|
assert.equal(r.status, 'error');
|
|
});
|
|
|
|
test('wanLatency: env override adjusts thresholds', async () => {
|
|
const prev = process.env.WAN_STANDARD_LATENCY_WARN_MS;
|
|
process.env.WAN_STANDARD_LATENCY_WARN_MS = '50';
|
|
try {
|
|
const r = await wanLatencyCheck.run(mkWanCtx({ links: [link({ latencyMs: 80 })] }));
|
|
assert.equal(r.status, 'warn', '80ms should warn when threshold is 50');
|
|
} finally {
|
|
if (prev === undefined) delete process.env.WAN_STANDARD_LATENCY_WARN_MS;
|
|
else process.env.WAN_STANDARD_LATENCY_WARN_MS = prev;
|
|
}
|
|
});
|
|
|
|
test('wanLatency: no data → skipped', async () => {
|
|
const r = await wanLatencyCheck.run(mkWanCtx({ links: [link({ latencyMs: null })] }));
|
|
assert.equal(r.status, 'skipped');
|
|
});
|
|
|
|
test('wanLatency: kill-switch skips', async () => {
|
|
await withKillSwitchOn(async () => {
|
|
const r = await wanLatencyCheck.run(mkWanCtx({ links: [link({ latencyMs: 500 })] }));
|
|
assert.equal(r.status, 'skipped');
|
|
});
|
|
});
|
|
|
|
// ─── wanJitter ──────────────────────────────────────────────────────
|
|
|
|
test('wanJitter: 5ms → ok', async () => {
|
|
const r = await wanJitterCheck.run(mkWanCtx({ links: [link({ jitterMs: 5 })] }));
|
|
assert.equal(r.status, 'ok');
|
|
});
|
|
|
|
test('wanJitter: 40ms → warn (> 30, < 50)', async () => {
|
|
const r = await wanJitterCheck.run(mkWanCtx({ links: [link({ jitterMs: 40 })] }));
|
|
assert.equal(r.status, 'warn');
|
|
});
|
|
|
|
test('wanJitter: 100ms → error (> 50)', async () => {
|
|
const r = await wanJitterCheck.run(mkWanCtx({ links: [link({ jitterMs: 100 })] }));
|
|
assert.equal(r.status, 'error');
|
|
});
|
|
|
|
// ─── wanLoss ────────────────────────────────────────────────────────
|
|
|
|
test('wanLoss: 0.1% → ok', async () => {
|
|
const r = await wanLossCheck.run(mkWanCtx({ links: [link({ lossPct: 0.1 })] }));
|
|
assert.equal(r.status, 'ok');
|
|
});
|
|
|
|
test('wanLoss: 2% → warn (> 1, < 3)', async () => {
|
|
const r = await wanLossCheck.run(mkWanCtx({ links: [link({ lossPct: 2 })] }));
|
|
assert.equal(r.status, 'warn');
|
|
});
|
|
|
|
test('wanLoss: 5% → error (> 3)', async () => {
|
|
const r = await wanLossCheck.run(mkWanCtx({ links: [link({ lossPct: 5 })] }));
|
|
assert.equal(r.status, 'error');
|
|
});
|
|
|
|
// ─── wanMos ─────────────────────────────────────────────────────────
|
|
// MOS is inverted — low is bad.
|
|
|
|
test('wanMos: 4.4 → ok (>= 4.0)', async () => {
|
|
const r = await wanMosCheck.run(mkWanCtx({ links: [link({ mos: 4.4 })] }));
|
|
assert.equal(r.status, 'ok');
|
|
});
|
|
|
|
test('wanMos: 3.8 → warn (< 4.0, >= 3.5)', async () => {
|
|
const r = await wanMosCheck.run(mkWanCtx({ links: [link({ mos: 3.8 })] }));
|
|
assert.equal(r.status, 'warn');
|
|
});
|
|
|
|
test('wanMos: 3.0 → error (< 3.5)', async () => {
|
|
const r = await wanMosCheck.run(mkWanCtx({ links: [link({ mos: 3.0 })] }));
|
|
assert.equal(r.status, 'error');
|
|
});
|
|
|
|
// ─── wanAlarms ──────────────────────────────────────────────────────
|
|
|
|
test('wanAlarms: none → ok', async () => {
|
|
const r = await wanAlarmsCheck.run(mkWanCtx());
|
|
assert.equal(r.status, 'ok');
|
|
});
|
|
|
|
test('wanAlarms: minor only → ok (informational)', async () => {
|
|
const r = await wanAlarmsCheck.run(mkWanCtx({
|
|
alarms: { last1h: { critical: 0, major: 0, minor: 3 }, samples: [] },
|
|
}));
|
|
assert.equal(r.status, 'ok');
|
|
assert.equal(r.details.minor, 3);
|
|
});
|
|
|
|
test('wanAlarms: major → warn', async () => {
|
|
const r = await wanAlarmsCheck.run(mkWanCtx({
|
|
alarms: { last1h: { critical: 0, major: 1, minor: 0 }, samples: [{ code: 'M1', message: 'wobble' }] },
|
|
}));
|
|
assert.equal(r.status, 'warn');
|
|
assert.match(r.message, /wobble/);
|
|
});
|
|
|
|
test('wanAlarms: critical → error', async () => {
|
|
const r = await wanAlarmsCheck.run(mkWanCtx({
|
|
alarms: { last1h: { critical: 2, major: 0, minor: 0 }, samples: [{ code: 'C1', message: 'boom' }] },
|
|
}));
|
|
assert.equal(r.status, 'error');
|
|
assert.match(r.message, /boom/);
|
|
});
|
|
|
|
test('wanAlarms: no alarms feed → skipped', async () => {
|
|
const ctx = mkWanCtx();
|
|
ctx.sdwanData.alarms = null;
|
|
const r = await wanAlarmsCheck.run(ctx);
|
|
assert.equal(r.status, 'skipped');
|
|
});
|
|
|
|
// ─── standards regression + registry ordering ──────────────────────
|
|
|
|
test('every WAN check exposes a standards object', () => {
|
|
const wanChecks = CHECKS.filter((c) => c.id.startsWith('wan'));
|
|
assert.equal(wanChecks.length, 8, 'expected 8 registered WAN checks');
|
|
const missing = wanChecks.filter((c) => !c.standards || typeof c.standards !== 'object');
|
|
assert.deepEqual(missing.map((c) => c.id), []);
|
|
});
|
|
|
|
test('WAN checks registered in the expected order after port bucket', () => {
|
|
const ids = CHECKS.map((c) => c.id);
|
|
const wanIds = ids.filter((id) => id.startsWith('wan'));
|
|
assert.deepEqual(wanIds, [
|
|
'wanSite',
|
|
'wanHealthscore',
|
|
'wanLinkState',
|
|
'wanLatency',
|
|
'wanJitter',
|
|
'wanLoss',
|
|
'wanMos',
|
|
'wanAlarms',
|
|
]);
|
|
// WAN bucket sits after the port bucket and before phoneOnline.
|
|
const portEnabledIdx = ids.indexOf('portEnabled');
|
|
const wanSiteIdx = ids.indexOf('wanSite');
|
|
const phoneOnlineIdx = ids.indexOf('phoneOnline');
|
|
assert.ok(portEnabledIdx < wanSiteIdx);
|
|
assert.ok(wanSiteIdx < phoneOnlineIdx);
|
|
});
|
|
|
|
test('no WAN check declares a remediation (diagnostic-only)', () => {
|
|
const wanChecks = CHECKS.filter((c) => c.id.startsWith('wan'));
|
|
for (const c of wanChecks) {
|
|
assert.equal(c.remediations, undefined, `${c.id} should not expose remediations`);
|
|
}
|
|
});
|