// 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, getAppMetric, getAlarms, APP_METRIC_NAMES, } 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(); } }); // ─── Per-application metrics — body-shape regressions ────────────── // // Ground truth for these assertions comes from two HAR captures // against a live tenant (site CG00127), both taken 2026-07-09: // - /Users/McqueenJ/Downloads/rtp-base-metricCG00127.har // (app id 15932000365560116) // - /Users/McqueenJ/Downloads/webex-base-metricCG00127.har // (app id 1708539371717015196 → Webex_Calling_RTP) // // The metric names, units, and filter shapes are IDENTICAL across // voice apps — only filter.app differs. That's why the check code is // app-agnostic and these tests use `` as a filler rather than // pinning one app. // // Every non-obvious body key here is one Prisma silently rejects if // wrong. Do NOT relax these without a fresh HAR — see // scripts/prismaProbe.js `try-shapes app-audio-*` to re-discover. test('getAppMetric(mos): AppAudioMos requires direction="Ingress" and OMITS filter.path_type', async () => { _resetPrismaAuthCache(); let seenBody = null; const fake = await makeFakePrisma({ 'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => { seenBody = body; return { body: { metrics: [{ series: [{ name: 'AppAudioMos', unit: 'count', data: [{ datapoints: [] }] }] }] } }; }, }); setSaseEnv(fake.baseUrl); try { await getAppMetric('site-CG127', '15932000365560116', 'mos', 60); assert.ok(seenBody, 'request reached fake server'); // The winning metric name from the HAR — anything else 400s // with METRIC_NOT_SUPPORTED, so pin it. assert.deepEqual(seenBody.metrics, [{ name: 'AppAudioMos', statistics: ['average'], unit: 'count', }]); // AppAudioMos is the ONE audio metric that does NOT take // filter.path_type. If we add it, the schema check flips from // "no matching metric" to "unsupported filter" — different bug // symptom, but same net "returns nothing" outcome. assert.ok(!('path_type' in seenBody.filter), 'AppAudioMos must NOT include filter.path_type (unlike AppPerfUDP*)'); // Direction is REQUIRED. Without it Prisma returns 400. assert.equal(seenBody.filter.direction, 'Ingress', 'AppAudioMos requires filter.direction="Ingress" (audio quality is what you RECEIVE)'); assert.deepEqual(seenBody.filter.site, ['site-CG127']); assert.deepEqual(seenBody.filter.app, ['15932000365560116'], 'app id must be sent as a STRING inside an ARRAY, not a bare int or a bare string'); // Empty view {} — the single-metric per-app queries use this // rather than {summary:true} or {individual:'app'}. assert.deepEqual(seenBody.view, {}); assert.ok(seenBody.start_time && seenBody.end_time, 'start_time + end_time both required — v2.6 monitor/metrics rejects without them'); } finally { await fake.close(); _resetPrismaAuthCache(); clearEnv(); } }); test('getAppMetric(loss): pathTypes option narrows filter.path_type', async () => { _resetPrismaAuthCache(); let seenBody = null; const fake = await makeFakePrisma({ 'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => { seenBody = body; return { body: { metrics: [{ series: [] }] } }; }, }); setSaseEnv(fake.baseUrl); try { await getAppMetric('site-A', '15932', 'loss', { windowMinutes: 60, pathTypes: ['VPN'], }); assert.deepEqual(seenBody.filter.path_type, ['VPN']); } finally { await fake.close(); _resetPrismaAuthCache(); clearEnv(); } }); test('getAppMetric(loss): pathIds option sets filter.path', async () => { _resetPrismaAuthCache(); let seenBody = null; const fake = await makeFakePrisma({ 'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => { seenBody = body; return { body: { metrics: [{ series: [] }] } }; }, }); setSaseEnv(fake.baseUrl); try { await getAppMetric('site-A', '15932', 'loss', { windowMinutes: 60, pathIds: ['wi-1'], }); assert.deepEqual(seenBody.filter.path, ['wi-1']); } finally { await fake.close(); _resetPrismaAuthCache(); clearEnv(); } }); test('getAppMetric(loss): AppPerfUDPAudioPacketLoss includes ALL 5 path_types', async () => { _resetPrismaAuthCache(); let seenBody = null; const fake = await makeFakePrisma({ 'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => { seenBody = body; return { body: { metrics: [{ series: [{ name: 'AppPerfUDPAudioPacketLoss', unit: 'percentage', data: [{ datapoints: [] }] }] }] } }; }, }); setSaseEnv(fake.baseUrl); try { await getAppMetric('site-CG127', '15932000365560116', 'loss', 60); assert.equal(seenBody.metrics[0].name, 'AppPerfUDPAudioPacketLoss'); // Case-sensitive — 'Percentage' returned METRIC_UNIT_NOT_SUPPORTED // in earlier probe runs. Lowercase-p is a live constraint. assert.equal(seenBody.metrics[0].unit, 'percentage'); assert.equal(seenBody.filter.direction, 'Ingress'); const paths = seenBody.filter.path_type; assert.ok(Array.isArray(paths)); for (const t of ['DirectInternet', 'VPN', 'PrivateWAN', 'PrivateVPN', 'ServiceLink']) { assert.ok(paths.includes(t), `path_type must include "${t}" (Prisma UI passes all 5)`); } } finally { await fake.close(); _resetPrismaAuthCache(); clearEnv(); } }); test('getAppMetric(jitter): AppPerfUDPAudioJitter uses milliseconds unit', async () => { _resetPrismaAuthCache(); let seenBody = null; const fake = await makeFakePrisma({ 'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => { seenBody = body; return { body: {} }; }, }); setSaseEnv(fake.baseUrl); try { await getAppMetric('site-CG127', '15932000365560116', 'jitter', 60); assert.equal(seenBody.metrics[0].name, 'AppPerfUDPAudioJitter'); assert.equal(seenBody.metrics[0].unit, 'milliseconds'); assert.equal(seenBody.filter.direction, 'Ingress'); assert.ok(Array.isArray(seenBody.filter.path_type)); } finally { await fake.close(); _resetPrismaAuthCache(); clearEnv(); } }); test('getAppMetric(bandwidth): AppPerfUDPAudioBandwidth has NO direction (bidirectional aggregate)', async () => { _resetPrismaAuthCache(); let seenBody = null; const fake = await makeFakePrisma({ 'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => { seenBody = body; return { body: {} }; }, }); setSaseEnv(fake.baseUrl); try { await getAppMetric('site-CG127', '15932000365560116', 'bandwidth', 60); assert.equal(seenBody.metrics[0].name, 'AppPerfUDPAudioBandwidth'); assert.equal(seenBody.metrics[0].unit, 'Mbps'); assert.ok(!('direction' in seenBody.filter), 'bandwidth is a bidirectional aggregate — Prisma rejects filter.direction on this metric'); assert.ok(Array.isArray(seenBody.filter.path_type)); } finally { await fake.close(); _resetPrismaAuthCache(); clearEnv(); } }); test('getAppMetric: no-op with clean null return when siteId or appId missing', async () => { // No network setup — if the wrapper tried to make a call, the // client would fail to resolve the SASE URL and throw. A silent // null keeps the enrichment pipeline resilient when a caller // forgets to configure PRISMA_APP_ID_VOICE (or the legacy // PRISMA_APP_ID_RTP_BASE). const r1 = await getAppMetric(null, '1708539371717015196', 'mos'); const r2 = await getAppMetric('site-X', null, 'mos'); assert.equal(r1, null); assert.equal(r2, null); }); test('getAppMetric: uses 5min interval for 24h window (NOT 1day — preserves 288-point time series)', async () => { // The whole point of client-side aggregation is that we can // extract WORST-window numbers from the series. If we ask for // interval=1day on a 1440min window, Prisma returns 1-2 // aggregated datapoints and the min/max collapse to the same // value as the avg — completely defeating the purpose. HAR // confirms the Prisma UI uses 5min for the 24h view. _resetPrismaAuthCache(); let seenBody = null; const fake = await makeFakePrisma({ 'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => { seenBody = body; return { body: {} }; }, }); setSaseEnv(fake.baseUrl); try { await getAppMetric('site-CG127', '15932000365560116', 'mos', 1440); assert.equal(seenBody.interval, '5min', '1440-min window MUST use 5min interval (yields ~288 pts, matches HAR). ' + 'Falling back to 1day would collapse the series to 1-2 aggregated pts.'); } finally { await fake.close(); _resetPrismaAuthCache(); clearEnv(); } }); test('getAppMetric: uses 1hour interval for 7d window (168 points — the new default)', async () => { _resetPrismaAuthCache(); let seenBody = null; const fake = await makeFakePrisma({ 'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => { seenBody = body; return { body: {} }; }, }); setSaseEnv(fake.baseUrl); try { // 7 days = 10080 min. This is now the default window (widened // from 24h so sporadic Webex Calling stores get enough per-app // samples). 5min buckets would be 2016 points per metric × 4 // metrics = 8064 numbers per site request. Snap to 1hour (168 // pts) to keep payloads reasonable while still catching // worst-hour degradation. await getAppMetric('site-CG127', '1708539371717015196', 'mos', 10080); assert.equal(seenBody.interval, '1hour'); } finally { await fake.close(); _resetPrismaAuthCache(); clearEnv(); } }); test('APP_METRIC_NAMES: registry exposes the HAR-confirmed winners', () => { // Cheap "someone renamed the constant" check. The four keys are // the API surface that sdwanEnrichment.js iterates over — a rename // would silently drop that metric from the enrichment payload // (parseAppSeries would return null and the check would go to // skipped rather than error). assert.equal(APP_METRIC_NAMES.mos.name, 'AppAudioMos'); assert.equal(APP_METRIC_NAMES.loss.name, 'AppPerfUDPAudioPacketLoss'); assert.equal(APP_METRIC_NAMES.jitter.name, 'AppPerfUDPAudioJitter'); assert.equal(APP_METRIC_NAMES.bandwidth.name, 'AppPerfUDPAudioBandwidth'); assert.equal(APP_METRIC_NAMES.mos.lowIsBad, true, 'MOS grades against worst-window minimum — lowIsBad must be true'); });