collabSupport/tests/sdwanEnrichment.test.js
jmcqueen 26ae704dff Tighten WAN tunnel and alarm rendering for actionable follow-ups.
Show overall tunnel status with downs only, list the last five alarms
with times, and note when overlay/physical alarms may explain bad voice DPI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 09:16:03 -04:00

1109 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// tests/sdwanEnrichment.test.js
//
// Split into two flavors:
// 1) Unit tests on the exported parse* / buildLinkRows helpers —
// these are pure functions, no HTTP.
// 2) Integration test of `collectSdwanForStore` end-to-end using a
// fake Prisma HTTP server. This exercises the site lookup +
// element fetch + parallel metric composition + `errors[]`
// preservation on partial failure.
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import {
collectSdwanForStore,
parseHealthscore,
buildLinkRows,
parseAlarms,
summarizeAppSeries,
resolveVoiceAppConfig,
annotateTunnelsWithAlarms,
_resetVoiceAppDeprecationFlag,
} from '../services/enrichment/sdwanEnrichment.js';
import { _resetSitesCache } from '../integrations/paloalto/sites.js';
import { _resetPrismaAuthCache } from '../integrations/paloalto/client.js';
// ─── Unit tests: parseHealthscore ───────────────────────────────────
test('parseHealthscore: nil / malformed inputs → null', () => {
assert.equal(parseHealthscore(null), null);
assert.equal(parseHealthscore({}), null);
assert.equal(parseHealthscore({ metrics: [] }), null);
assert.equal(parseHealthscore({ metrics: 'nope' }), null);
});
test('parseHealthscore: LIVE v2.6 shape — metrics[].series[].data[].datapoints[].value (WINNER)', () => {
// The actual live shape verified 2026-07-09 on tenant. Note the
// wrapper: data[0] is {statistics: 'max', datapoints: [...]}, NOT
// the array of samples itself.
const resp = {
metrics: [{
series: [{
name: 'Healthscore',
unit: 'gauge',
interval: '5min',
view: 'summary',
data: [{
statistics: 'max',
datapoints: [
{ time: '2026-07-09T12:45:00Z', value: 100 },
{ time: '2026-07-09T12:50:00Z', value: 98 },
{ time: '2026-07-09T12:55:00Z', value: 92 },
{ time: '2026-07-09T13:00:00Z', value: 87 },
],
}],
}],
}],
};
const hs = parseHealthscore(resp, '16158173173100144');
assert.equal(hs.value, 87, 'must pick the LAST datapoint in the wrapper');
assert.deepEqual(hs.breakdown, {});
});
test('parseHealthscore: LIVE v2.6 shape — empty datapoints → falls through to next shape', () => {
const resp = {
metrics: [{
series: [{
name: 'Healthscore',
data: [{ statistics: 'max', datapoints: [] }],
}],
}],
};
assert.equal(parseHealthscore(resp, 'target-site'), null,
'empty datapoints must not silently return a stale/undefined value');
});
test('parseHealthscore: FALLBACK v2.6 shape — metrics[0].sites[].healthscore', () => {
const resp = {
metrics: [{
name: 'Healthscore',
sites: [
{ site_id: 'other-1', healthscore: 55 },
{ site_id: 'target-site', healthscore: 92 },
],
}],
};
const hs = parseHealthscore(resp, 'target-site');
assert.equal(hs.value, 92, 'must pick the site entry whose site_id matches');
});
test('parseHealthscore: LIVE v2.6 shape — nested under .data.score', () => {
const resp = {
metrics: [{
name: 'Healthscore',
sites: [
{ site_id: 'target-site', data: { score: 87 } },
],
}],
};
const hs = parseHealthscore(resp, 'target-site');
assert.equal(hs.value, 87);
});
test('parseHealthscore: LIVE v2.6 shape — fallback picks any 0-100 numeric field', () => {
// Defensive: if Prisma renames the value field between versions,
// parseHealthscore falls through to the first numeric in 0-100 range.
const resp = {
metrics: [{
name: 'Healthscore',
sites: [{ site_id: 'target-site', mystery_key: 73, unrelated_id: 999 }],
}],
};
const hs = parseHealthscore(resp, 'target-site');
assert.equal(hs.value, 73, 'unrelated_id (999) skipped because outside 0-100');
});
test('parseHealthscore: LEGACY shape — metrics[0].series[].data[].value (pan.dev-documented)', () => {
// Fallback support for other tenants that DO return the pan.dev
// shape. This tenant doesn't, but we don't want to break others.
const resp = {
metrics: [{
name: 'Healthscore',
series: [
{ view: { site: 'wrong-1' }, data: [{ value: 40 }] },
{ view: { site: 'target-site' }, data: [{ value: 91 }] },
],
}],
};
const hs = parseHealthscore(resp, 'target-site');
assert.equal(hs.value, 91);
});
test('parseHealthscore: no siteId → first available (backwards-compat)', () => {
const resp = {
metrics: [{
name: 'Healthscore',
sites: [
{ site_id: 'first', healthscore: 77 },
{ site_id: 'second', healthscore: 33 },
],
}],
};
const hs = parseHealthscore(resp);
assert.equal(hs.value, 77);
});
// ─── Unit tests: buildLinkRows ──────────────────────────────────────
//
// The waninterface config list is the source of truth for the row
// set. LQM metrics are layered in as overlays keyed by
// `view.waninterface`. `up` comes from the waninterface's admin
// state (the closest we have to a runtime up/down signal until we
// wire /waninterfaces/{id}/status per element in a later phase).
/**
* Build a live-shape LQM response (metrics[].sites[].paths[].data.<key>).
* This is the shape verified against a real tenant on 2026-07-09 via
* /scripts/prismaProbe.js — not the pan.dev-documented shape.
*/
function lqmLiveResp({ siteId = 'site-A', pathId, dataKey, value, completeness = 100 }) {
return {
metrics: [{
name: 'LqmLatencyPointMetric',
unit: 'milliseconds',
sites: [{
site_id: siteId,
paths: [{
path_id: pathId,
remote_site_id: '0',
data: {
sample_completeness: completeness,
[dataKey]: value,
},
}],
}],
}],
};
}
// Legacy shape (pan.dev-documented) — kept as a fallback test target
// so we notice if a future tenant returns this variant.
function lqmLegacySeries(waninterfaceId, points) {
return { view: { waninterface: waninterfaceId }, data: points };
}
test('buildLinkRows: LIVE shape — metrics[].sites[].paths[].data.<metricKey>', () => {
const rows = buildLinkRows({
wanInterfaces: [
{ id: 'wi-mpls', name: 'MPLS Circuit', adminUp: true, usedFor: 'primary' },
{ id: 'wi-bb', name: 'Broadband', adminUp: true, usedFor: 'secondary' },
],
metricResponses: {
latency: lqmLiveResp({ pathId: 'wi-mpls', dataKey: 'rtt_latency', value: 42.3 }),
jitter: lqmLiveResp({ pathId: 'wi-mpls', dataKey: 'rtt_jitter', value: 8.7 }),
loss: lqmLiveResp({ pathId: 'wi-mpls', dataKey: 'pkt_loss_pct', value: 0.4 }),
mos: lqmLiveResp({ pathId: 'wi-mpls', dataKey: 'mos', value: 4.42 }),
},
});
assert.equal(rows.length, 2);
const mpls = rows.find((r) => r.interfaceId === 'wi-mpls');
assert.equal(mpls.interfaceName, 'MPLS Circuit');
assert.equal(mpls.up, true);
assert.equal(mpls.latencyMs, 42.3);
assert.equal(mpls.jitterMs, 8.7);
assert.equal(mpls.lossPct, 0.4);
assert.equal(mpls.mos, 4.42);
assert.equal(mpls.transportType, 'primary');
assert.equal(mpls.sampleCompleteness, 100, 'quality marker preserved for diagnostics');
const bb = rows.find((r) => r.interfaceId === 'wi-bb');
assert.equal(bb.up, true);
assert.equal(bb.latencyMs, null, 'broadband had no metric samples');
});
test('buildLinkRows: LIVE shape — matches paths by path_id (not view.path)', () => {
// Regression: initial parser looked for series[].view.path, missing
// the fact that Prisma's live tenant returns a completely different
// metrics[].sites[].paths[].path_id shape. Verified live 2026-07-09.
const rows = buildLinkRows({
wanInterfaces: [{ id: '16158173176610209', name: 'Inet1', adminUp: null, usedFor: null }],
metricResponses: {
latency: lqmLiveResp({
siteId: '16158173173100144',
pathId: '16158173176610209',
dataKey: 'rtt_latency',
value: 22.0121008,
}),
},
});
assert.equal(rows.length, 1);
assert.equal(rows[0].latencyMs, 22, 'rounded to 1 decimal');
});
test('buildLinkRows: LIVE shape — loss is directional (max of downlink + uplink)', () => {
// Prisma returns packet loss as two separate keys — one per
// direction — and the fallback scanner would pick whichever
// came first, silently under-reporting asymmetric loss patterns.
// We must fold them via max(). Verified live 2026-07-09 via
// `try-shapes lqm-loss` which returned keys:
// data.downlink_pkt_loss_avg and data.uplink_pkt_loss_avg
const rows = buildLinkRows({
wanInterfaces: [{ id: 'wi-1', name: 'w1', adminUp: true, usedFor: 'primary' }],
metricResponses: {
loss: {
metrics: [{
name: 'LqmPktLossPointMetric',
sites: [{
site_id: 'site-A',
paths: [{
path_id: 'wi-1',
data: {
sample_completeness: 100,
downlink_pkt_loss_avg: 0.0,
uplink_pkt_loss_avg: 2.5,
},
}],
}],
}],
},
},
});
assert.equal(rows.length, 1);
assert.equal(rows[0].lossPct, 2.5,
'must report the WORSE direction (2.5%) — reporting 0 would hide upstream loss');
});
test('buildLinkRows: LIVE shape — MOS is directional (MIN of downlink + uplink avg)', () => {
// Prisma returns MOS as 6 keys per path:
// downlink_mos_{avg,min,max} + uplink_mos_{avg,min,max}
// Lower MOS = worse audio, so if downlink is 4.5 (great) but
// uplink is 3.0 (barely usable), the call sounds bad and we must
// surface 3.0. Picking any single key (like the fallback did)
// would hide the problem. Verified live via `try-shapes lqm-mos`
// 2026-07-09.
const rows = buildLinkRows({
wanInterfaces: [{ id: 'wi-1', name: 'w1', adminUp: true, usedFor: 'primary' }],
metricResponses: {
mos: {
metrics: [{
name: 'LqmMosPointMetric',
sites: [{
site_id: 'site-A',
paths: [{
path_id: 'wi-1',
data: {
sample_completeness: 100,
downlink_mos_avg: 4.5,
downlink_mos_min: 4.2,
downlink_mos_max: 4.6,
uplink_mos_avg: 3.0,
uplink_mos_min: 2.7,
uplink_mos_max: 3.3,
},
}],
}],
}],
},
},
});
assert.equal(rows.length, 1);
assert.equal(rows[0].mos, 3.0,
'must report the WORSE direction avg (3.0) — reporting downlink 4.5 would hide bad-uplink calls');
});
test('buildLinkRows: LIVE shape — MOS with symmetric high values → picks the min', () => {
const rows = buildLinkRows({
wanInterfaces: [{ id: 'wi-1', name: 'w1', adminUp: true, usedFor: 'primary' }],
metricResponses: {
mos: {
metrics: [{
name: 'LqmMosPointMetric',
sites: [{
site_id: 'site-A',
paths: [{
path_id: 'wi-1',
data: {
sample_completeness: 100,
downlink_mos_avg: 4.39,
uplink_mos_avg: 4.42,
},
}],
}],
}],
},
},
});
assert.equal(rows[0].mos, 4.39,
'both directions healthy → still take the worse (lower) direction');
});
test('buildLinkRows: LIVE shape — loss with symmetric zero on both directions → 0 (not null)', () => {
const rows = buildLinkRows({
wanInterfaces: [{ id: 'wi-1', name: 'w1', adminUp: true, usedFor: 'primary' }],
metricResponses: {
loss: {
metrics: [{
name: 'LqmPktLossPointMetric',
sites: [{
site_id: 'site-A',
paths: [{
path_id: 'wi-1',
data: {
sample_completeness: 100,
downlink_pkt_loss_avg: 0,
uplink_pkt_loss_avg: 0,
},
}],
}],
}],
},
},
});
assert.equal(rows[0].lossPct, 0, 'zero loss must render as 0, NOT null');
});
test('buildLinkRows: LIVE shape — extractLqmValue fallback on unknown data key', () => {
// Defensive: if Prisma renames a data key between tenant versions,
// extractLqmValue falls back to the first numeric non-completeness
// field. Simulate that by using an unknown key name.
const rows = buildLinkRows({
wanInterfaces: [{ id: 'wi-1', name: 'w1', adminUp: true, usedFor: 'primary' }],
metricResponses: {
latency: {
metrics: [{
name: 'LqmLatencyPointMetric',
sites: [{
site_id: 'site-A',
paths: [{ path_id: 'wi-1', data: { sample_completeness: 100, mystery_new_key: 55 } }],
}],
}],
},
},
});
assert.equal(rows.length, 1);
assert.equal(rows[0].latencyMs, 55, 'fallback picked the first numeric non-completeness field');
});
test('buildLinkRows: LEGACY shape (pan.dev) — metrics[].series[].data[].value with view.path', () => {
// Fallback shape support — should keep working for any tenant that
// returns the pan.dev-documented response.
const rows = buildLinkRows({
wanInterfaces: [{ id: 'wi-mpls', name: 'MPLS', adminUp: true, usedFor: 'primary' }],
metricResponses: {
latency: { metrics: [{ series: [{ view: { path: 'wi-mpls' }, data: [{ value: 25 }] }] }] },
},
});
assert.equal(rows.length, 1);
assert.equal(rows[0].latencyMs, 25);
});
test('buildLinkRows: LEGACY shape — last non-null sample wins', () => {
const rows = buildLinkRows({
wanInterfaces: [{ id: 'wi-1', name: 'w1', adminUp: true, usedFor: 'primary' }],
metricResponses: {
latency: { metrics: [{ series: [
lqmLegacySeries('wi-1', [{ value: 10 }, { value: null }, { value: 20 }, { value: null }]),
]}]},
},
});
assert.equal(rows.length, 1);
assert.equal(rows[0].latencyMs, 20);
});
test('buildLinkRows: LQM samples for unknown path_id are dropped', () => {
const rows = buildLinkRows({
wanInterfaces: [{ id: 'wi-1', name: 'w1', adminUp: true, usedFor: 'primary' }],
metricResponses: {
latency: lqmLiveResp({ pathId: 'wi-orphan', dataKey: 'rtt_latency', value: 99 }),
},
});
assert.equal(rows.length, 1);
assert.equal(rows[0].interfaceId, 'wi-1');
assert.equal(rows[0].latencyMs, null, 'no sample matched the configured wan interface');
});
test('buildLinkRows: no waninterfaces → empty list even with LQM data', () => {
const rows = buildLinkRows({
wanInterfaces: [],
metricResponses: {
latency: lqmLiveResp({ pathId: 'wi-1', dataKey: 'rtt_latency', value: 10 }),
},
});
assert.deepEqual(rows, []);
});
test('buildLinkRows: runtime operationalUp preferred over adminUp', () => {
const rows = buildLinkRows({
wanInterfaces: [
{ id: 'wi-1', name: 'Primary', adminUp: true, usedFor: 'primary' },
],
statusByInterfaceId: new Map([
['wi-1', { operationalUp: false, adminUp: true, elementId: 'el-1' }],
]),
metricResponses: {},
});
assert.equal(rows[0].up, false);
assert.equal(rows[0].operationalUp, false);
assert.equal(rows[0].adminUp, true);
assert.equal(rows[0].elementId, 'el-1');
});
test('annotateTunnelsWithAlarms: marks tunnels cited in alarm vpnLinkIds', () => {
const tunnels = annotateTunnelsWithAlarms(
[{ id: 'vpn-link-2', up: true }, { id: 'vpn-link-1', up: true }],
{ samples: [{ vpnLinkIds: ['vpn-link-2'] }] },
);
assert.equal(tunnels[0].recentAlarm, true);
assert.equal(tunnels[1].recentAlarm, false);
});
test('buildLinkRows: admin-down waninterface reports up:false', () => {
const rows = buildLinkRows({
wanInterfaces: [{ id: 'wi-lte', name: 'LTE', adminUp: false, usedFor: 'backup' }],
metricResponses: {},
});
assert.equal(rows.length, 1);
assert.equal(rows[0].up, false);
});
test('buildLinkRows: adminUp:null but WITH LQM samples → up:true (data-flowing == up)', () => {
// Regression: on the live tenant, admin_up isn't exposed by the
// waninterface config API — every row starts with up=null. But if
// Prisma returns LQM samples for a path, that path is provably
// carrying traffic. Since traffic == up, we upgrade the row.
// Otherwise the WAN Link State check flags every path as unknown
// even when circuits are visibly healthy.
const rows = buildLinkRows({
wanInterfaces: [{ id: 'wi-x', name: 'Inet1', adminUp: null, usedFor: 'primary' }],
metricResponses: {
latency: lqmLiveResp({ pathId: 'wi-x', dataKey: 'rtt_latency', value: 22.3 }),
},
});
assert.equal(rows.length, 1);
assert.equal(rows[0].up, true, 'LQM samples prove the path is carrying traffic');
assert.equal(rows[0].latencyMs, 22.3);
});
test('buildLinkRows: adminUp:null WITHOUT LQM samples → up:null (still unknown)', () => {
// Complement of the previous test: if we have NEITHER admin_up
// NOR LQM samples, we genuinely don't know the state and must
// NOT lie about it.
const rows = buildLinkRows({
wanInterfaces: [{ id: 'wi-x', name: 'Inet1', adminUp: null, usedFor: 'primary' }],
metricResponses: {},
});
assert.equal(rows.length, 1);
assert.equal(rows[0].up, null, 'null (unknown) is meaningfully different from false (admin-down)');
});
test('buildLinkRows: adminUp:false → up:false even when LQM samples arrive (admin-disabled wins)', () => {
// Safety: an admin-disabled circuit is DOWN regardless of what
// Prisma monitoring says. Never upgrade explicit adminUp=false.
const rows = buildLinkRows({
wanInterfaces: [{ id: 'wi-lte', name: 'LTE', adminUp: false, usedFor: 'backup' }],
metricResponses: {
latency: lqmLiveResp({ pathId: 'wi-lte', dataKey: 'rtt_latency', value: 45 }),
},
});
assert.equal(rows.length, 1);
assert.equal(rows[0].up, false, 'admin-disabled must remain down even with monitor samples');
assert.equal(rows[0].latencyMs, 45, 'samples still recorded for diagnostics');
});
// ─── Unit tests: parseAlarms ────────────────────────────────────────
test('parseAlarms: nil → empty counts', () => {
const a = parseAlarms(null);
assert.deepEqual(a.last1h, { critical: 0, major: 0, minor: 0 });
assert.deepEqual(a.samples, []);
});
test('parseAlarms: counts by severity, keeps up to 50 samples (newest first)', () => {
const resp = {
items: [
{ severity: 'critical', code: 'C1', info: 'boom', time: '2025-01-01T10:00:00Z' },
{ severity: 'major', code: 'M1', info: 'wobble', time: '2025-01-01T09:00:00Z' },
{ severity: 'major', code: 'M2', info: 'wobble2', time: '2025-01-01T08:00:00Z' },
{ severity: 'minor', code: 'm1', info: 'meh', time: '2025-01-01T07:00:00Z' },
{ severity: 'minor', code: 'm2', info: 'meh2', time: '2025-01-01T06:00:00Z' },
{ severity: 'minor', code: 'm3', info: 'meh3', time: '2025-01-01T05:00:00Z' },
],
};
const a = parseAlarms(resp);
assert.equal(a.last1h.critical, 1);
assert.equal(a.last1h.major, 2);
assert.equal(a.last1h.minor, 3);
assert.equal(a.samples.length, 6);
assert.equal(a.samples[0].code, 'C1', 'newest first');
});
test('parseAlarms: caps samples at 50', () => {
const items = Array.from({ length: 60 }, (_, i) => ({
severity: 'minor',
code: `C${i}`,
info: 'x',
time: new Date(Date.UTC(2025, 0, 1, 12, 0, i)).toISOString(),
}));
const a = parseAlarms({ items });
assert.equal(a.samples.length, 50);
assert.equal(a.last1h.minor, 60);
});
test('parseAlarms: filters out cleared alarms (events/query returns both open + cleared)', () => {
const resp = {
items: [
{ severity: 'critical', code: 'STILL_OPEN', info: 'x', time: 't1' },
{ severity: 'critical', code: 'ALREADY_FIXED', info: 'y', time: 't2', cleared: true },
{ severity: 'major', code: 'ALSO_CLEARED', info: 'z', time: 't3', cleared: true },
],
};
const a = parseAlarms(resp);
assert.equal(a.last1h.critical, 1, 'only the still-open critical is counted');
assert.equal(a.last1h.major, 0);
assert.equal(a.samples.length, 1);
assert.equal(a.samples[0].code, 'STILL_OPEN');
});
test('parseAlarms: local site filter drops events whose site_id does not match', () => {
// Regression: Prisma's events/query sometimes silently ignores our
// `query.site` filter, in which case we get tenant-wide events. If
// we counted those as this-site alarms we'd report wildly inflated
// counts (e.g. 20 major for a small store). Local site match here
// is the safety net.
const resp = {
items: [
{ severity: 'critical', code: 'OUR_ALARM', site_id: 'target-site', time: 't1' },
{ severity: 'major', code: 'OTHER_STORE', site_id: 'other-site-1', time: 't2' },
{ severity: 'major', code: 'ALSO_OTHER', site_id: 'other-site-2', time: 't3' },
// No site_id — tenant-scoped event, kept
{ severity: 'minor', code: 'TENANT_WIDE', time: 't4' },
],
};
const a = parseAlarms(resp, 'target-site');
assert.equal(a.last1h.critical, 1);
assert.equal(a.last1h.major, 0, 'other-store alarms must NOT count toward this site');
assert.equal(a.last1h.minor, 1, 'events with no site_id are kept (tenant-scoped)');
assert.equal(a._diag.rawEventCount, 4);
assert.equal(a._diag.droppedForSiteMismatch, 2);
});
test('parseAlarms: no siteId argument → no local scoping (backwards-compat)', () => {
const resp = {
items: [
{ severity: 'critical', code: 'A', site_id: 'anything', time: 't1' },
{ severity: 'major', code: 'B', site_id: 'something-else', time: 't2' },
],
};
const a = parseAlarms(resp);
assert.equal(a.last1h.critical, 1);
assert.equal(a.last1h.major, 1, 'no siteId → do not drop by site');
assert.equal(a._diag.droppedForSiteMismatch, 0);
});
test('parseAlarms: nested info object → JSON-stringified (not "[object Object]")', () => {
// Live Prisma events regularly carry `info` as a nested object,
// e.g. { vpn_link_id: '...' }. If we let String() coerce this we
// dump [object Object] into chat. Flatten to JSON instead.
const resp = {
items: [
{ severity: 'major', code: 'ANYNET_DOWN', info: { vpn_link_id: 'link-1' }, time: 't' },
],
};
const a = parseAlarms(resp);
assert.equal(a.samples[0].message.startsWith('{'), true,
'nested object should be JSON-stringified for renderer safety');
assert.match(a.samples[0].message, /vpn_link_id/);
});
// ─── Integration: collectSdwanForStore end-to-end ───────────────────
async function makeFakePrisma(handlers) {
const server = http.createServer((req, res) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
if (req.url === '/oauth2/access_token') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ access_token: 't', expires_in: 900 }));
return;
}
// Mandatory SASE unified SD-WAN session priming call.
if (req.url === '/sdwan/v2.1/api/profile' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 'stub-profile' }));
return;
}
// Path-only fallback so tests don't have to encode ?limit=1000
// etc. in their handler keys.
const pathOnly = (req.url || '').split('?')[0];
const h = handlers[`${req.method} ${req.url}`] || handlers[`${req.method} ${pathOnly}`];
if (h) {
const parsed = body ? JSON.parse(body) : null;
const r = h({ req, body: parsed });
res.writeHead(r.status || 200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(r.body || {}));
return;
}
res.writeHead(404);
res.end();
});
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const { port } = server.address();
return { baseUrl: `http://127.0.0.1:${port}`, close: () => new Promise((r) => server.close(r)) };
}
function setupSaseEnv(baseUrl) {
process.env.PRISMA_AUTH_MODE = 'sase';
process.env.PRISMA_SASE_BASE_URL = baseUrl;
process.env.PRISMA_AUTH_URL = `${baseUrl}/oauth2/access_token`;
process.env.PRISMA_CLIENT_ID = 'id';
process.env.PRISMA_CLIENT_SECRET = 'secret';
process.env.PRISMA_TSG_ID = 'tsg';
}
function clearEnv() {
['PRISMA_AUTH_MODE','PRISMA_SASE_BASE_URL','PRISMA_AUTH_URL',
'PRISMA_CLIENT_ID','PRISMA_CLIENT_SECRET','PRISMA_TSG_ID',
'PRISMA_LEGACY_BASE_URL','PRISMA_EMAIL','PRISMA_PASSWORD']
.forEach((k) => delete process.env[k]);
}
test('collectSdwanForStore: no site → short-circuits with site:null', async () => {
_resetSitesCache(); _resetPrismaAuthCache();
const fake = await makeFakePrisma({
'GET /sdwan/v4.13/api/sites': () => ({
body: { items: [{ id: 's', name: 'CG99999' }] },
}),
});
setupSaseEnv(fake.baseUrl);
try {
const data = await collectSdwanForStore(782);
assert.equal(data.site, null);
assert.deepEqual(data.elements, []);
assert.deepEqual(data.links, []);
assert.equal(data.healthscore, null);
assert.equal(data.errors.length, 0);
assert.equal(data.storeNum, '782');
} finally {
await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearEnv();
}
});
test('collectSdwanForStore: happy path composes site+elements+waninterfaces+LQM', async () => {
_resetSitesCache(); _resetPrismaAuthCache();
const fake = await makeFakePrisma({
'GET /sdwan/v4.13/api/sites': () => ({
body: { items: [{ id: 'site-A', name: 'CG00782', description: 'store 782' }] },
}),
'GET /sdwan/v3.1/api/elements': () => ({
body: { items: [{ id: 'el-1', name: 'ION-A', connected: true, site_id: 'site-A' }] },
}),
'GET /sdwan/v2.10/api/sites/site-A/waninterfaces': () => ({
body: { items: [
{ id: 'wi-mpls', name: 'MPLS Circuit', admin_up: true, used_for: 'primary' },
{ id: 'wi-bb', name: 'Broadband', admin_up: true, used_for: 'secondary' },
]},
}),
'POST /sdwan/monitor/v2.6/api/monitor/metrics': () => ({
// LIVE v2.6 healthscore response shape verified 2026-07-09.
// The response is already server-side scoped to filter.site,
// so no siteId filtering needed by parseHealthscore.
body: {
metrics: [{
series: [{
name: 'Healthscore',
unit: 'gauge',
interval: '5min',
view: 'summary',
data: [{
statistics: 'max',
datapoints: [
{ time: '2026-07-09T12:45:00Z', value: 88 },
{ time: '2026-07-09T13:00:00Z', value: 92 },
],
}],
}],
}],
},
}),
'POST /sdwan/monitor/v2.0/api/monitor/lqm_point_metrics': ({ body }) => {
const name = body?.metrics?.[0]?.name;
// Live-tenant data shapes per metric (verified 2026-07-09):
// latency/jitter/mos → single scalar under RTT/aggregate key
// loss → TWO keys, directional. buildLinkRows
// folds via max(downlink, uplink).
const dataFor = {
LqmLatencyPointMetric: { rtt_latency: 30 },
LqmJitterPointMetric: { rtt_jitter: 5 },
LqmPktLossPointMetric: { downlink_pkt_loss_avg: 0.05, uplink_pkt_loss_avg: 0.1 },
// MOS is directional × 3 stats. buildLinkRows takes
// min(downlink_mos_avg, uplink_mos_avg) — the worst average
// direction — so with downlink=4.35 + uplink=4.3 the row
// should end up at 4.3.
LqmMosPointMetric: {
downlink_mos_avg: 4.35,
downlink_mos_min: 4.2,
downlink_mos_max: 4.4,
uplink_mos_avg: 4.3,
uplink_mos_min: 4.1,
uplink_mos_max: 4.4,
},
};
return {
body: {
metrics: [{
name,
unit: body?.metrics?.[0]?.unit,
sites: [{
site_id: 'site-A',
paths: [{
path_id: 'wi-mpls',
remote_site_id: '0',
data: { sample_completeness: 100, ...(dataFor[name] || {}) },
}],
}],
}],
},
};
},
'POST /sdwan/v3.7/api/events/query': () => ({
body: { items: [] },
}),
});
setupSaseEnv(fake.baseUrl);
try {
const data = await collectSdwanForStore(782);
assert.equal(data.site?.name, 'CG00782');
assert.equal(data.elements.length, 1);
assert.equal(data.healthscore?.value, 92);
assert.equal(data.links.length, 2, 'one row per waninterface');
const mpls = data.links.find((l) => l.interfaceId === 'wi-mpls');
assert.equal(mpls.up, true);
assert.equal(mpls.latencyMs, 30);
assert.equal(mpls.jitterMs, 5);
assert.equal(mpls.lossPct, 0.1);
assert.equal(mpls.mos, 4.3);
assert.equal(mpls.transportType, 'primary');
const bb = data.links.find((l) => l.interfaceId === 'wi-bb');
assert.equal(bb.up, true);
assert.equal(bb.latencyMs, null, 'broadband has no LQM sample in the fixture');
assert.ok(Array.isArray(data.tunnels), 'tunnels array always present');
assert.equal(data.errors.length, 0);
} finally {
await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearEnv();
}
});
test('collectSdwanForStore: reports the effective window on the returned payload', async () => {
_resetSitesCache(); _resetPrismaAuthCache();
const fake = await makeFakePrisma({
'GET /sdwan/v4.13/api/sites': () => ({
body: { items: [{ id: 's', name: 'CG00782' }] },
}),
'GET /sdwan/v3.1/api/elements': () => ({
body: { items: [] },
}),
'GET /sdwan/v2.10/api/sites/s/waninterfaces': () => ({
body: { items: [] },
}),
'POST /sdwan/monitor/v2.6/api/monitor/metrics': () => ({
body: { metrics: [] },
}),
'POST /sdwan/monitor/v2.0/api/monitor/lqm_point_metrics': () => ({
body: { metrics: [] },
}),
'POST /sdwan/v3.7/api/events/query': () => ({
body: { items: [] },
}),
});
setupSaseEnv(fake.baseUrl);
try {
// Default (no opts): 7 days (10080 min). Widened from 24h because
// per-app DPI metrics only get datapoints when calls actually
// happen — sporadic Webex Calling stores need a wider window for
// worst-window statistics to be meaningful. Override via
// WAN_STANDARD_WINDOW_MINUTES env or --window on /voicediag.
let data = await collectSdwanForStore(782);
assert.equal(data.window?.minutes, 10080);
assert.equal(data.window?.alarmMinutes, 10080,
'alarms match the requested window when it is >= 60m');
// Explicit 15m override → metric window narrows, alarms floor at 60m
data = await collectSdwanForStore(782, { windowMinutes: 15 });
assert.equal(data.window?.minutes, 15);
assert.equal(data.window?.alarmMinutes, 60,
'alarms floor at 60m even when the requested window is smaller');
// Explicit 24h override — the common "live triage" case
data = await collectSdwanForStore(782, { windowMinutes: 1440 });
assert.equal(data.window?.minutes, 1440,
'24h stays 24h — no forced upgrade to 7d');
assert.equal(data.window?.alarmMinutes, 1440);
// Out-of-range values are clamped to [1, 10080]
data = await collectSdwanForStore(782, { windowMinutes: 99999 });
assert.equal(data.window?.minutes, 10080,
'capped at 7d — Prisma downsamples per-app series past this to 1-day buckets');
data = await collectSdwanForStore(782, { windowMinutes: -5 });
assert.equal(data.window?.minutes, 10080,
'invalid → falls to env default (7d, was 24h before the 2026-07-09 widen)');
} finally {
await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearEnv();
}
});
test('collectSdwanForStore: reports window even when site is unresolved', async () => {
_resetSitesCache(); _resetPrismaAuthCache();
const fake = await makeFakePrisma({
'GET /sdwan/v4.13/api/sites': () => ({
body: { items: [{ id: 's', name: 'CG99999' }] },
}),
});
setupSaseEnv(fake.baseUrl);
try {
const data = await collectSdwanForStore(782, { windowMinutes: 60 });
assert.equal(data.site, null);
assert.equal(data.window?.minutes, 60);
} finally {
await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearEnv();
}
});
test('collectSdwanForStore: preserves per-metric failure in errors[]', async () => {
_resetSitesCache(); _resetPrismaAuthCache();
const fake = await makeFakePrisma({
'GET /sdwan/v4.13/api/sites': () => ({
body: { items: [{ id: 'site-B', name: 'CG00782' }] },
}),
'GET /sdwan/v3.1/api/elements': () => ({
body: { items: [{ id: 'el-1', name: 'ION', connected: true, site_id: 'site-B' }] },
}),
'GET /sdwan/v2.10/api/sites/site-B/waninterfaces': () => ({
body: { items: [] },
}),
'POST /sdwan/monitor/v2.6/api/monitor/metrics': () => ({
status: 500, body: { error: 'boom' },
}),
'POST /sdwan/monitor/v2.0/api/monitor/lqm_point_metrics': () => ({
body: { metrics: [] },
}),
'POST /sdwan/v3.7/api/events/query': () => ({
body: { items: [] },
}),
});
setupSaseEnv(fake.baseUrl);
try {
const data = await collectSdwanForStore(782);
// Site + elements arrived; healthscore null because upstream
// returned 500 (the metric wrapper catches + returns null with
// a log line — no errors[] entry, healthscore just stays null).
assert.equal(data.site?.name, 'CG00782');
assert.equal(data.healthscore, null);
assert.equal(data.links.length, 0, 'no waninterfaces → no rows');
} finally {
await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearEnv();
}
});
// ─── Unit tests: summarizeAppSeries ─────────────────────────────────
//
// App metrics come back as a real time series (usually 288 pts over
// 24h at 5-min intervals). The summarizer must extract worst-window
// stats reliably regardless of null values, empty series, and the
// v2.6 response shape's quirks.
test('summarizeAppSeries: nil / malformed inputs → null', () => {
assert.equal(summarizeAppSeries(null, 'X'), null);
assert.equal(summarizeAppSeries({}, 'X'), null);
assert.equal(summarizeAppSeries({ metrics: [] }, 'X'), null);
assert.equal(summarizeAppSeries({ metrics: [{ series: [] }] }, 'X'), null);
});
test('summarizeAppSeries: HAR-shape MOS response → {avg,min,max,p95}', () => {
// Truncated version of the live rtp-base MOS response from
// rtp-base-metricCG00127.har (2026-07-09). The webex HAR
// (webex-base-metricCG00127.har) has an identical body shape —
// this single test covers both apps.
const resp = {
metrics: [{
series: [{
name: 'AppAudioMos',
unit: 'count',
interval: '5min',
data: [{
statistics: 'average',
datapoints: [
{ value: 3.5569644, time: 'T1' },
{ value: 3.55610356, time: 'T2' },
{ value: 3.92861172, time: 'T3' },
{ value: 1.8523214, time: 'T4' }, // worst window from the HAR
{ value: 4.4092858, time: 'T5' }, // best window
{ value: 4.0257409, time: 'T6' },
],
}],
}],
}],
};
const s = summarizeAppSeries(resp, 'AppAudioMos');
assert.ok(s, 'summary produced');
assert.equal(s.samples, 6);
assert.equal(s.validSamples, 6);
assert.equal(s.unit, 'count');
assert.equal(s.interval, '5min');
assert.equal(s.min, 1.85, 'worst-window MOS surfaced (the whole reason for the check)');
assert.equal(s.max, 4.41);
// Avg ≈ 3.5548 → 3.55 after round2
assert.equal(s.avg, 3.55);
assert.ok(Array.isArray(s.values), 'raw values retained for downstream stats');
assert.equal(s.values.length, 6);
});
test('summarizeAppSeries: all-null datapoints → validSamples=0, avg/min/max null', () => {
const resp = {
metrics: [{ series: [{
name: 'AppPerfUDPAudioJitter', unit: 'milliseconds', interval: '5min',
data: [{ datapoints: [{ value: null, time: 'T1' }, { time: 'T2' }] }],
}] }],
};
const s = summarizeAppSeries(resp, 'AppPerfUDPAudioJitter');
assert.equal(s.samples, 2);
assert.equal(s.validSamples, 0);
assert.equal(s.avg, null);
assert.equal(s.min, null);
assert.equal(s.max, null);
});
test('summarizeAppSeries: mixed null + numeric → nulls ignored in aggregates', () => {
const resp = {
metrics: [{ series: [{
name: 'AppPerfUDPAudioPacketLoss', unit: 'percentage', interval: '5min',
data: [{ datapoints: [
{ value: null, time: 'T1' },
{ value: 22, time: 'T2' },
{ value: 0, time: 'T3' },
{ value: null, time: 'T4' },
{ value: 5, time: 'T5' },
] }],
}] }],
};
const s = summarizeAppSeries(resp, 'AppPerfUDPAudioPacketLoss');
assert.equal(s.samples, 5);
assert.equal(s.validSamples, 3);
assert.equal(s.min, 0);
assert.equal(s.max, 22);
assert.equal(s.avg, 9, '(22 + 0 + 5) / 3 = 9');
});
// ─── resolveVoiceAppConfig ──────────────────────────────────────────
//
// Reads env vars at call time and returns {appId, appName} for the
// tenant-configured voice application. Two env vars are honored:
// - PRISMA_APP_ID_VOICE (canonical) + PRISMA_APP_NAME_VOICE (name)
// - PRISMA_APP_ID_RTP_BASE (legacy, backwards-compat only)
//
// Every test here restores the original env so parallel test files
// don't stomp each other, and clears the deprecation-log dedupe flag
// so the deprecation-warning test can observe fresh log behavior.
function withVoiceEnv(overrides, fn) {
const KEYS = ['PRISMA_APP_ID_VOICE', 'PRISMA_APP_ID_RTP_BASE', 'PRISMA_APP_NAME_VOICE'];
const saved = Object.fromEntries(KEYS.map((k) => [k, process.env[k]]));
try {
for (const k of KEYS) delete process.env[k];
for (const [k, v] of Object.entries(overrides)) {
if (v !== undefined) process.env[k] = v;
}
_resetVoiceAppDeprecationFlag();
return fn();
} finally {
for (const k of KEYS) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
_resetVoiceAppDeprecationFlag();
}
}
test('resolveVoiceAppConfig: nothing set → {appId:null, appName:"voice"}', () => {
withVoiceEnv({}, () => {
const cfg = resolveVoiceAppConfig();
assert.equal(cfg.appId, null);
assert.equal(cfg.appName, 'voice');
});
});
test('resolveVoiceAppConfig: PRISMA_APP_ID_VOICE alone → id set, default name "voice"', () => {
withVoiceEnv({ PRISMA_APP_ID_VOICE: '1708539371717015196' }, () => {
const cfg = resolveVoiceAppConfig();
assert.equal(cfg.appId, '1708539371717015196');
assert.equal(cfg.appName, 'voice',
'unnamed config gets the generic "voice" label — tenants override with PRISMA_APP_NAME_VOICE');
});
});
test('resolveVoiceAppConfig: PRISMA_APP_NAME_VOICE overrides display label', () => {
// The recommended Webex Calling deployment.
withVoiceEnv({
PRISMA_APP_ID_VOICE: '1708539371717015196',
PRISMA_APP_NAME_VOICE: 'Webex_Calling_RTP',
}, () => {
const cfg = resolveVoiceAppConfig();
assert.equal(cfg.appId, '1708539371717015196');
assert.equal(cfg.appName, 'Webex_Calling_RTP');
});
});
test('resolveVoiceAppConfig: legacy PRISMA_APP_ID_RTP_BASE honored, appName defaults to "rtp-base"', () => {
// Backwards-compat path — existing deployments must keep working
// without any env changes. Display name defaults to "rtp-base" so
// dashboards/screenshots that reference the old label still match.
withVoiceEnv({ PRISMA_APP_ID_RTP_BASE: '15932000365560116' }, () => {
const cfg = resolveVoiceAppConfig();
assert.equal(cfg.appId, '15932000365560116');
assert.equal(cfg.appName, 'rtp-base');
});
});
test('resolveVoiceAppConfig: legacy id + explicit name override → uses both', () => {
withVoiceEnv({
PRISMA_APP_ID_RTP_BASE: '15932000365560116',
PRISMA_APP_NAME_VOICE: 'rtp',
}, () => {
const cfg = resolveVoiceAppConfig();
assert.equal(cfg.appId, '15932000365560116');
assert.equal(cfg.appName, 'rtp',
'explicit PRISMA_APP_NAME_VOICE wins over the legacy-default "rtp-base"');
});
});
test('resolveVoiceAppConfig: PRISMA_APP_ID_VOICE takes precedence over legacy PRISMA_APP_ID_RTP_BASE', () => {
// Migration scenario: operator set the new env alongside the old
// one before removing the old one. New value must win.
withVoiceEnv({
PRISMA_APP_ID_VOICE: '1708539371717015196',
PRISMA_APP_ID_RTP_BASE: '15932000365560116',
}, () => {
const cfg = resolveVoiceAppConfig();
assert.equal(cfg.appId, '1708539371717015196',
'canonical env var wins — legacy is only a fallback');
assert.equal(cfg.appName, 'voice',
'name default follows the new env, not the legacy one');
});
});
test('resolveVoiceAppConfig: whitespace-only values treated as unset', () => {
withVoiceEnv({
PRISMA_APP_ID_VOICE: ' ',
PRISMA_APP_ID_RTP_BASE: '\t\n',
PRISMA_APP_NAME_VOICE: ' ',
}, () => {
const cfg = resolveVoiceAppConfig();
assert.equal(cfg.appId, null);
assert.equal(cfg.appName, 'voice',
'whitespace-only PRISMA_APP_NAME_VOICE falls back to generic default');
});
});
test('resolveVoiceAppConfig: values are trimmed (env vars often carry accidental whitespace)', () => {
withVoiceEnv({
PRISMA_APP_ID_VOICE: ' 1708539371717015196 ',
PRISMA_APP_NAME_VOICE: ' Webex_Calling_RTP ',
}, () => {
const cfg = resolveVoiceAppConfig();
assert.equal(cfg.appId, '1708539371717015196', 'id trimmed');
assert.equal(cfg.appName, 'Webex_Calling_RTP', 'name trimmed');
});
});