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>
331 lines
13 KiB
JavaScript
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();
|
|
}
|
|
});
|