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

331 lines
13 KiB
JavaScript

// tests/paloalto.metrics.test.js
//
// Regression guards for the Prisma SD-WAN metric-body shapes. Every
// bug in this file was found the hard way — live tenant returned
// SCHEMA_CHECK_FAIL 400s that took a round trip to pin down. Keep
// these tests thin, focused, and worded so a future refactor can
// see exactly WHICH schema constraint the assertion is protecting.
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import {
getHealthscore,
getLqmMetric,
getAlarms,
} from '../integrations/paloalto/metrics.js';
import { _resetPrismaAuthCache } from '../integrations/paloalto/client.js';
async function makeFakePrisma(routes) {
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;
}
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;
}
const h = routes[`${req.method} ${req.url}`];
if (h) {
const parsed = body ? JSON.parse(body) : null;
const r = h({ req, body: parsed });
res.writeHead(r.status || 200, { 'Content-Type': 'application/json', ...(r.headers || {}) });
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 setSaseEnv(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]);
}
// ─── Body-shape regressions ─────────────────────────────────────────
test('getHealthscore: uses v2.6 unified metrics endpoint (v2.0 aiops/health is a dead end on this tenant)', async () => {
_resetPrismaAuthCache();
const validIntervals = new Set(['10sec', '1min', '5min', '1hour', '1day']);
let seenBody = null;
let seenUrl = null;
const fake = await makeFakePrisma({
'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ req, body }) => {
seenBody = body;
seenUrl = req.url;
return { body: { metrics: [] } };
},
// Trip-wire on the old v2.0 aiops/health endpoint. Fails loudly
// if a future refactor accidentally regresses to it.
'POST /sdwan/monitor/v2.0/api/monitor/aiops/health': () => {
throw new Error('REGRESSION: healthscore should hit v2.6 /monitor/metrics, not v2.0 /aiops/health');
},
});
setSaseEnv(fake.baseUrl);
try {
await getHealthscore('site-A');
// v2.6 endpoint is the ONLY healthscore endpoint that returns 200
// on the observed tenant (verified 2026-07-09 via prismaProbe
// try-shapes; both v2.0 aiops/health and v2.1 aggregates dead-end
// on tenant-enforced schema errors).
assert.equal(seenUrl, '/sdwan/monitor/v2.6/api/monitor/metrics');
assert.deepEqual(seenBody.filter, { site: ['site-A'] },
'v2.6 accepts filter.site as an ARRAY (unlike v2.0)');
assert.deepEqual(seenBody.view, {},
'v2.6 accepts view as an empty object (unlike v2.0 aiops/health which wants a string enum)');
assert.equal(seenBody.metrics[0].name, 'Healthscore');
assert.deepEqual(seenBody.metrics[0].statistics, ['max']);
assert.equal(seenBody.metrics[0].unit, 'gauge');
assert.equal(
Object.prototype.hasOwnProperty.call(seenBody, 'end_time'), false,
'no end_time — endpoint interprets window as [start_time, now)',
);
assert.ok(validIntervals.has(seenBody.interval),
`interval must be one of ${[...validIntervals].join(', ')} — got "${seenBody.interval}"`);
} finally {
await fake.close(); _resetPrismaAuthCache(); clearEnv();
}
});
test('getLqmMetric: uses dedicated lqm_point_metrics endpoint (not sys_point_metrics)', async () => {
_resetPrismaAuthCache();
let seenBody = null;
let seenUrl = null;
const fake = await makeFakePrisma({
'POST /sdwan/monitor/v2.0/api/monitor/lqm_point_metrics': ({ req, body }) => {
seenBody = body;
seenUrl = req.url;
return { body: { metrics: [] } };
},
// Regression trip-wire: sys_point_metrics is the CPU/Memory/Disk
// endpoint and rejects filter.wan_interfaces. If we regress to it,
// fail the test with a specific message instead of a generic 404.
'POST /sdwan/monitor/v2.0/api/monitor/sys_point_metrics': () => {
throw new Error('REGRESSION: getLqmMetric should use lqm_point_metrics, not sys_point_metrics');
},
});
setSaseEnv(fake.baseUrl);
try {
await getLqmMetric('site-A', ['wi-1', 'wi-2'], 'latency');
assert.equal(seenUrl, '/sdwan/monitor/v2.0/api/monitor/lqm_point_metrics');
// Live 400 regressions + LIVEcommunity #1235108 working example:
// - `end_time` is REJECTED ("not defined in the schema"). The
// endpoint interprets the window as [start_time, now).
// - filter.site must be an ARRAY on lqm_point_metrics — 400 was
// "$.filter.site: string found, array expected". OPPOSITE of
// sys_point_metrics, which wants a string.
// - filter.path is the KEY THAT WORKS. Both `wan_interfaces`
// and `waninterface` were rejected as "not defined in the
// schema". The LIVEcommunity example for LqmLatency uses:
// filter: { site: [id], path: [wi_id] }
assert.equal(
Object.prototype.hasOwnProperty.call(seenBody, 'end_time'), false,
'lqm_point_metrics rejects `end_time` — do not send',
);
assert.ok(Array.isArray(seenBody.filter.site),
'lqm_point_metrics requires filter.site as an ARRAY (opposite of sys_point_metrics)');
assert.deepEqual(seenBody.filter.site, ['site-A']);
assert.deepEqual(seenBody.filter.path, ['wi-1', 'wi-2'],
'filter key is `path` — NOT wan_interfaces (rejected), NOT waninterface (sys_point_metrics variant)');
assert.equal(
Object.prototype.hasOwnProperty.call(seenBody.filter, 'wan_interfaces'),
false,
'do NOT send `wan_interfaces` — rejected as "not defined in the schema"',
);
assert.equal(
Object.prototype.hasOwnProperty.call(seenBody.filter, 'waninterface'),
false,
'do NOT send `waninterface` — that key belongs to sys_point_metrics',
);
assert.equal(
Object.prototype.hasOwnProperty.call(seenBody.filter, 'elements'),
false,
'do NOT send `elements` — that was a sys_point_metrics quirk',
);
assert.equal(seenBody.metrics[0].name, 'LqmLatencyPointMetric');
assert.equal(seenBody.metrics[0].unit, 'milliseconds');
} finally {
await fake.close(); _resetPrismaAuthCache(); clearEnv();
}
});
test('getLqmMetric: correct PointMetric name + unit per key (jitter/loss/mos)', async () => {
_resetPrismaAuthCache();
const seen = [];
const fake = await makeFakePrisma({
'POST /sdwan/monitor/v2.0/api/monitor/lqm_point_metrics': ({ body }) => {
seen.push(body.metrics[0]);
return { body: { metrics: [] } };
},
});
setSaseEnv(fake.baseUrl);
try {
await getLqmMetric('site-A', ['wi-1'], 'jitter');
await getLqmMetric('site-A', ['wi-1'], 'loss');
await getLqmMetric('site-A', ['wi-1'], 'mos');
assert.deepEqual(seen.map((m) => [m.name, m.unit]), [
// Regression: units are CASE-SENSITIVE per Prisma's schema
// validator. `Percentage` (capital P) was rejected on this
// tenant with 400 METRIC_UNIT_NOT_SUPPORTED — the correct
// spelling is lowercase `percentage`. Verified 2026-07-09 via
// `try-shapes lqm-loss`.
['LqmJitterPointMetric', 'milliseconds'],
['LqmPktLossPointMetric', 'percentage'],
['LqmMosPointMetric', 'count'],
]);
} finally {
await fake.close(); _resetPrismaAuthCache(); clearEnv();
}
});
test('getLqmMetric: empty waninterfaceIds → skip (no HTTP call)', async () => {
_resetPrismaAuthCache();
let called = false;
const fake = await makeFakePrisma({
'POST /sdwan/monitor/v2.0/api/monitor/lqm_point_metrics': () => {
called = true;
return { body: { metrics: [] } };
},
});
setSaseEnv(fake.baseUrl);
try {
assert.equal(await getLqmMetric('site-A', [], 'latency'), null);
assert.equal(called, false);
} finally {
await fake.close(); _resetPrismaAuthCache(); clearEnv();
}
});
// ─── 429 retry ──────────────────────────────────────────────────────
test('client: 429 → retry with exponential backoff, then succeed on second attempt', async () => {
_resetPrismaAuthCache();
let attempt = 0;
const startTs = Date.now();
const fake = await makeFakePrisma({
'POST /sdwan/monitor/v2.6/api/monitor/metrics': () => {
attempt += 1;
if (attempt === 1) {
return { status: 429, body: { error: 'too many' } };
}
return { body: { metrics: [{ name: 'Healthscore', series: [{ data: [{ value: 95 }] }] }] } };
},
});
setSaseEnv(fake.baseUrl);
try {
const resp = await getHealthscore('site-A');
const elapsed = Date.now() - startTs;
assert.equal(attempt, 2, 'should have retried exactly once');
assert.ok(resp, 'second attempt should return the payload');
assert.equal(resp.metrics[0].name, 'Healthscore');
assert.ok(elapsed >= 1500, `should have waited ~1.5s (base backoff) — waited ${elapsed}ms`);
} finally {
await fake.close(); _resetPrismaAuthCache(); clearEnv();
}
});
test('client: 429 → honors Retry-After header when present', async () => {
_resetPrismaAuthCache();
let attempt = 0;
const startTs = Date.now();
const fake = await makeFakePrisma({
'POST /sdwan/monitor/v2.6/api/monitor/metrics': () => {
attempt += 1;
if (attempt === 1) {
// Retry-After of 2 seconds — should be honored over the
// client's default 1.5s exponential backoff.
return { status: 429, headers: { 'Retry-After': '2' }, body: {} };
}
return { body: { metrics: [] } };
},
});
setSaseEnv(fake.baseUrl);
try {
await getHealthscore('site-A');
const elapsed = Date.now() - startTs;
assert.ok(elapsed >= 2000, `should have waited ~2s per Retry-After — waited ${elapsed}ms`);
assert.equal(attempt, 2);
} finally {
await fake.close(); _resetPrismaAuthCache(); clearEnv();
}
});
test('client: 429 → gives up after MAX_429_RETRIES and returns null', async () => {
_resetPrismaAuthCache();
let attempt = 0;
const fake = await makeFakePrisma({
'POST /sdwan/monitor/v2.6/api/monitor/metrics': () => {
attempt += 1;
return { status: 429, body: {} };
},
});
setSaseEnv(fake.baseUrl);
try {
const resp = await getHealthscore('site-A');
assert.equal(resp, null, 'metric wrapper absorbs the exhausted-retry error');
// 1 original + 2 retries = 3 total attempts.
assert.equal(attempt, 3, 'should attempt exactly MAX_429_RETRIES + 1 times');
} finally {
await fake.close(); _resetPrismaAuthCache(); clearEnv();
}
});
// ─── Alarms body shape (events/query endpoint) ──────────────────────
test('getAlarms: uses events/query endpoint (not /monitor/alarms which 404s)', async () => {
_resetPrismaAuthCache();
let seenBody = null;
let seenUrl = null;
const fake = await makeFakePrisma({
'POST /sdwan/v3.7/api/events/query': ({ req, body }) => {
seenBody = body;
seenUrl = req.url;
return { body: { items: [] } };
},
// Regression trip-wire: if we accidentally regress to the old
// /monitor/alarms path, this handler catches it and fails the
// test with a specific message instead of a generic 404.
'POST /sdwan/monitor/v2.0/api/monitor/alarms': () => {
throw new Error('REGRESSION: getAlarms should use /events/query, not /monitor/alarms');
},
});
setSaseEnv(fake.baseUrl);
try {
const resp = await getAlarms('site-A');
assert.ok(resp, 'events/query response returned');
assert.equal(seenUrl, '/sdwan/v3.7/api/events/query');
assert.deepEqual(seenBody.query.site, ['site-A']);
assert.deepEqual(seenBody.query.type, ['alarm'],
'must filter for type=alarm — informational events would inflate the count');
assert.deepEqual(seenBody.severity, ['critical', 'major', 'minor']);
assert.ok(seenBody.limit && typeof seenBody.limit === 'object',
'limit is an OBJECT on events/query (count + sort_on + sort_order), not an int');
assert.equal(seenBody.limit.sort_on, 'time');
assert.equal(seenBody.limit.sort_order, 'descending');
assert.ok(seenBody.start_time, 'start_time populated');
} finally {
await fake.close(); _resetPrismaAuthCache(); clearEnv();
}
});