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>
450 lines
16 KiB
JavaScript
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();
|
|
}
|
|
});
|