collabSupport/tests/paloalto.client.test.js
jmcqueen 9d0dbb071e Add per-app DPI voice-quality checks + widen WAN window to 7d
Adds three new SD-WAN checks (wanAppRtpMos/Loss/Jitter) that measure
REAL voice-traffic quality on actual RTP frames via Prisma DPI, not
synthetic link probes. Graded against the WORST 5-minute window so
transient degradation the 24h link-probe averages smooth away
actually surfaces.

Voice-app selection is tenant-configurable via PRISMA_APP_ID_VOICE +
PRISMA_APP_NAME_VOICE (Webex_Calling_RTP recommended for Webex
Calling shops — the Webex-specific DPI signature excludes non-Webex
UDP noise). Legacy PRISMA_APP_ID_RTP_BASE still honored with a
one-time deprecation warning.

Widens the default WAN look-back from 24h to 7 days: per-app metrics
only get datapoints when calls actually happen, so sporadic Webex
Calling stores (3-4 calls/day) need a wider window for worst-window
statistics to be meaningful. Interval picker snaps 7d to 1hour
buckets (168 pts) to keep payloads bounded while preserving
worst-hour granularity. Hard-capped at 7d — beyond that Prisma
downsamples to 1-day buckets and the signal collapses.

Also:
- Client-side concurrency limiter (PRISMA_MAX_INFLIGHT, default 3)
  to prevent 429 cascades when /voicediag fans out 10+ parallel
  metric fetches
- "View in Prisma UI" deep links in both /phonestatus WAN follow-up
  and /voicediag details, threading through a new
  integrations/paloalto/urls.js builder
- humanizeMetricUnit maps raw API unit strings ("percentage",
  "milliseconds") to display symbols ("%", "ms") to fix
  "11.83percentage" leaking to the UI
- getAppAudio envelope distinguishes not-configured / fetch-failed /
  no-traffic states so misleading "set env var" messages don't fire
  when the real problem is a 429

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 13:54:15 -04:00

450 lines
16 KiB
JavaScript

