collabSupport/tests/sdwanEnrichment.test.js
jmcqueen b802383441 Add Prisma SD-WAN voice-quality enrichment for /phonestatus + /voicediag
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>
2026-07-09 09:45:29 -04:00

844 lines
31 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,
} 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: 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 5 most-recent samples', () => {
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, 5);
assert.equal(a.samples[0].code, 'C1', 'newest first');
});
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.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): 15 min (also the env default)
let data = await collectSdwanForStore(782);
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 → both windows go to 1440
data = await collectSdwanForStore(782, { windowMinutes: 1440 });
assert.equal(data.window?.minutes, 1440);
assert.equal(data.window?.alarmMinutes, 1440);
// Out-of-range values are clamped to [1, 1440]
data = await collectSdwanForStore(782, { windowMinutes: 99999 });
assert.equal(data.window?.minutes, 1440, 'capped at 24h');
data = await collectSdwanForStore(782, { windowMinutes: -5 });
assert.equal(data.window?.minutes, 15, 'invalid → falls to env default');
} 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();
}
});