// 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(); } });