// tests/paloalto.client.test.js
//
// Coverage for integrations/paloalto/client.js — the dual-mode
// auth wrapper. Uses a fake HTTP server to exercise:
// - SASE OAuth token acquisition (form-encoded client_credentials
// against a fake auth URL)
// - Legacy CloudGenix login (JSON POST against /v2.0/api/login)
// - Mutex behaviour under concurrent callers
// - 401 → forced refresh + one-shot retry
// - PRISMA_AUTH_MODE=unknown throws a clear error
//
// Uses the same fake-server pattern as paloalto.sites.test.js.
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import {
paloAltoAxios,
getPrismaToken,
_resetPrismaAuthCache,
_resetPrismaConcurrency,
_prismaInflightCount,
} from '../integrations/paloalto/client.js';
const FAKE_TOKEN_1 = 'token-round-1';
const FAKE_TOKEN_2 = 'token-round-2';
async function makeFakeAuthServer(handlers = {}) {
const requests = [];
const server = http.createServer((req, res) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
requests.push({ method: req.method, url: req.url, body, headers: req.headers });
// Default SASE session-prime responder so tests that only care
// about the token / retry path don't need to wire this in.
// Individual tests can override by providing an explicit
// handler for `GET /sdwan/v2.1/api/profile`.
const explicitHandler = handlers[`${req.method} ${req.url}`];
if (
req.url === '/sdwan/v2.1/api/profile' &&
req.method === 'GET' &&
!explicitHandler
) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 'stub-profile' }));
return;
}
// Default OAuth token responder — same rationale as the prime
// default above. Overridable by explicit handler.
if (
req.url === '/oauth2/access_token' &&
req.method === 'POST' &&
!explicitHandler
) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ access_token: 'default-token', expires_in: 900 }));
return;
}
const handler = explicitHandler;
if (handler) {
// Handlers may return a Promise so a test can pause the
// response until an external gate resolves — used by the
// concurrency-limiter test to hold in-flight requests open.
Promise.resolve(handler({ req, body })).then((result) => {
res.writeHead(result.status || 200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result.body || {}));
}).catch((err) => {
res.writeHead(500);
res.end(JSON.stringify({ err: err.message }));
});
return;
}
res.writeHead(404);
res.end();
});
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const { port } = server.address();
return {
port,
baseUrl: `http://127.0.0.1:${port}`,
requests,
close: () => new Promise((r) => server.close(r)),
};
}
function clearAllEnv() {
delete process.env.PRISMA_AUTH_MODE;
delete process.env.PRISMA_SASE_BASE_URL;
delete process.env.PRISMA_LEGACY_BASE_URL;
delete process.env.PRISMA_AUTH_URL;
delete process.env.PRISMA_CLIENT_ID;
delete process.env.PRISMA_CLIENT_SECRET;
delete process.env.PRISMA_TSG_ID;
delete process.env.PRISMA_EMAIL;
delete process.env.PRISMA_PASSWORD;
}
test('client: PRISMA_AUTH_MODE=unknown throws on first token request', async () => {
_resetPrismaAuthCache();
clearAllEnv();
process.env.PRISMA_AUTH_MODE = 'nonsense';
try {
await assert.rejects(() => getPrismaToken(true), /PRISMA_AUTH_MODE/);
} finally {
clearAllEnv();
_resetPrismaAuthCache();
}
});
test('client: SASE mode fetches token via client_credentials + scopes to tsg_id', async () => {
_resetPrismaAuthCache();
clearAllEnv();
const fake = await makeFakeAuthServer({
'POST /oauth2/access_token': ({ body }) => {
assert.match(body, /grant_type=client_credentials/);
assert.match(body, /scope=tsg_id%3A/);
return { status: 200, body: { access_token: FAKE_TOKEN_1, expires_in: 900 } };
},
});
process.env.PRISMA_AUTH_MODE = 'sase';
process.env.PRISMA_AUTH_URL = `${fake.baseUrl}/oauth2/access_token`;
process.env.PRISMA_CLIENT_ID = 'id';
process.env.PRISMA_CLIENT_SECRET = 'secret';
process.env.PRISMA_TSG_ID = 'tsg';
try {
const t = await getPrismaToken(true);
assert.equal(t, FAKE_TOKEN_1);
} finally {
await fake.close();
clearAllEnv();
_resetPrismaAuthCache();
}
});
test('client: SASE mode surfaces missing envs with a clear error', async () => {
_resetPrismaAuthCache();
clearAllEnv();
process.env.PRISMA_AUTH_MODE = 'sase';
try {
await assert.rejects(() => getPrismaToken(true), /PRISMA_CLIENT_ID/);
} finally {
clearAllEnv();
_resetPrismaAuthCache();
}
});
test('client: legacy mode POSTs email+password to /v2.0/api/login', async () => {
_resetPrismaAuthCache();
clearAllEnv();
const fake = await makeFakeAuthServer({
'POST /v2.0/api/login': ({ body }) => {
const parsed = JSON.parse(body);
assert.equal(parsed.email, 'a@b.com');
assert.equal(parsed.password, 'secret');
return { status: 200, body: { x_auth_token: 'legacy-token-xyz' } };
},
});
process.env.PRISMA_AUTH_MODE = 'legacy';
process.env.PRISMA_LEGACY_BASE_URL = fake.baseUrl;
process.env.PRISMA_EMAIL = 'a@b.com';
process.env.PRISMA_PASSWORD = 'secret';
try {
const t = await getPrismaToken(true);
assert.equal(t, 'legacy-token-xyz');
} finally {
await fake.close();
clearAllEnv();
_resetPrismaAuthCache();
}
});
test('client: legacy mode surfaces missing envs with a clear error', async () => {
_resetPrismaAuthCache();
clearAllEnv();
process.env.PRISMA_AUTH_MODE = 'legacy';
try {
await assert.rejects(() => getPrismaToken(true), /PRISMA_EMAIL/);
} finally {
clearAllEnv();
_resetPrismaAuthCache();
}
});
test('client: concurrent callers coalesce onto a single token refresh', async () => {
_resetPrismaAuthCache();
clearAllEnv();
let hits = 0;
const fake = await makeFakeAuthServer({
'POST /oauth2/access_token': () => {
hits += 1;
return { status: 200, body: { access_token: `token-${hits}`, expires_in: 900 } };
},
});
process.env.PRISMA_AUTH_MODE = 'sase';
process.env.PRISMA_AUTH_URL = `${fake.baseUrl}/oauth2/access_token`;
process.env.PRISMA_CLIENT_ID = 'id';
process.env.PRISMA_CLIENT_SECRET = 'secret';
process.env.PRISMA_TSG_ID = 'tsg';
try {
const tokens = await Promise.all([
getPrismaToken(true),
getPrismaToken(false),
getPrismaToken(false),
getPrismaToken(false),
]);
assert.equal(hits, 1, 'mutex should coalesce concurrent refresh requests');
assert.equal(tokens[0], 'token-1');
// Subsequent callers should get the same cached token as the
// first (mutex holds them until it's cached).
assert.equal(tokens[1], tokens[0]);
assert.equal(tokens[2], tokens[0]);
assert.equal(tokens[3], tokens[0]);
} finally {
await fake.close();
clearAllEnv();
_resetPrismaAuthCache();
}
});
test('client: SASE mode primes session with GET /sdwan/v2.1/api/profile before the first SD-WAN call', async () => {
_resetPrismaAuthCache();
clearAllEnv();
let profileHits = 0;
let apiHits = 0;
const fake = await makeFakeAuthServer({
'GET /sdwan/v2.1/api/profile': ({ req }) => {
profileHits += 1;
assert.match(req.headers.authorization || '', /^Bearer /);
return { status: 200, body: { id: 'stub-profile', tenant_id: 't' } };
},
'GET /sdwan/v4.13/api/sites': () => {
apiHits += 1;
return { status: 200, body: { items: [] } };
},
});
process.env.PRISMA_AUTH_MODE = 'sase';
process.env.PRISMA_SASE_BASE_URL = fake.baseUrl;
process.env.PRISMA_AUTH_URL = `${fake.baseUrl}/oauth2/access_token`;
process.env.PRISMA_CLIENT_ID = 'id';
process.env.PRISMA_CLIENT_SECRET = 'secret';
process.env.PRISMA_TSG_ID = 'tsg';
try {
await paloAltoAxios.get('/sdwan/v4.13/api/sites');
await paloAltoAxios.get('/sdwan/v4.13/api/sites');
await paloAltoAxios.get('/sdwan/v4.13/api/sites');
assert.equal(profileHits, 1, 'priming call should fire exactly once per token');
assert.equal(apiHits, 3, 'subsequent SD-WAN calls should all succeed');
// Ordering assertion: /profile happened before the first SD-WAN call.
const seqUrls = fake.requests.map((r) => r.url).filter((u) => u.startsWith('/sdwan/'));
assert.equal(seqUrls[0], '/sdwan/v2.1/api/profile');
} finally {
await fake.close();
clearAllEnv();
_resetPrismaAuthCache();
}
});
test('client: SASE priming re-runs after a forced token refresh', async () => {
_resetPrismaAuthCache();
clearAllEnv();
let profileHits = 0;
const fake = await makeFakeAuthServer({
'GET /sdwan/v2.1/api/profile': () => {
profileHits += 1;
return { status: 200, body: { id: 'stub-profile' } };
},
'GET /sdwan/v4.13/api/sites': () => ({ status: 200, body: { items: [] } }),
});
process.env.PRISMA_AUTH_MODE = 'sase';
process.env.PRISMA_SASE_BASE_URL = fake.baseUrl;
process.env.PRISMA_AUTH_URL = `${fake.baseUrl}/oauth2/access_token`;
process.env.PRISMA_CLIENT_ID = 'id';
process.env.PRISMA_CLIENT_SECRET = 'secret';
process.env.PRISMA_TSG_ID = 'tsg';
try {
await paloAltoAxios.get('/sdwan/v4.13/api/sites');
assert.equal(profileHits, 1);
// Force a token refresh — priming should re-run on next call.
await getPrismaToken(true);
await paloAltoAxios.get('/sdwan/v4.13/api/sites');
assert.equal(profileHits, 2, 'new token → re-prime');
} finally {
await fake.close();
clearAllEnv();
_resetPrismaAuthCache();
}
});
test('client: axios instance retries once after 401 with forced refresh', async () => {
_resetPrismaAuthCache();
clearAllEnv();
let tokenCallCount = 0;
let apiCallCount = 0;
const fake = await makeFakeAuthServer({
'POST /oauth2/access_token': () => {
tokenCallCount += 1;
return {
status: 200,
body: {
access_token: tokenCallCount === 1 ? FAKE_TOKEN_1 : FAKE_TOKEN_2,
expires_in: 900,
},
};
},
'GET /some/api/endpoint': ({ req }) => {
apiCallCount += 1;
// First call → 401 to trigger refresh. Second call must
// present the new token to succeed.
if (apiCallCount === 1) {
return { status: 401, body: { error: 'expired' } };
}
const auth = req.headers.authorization || '';
if (auth === `Bearer ${FAKE_TOKEN_2}`) {
return { status: 200, body: { ok: true } };
}
return { status: 401, body: { error: 'still bad' } };
},
});
process.env.PRISMA_AUTH_MODE = 'sase';
process.env.PRISMA_SASE_BASE_URL = fake.baseUrl;
process.env.PRISMA_AUTH_URL = `${fake.baseUrl}/oauth2/access_token`;
process.env.PRISMA_CLIENT_ID = 'id';
process.env.PRISMA_CLIENT_SECRET = 'secret';
process.env.PRISMA_TSG_ID = 'tsg';
try {
const res = await paloAltoAxios.get('/some/api/endpoint');
assert.equal(res.status, 200);
assert.equal(res.data.ok, true);
assert.equal(tokenCallCount, 2, 'token refresh happened after 401');
assert.equal(apiCallCount, 2, 'API call retried once');
} finally {
await fake.close();
clearAllEnv();
_resetPrismaAuthCache();
}
});
// ─── Concurrency limiter ───────────────────────────────────────────
//
// Regression guard for the /voicediag rate-limit cascade: without
// the semaphore, fanning out 10 metric calls in parallel would blow
// past Prisma's per-second cap and 429 half of them. The cap has to
// bound concurrent in-flight AXIOS calls at MAX_INFLIGHT (default 3)
// AND has to release on both success + failure paths so 429 retries
// don't stall queued callers.
//
// Testing strategy: the server introduces a small artificial delay
// so we can observe the peak concurrent request count. All
// responses complete before the test's finally block, so no socket
// races on server-close.
test('client: concurrency limiter caps peak concurrent server-side requests at MAX_INFLIGHT=3', async () => {
_resetPrismaAuthCache();
_resetPrismaConcurrency();
clearAllEnv();
let peakConcurrent = 0;
let concurrent = 0;
const fake = await makeFakeAuthServer({
'POST /oauth2/access_token': () => ({
status: 200, body: { access_token: FAKE_TOKEN_1, expires_in: 900 },
}),
// ~80ms delay per response — long enough to let the client fill
// its 3-permit window, short enough that all 8 requests finish
// in ~250-400ms without complex gating logic.
'GET /slow': async () => {
concurrent += 1;
if (concurrent > peakConcurrent) peakConcurrent = concurrent;
await new Promise((r) => setTimeout(r, 80));
concurrent -= 1;
return { status: 200, body: { ok: true } };
},
});
process.env.PRISMA_AUTH_MODE = 'sase';
process.env.PRISMA_SASE_BASE_URL = fake.baseUrl;
process.env.PRISMA_AUTH_URL = `${fake.baseUrl}/oauth2/access_token`;
process.env.PRISMA_CLIENT_ID = 'id';
process.env.PRISMA_CLIENT_SECRET = 'secret';
process.env.PRISMA_TSG_ID = 'tsg';
try {
const results = await Promise.all(
Array.from({ length: 8 }, () => paloAltoAxios.get('/slow')),
);
assert.equal(results.length, 8);
assert.ok(
peakConcurrent <= 3,
`peak concurrent server-side requests should be <= 3 (semaphore cap), was ${peakConcurrent}`,
);
// If the cap works, the first batch of 3 completes ~80ms in,
// and the next batch fills — peak MUST equal MAX_INFLIGHT under
// any realistic scheduling. If it's under 3, the semaphore is
// too tight or requests are strictly serial (which would also
// be a bug).
assert.ok(peakConcurrent >= 2,
`expected the client to actually parallelize (peak >= 2), was ${peakConcurrent}`);
assert.equal(_prismaInflightCount(), 0,
'inflight counter must return to 0 after all requests finish');
} finally {
await fake.close();
clearAllEnv();
_resetPrismaAuthCache();
_resetPrismaConcurrency();
}
});
test('client: concurrency permit released on 500 (failure path drains cleanly, no permit leak)', async () => {
_resetPrismaAuthCache();
_resetPrismaConcurrency();
clearAllEnv();
let apiCalls = 0;
const fake = await makeFakeAuthServer({
'POST /oauth2/access_token': () => ({
status: 200, body: { access_token: FAKE_TOKEN_1, expires_in: 900 },
}),
'GET /explode': () => { apiCalls += 1; return { status: 500, body: { err: 'boom' } }; },
});
process.env.PRISMA_AUTH_MODE = 'sase';
process.env.PRISMA_SASE_BASE_URL = fake.baseUrl;
process.env.PRISMA_AUTH_URL = `${fake.baseUrl}/oauth2/access_token`;
process.env.PRISMA_CLIENT_ID = 'id';
process.env.PRISMA_CLIENT_SECRET = 'secret';
process.env.PRISMA_TSG_ID = 'tsg';
try {
// 6 requests that all 500. If the permit isn't released on the
// error path, the 4th onwards would hang forever — the outer
// 20s axios timeout would fire and the test would fail with
// timeout rather than clean rejections. All should reject
// cleanly and inflight must return to zero.
const results = await Promise.allSettled(
Array.from({ length: 6 }, () => paloAltoAxios.get('/explode')),
);
assert.equal(results.filter((r) => r.status === 'rejected').length, 6,
'all 6 requests should have rejected — none hung');
assert.equal(apiCalls, 6, 'all 6 reached the server (queue drained)');
assert.equal(_prismaInflightCount(), 0,
'inflight counter should return to 0 after failure — no permit leak');
} finally {
await fake.close();
clearAllEnv();
_resetPrismaAuthCache();
_resetPrismaConcurrency();
}
});