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>
326 lines
11 KiB
JavaScript
326 lines
11 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 } 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) {
|
|
const result = handler({ req, body });
|
|
res.writeHead(result.status || 200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(result.body || {}));
|
|
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();
|
|
}
|
|
});
|