// tests/paloalto.sites.test.js // // Unit + integration coverage for integrations/paloalto/sites.js. // // The `siteNameForStore()` function is pure — most cases are covered // by inline assertions. The `findSdwanSiteForStore()` + // `getElementsForSite()` functions hit HTTP; we stand up a tiny // fake Prisma server on an ephemeral port and point the module at // it via PRISMA_SASE_BASE_URL. Same pattern as dectRelayHub.test.js. // // Env cleanup runs after every test that touches process.env so // one failing test can't poison the rest of the file. import test from 'node:test'; import assert from 'node:assert/strict'; import http from 'node:http'; import { siteNameForStore, findSdwanSiteForStore, getAllSites, getElementsForSite, getWanInterfacesForSite, _resetSitesCache, } from '../integrations/paloalto/sites.js'; import { _resetPrismaAuthCache } from '../integrations/paloalto/client.js'; const FAKE_TOKEN = 'test-token-abc123'; // ─── Fake Prisma server ───────────────────────────────────────────── // // Handles just enough of the two endpoints the sites module talks // to. Each test passes a `handlers` map so it can control what // each endpoint returns — a missing handler responds 404. All // requests get authenticated against FAKE_TOKEN so the client's // interceptor + refresh flow gets exercised. async function makeFakePrisma(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 }); // Auth token endpoint (SASE). The auth URL is the whole URL // (not a path on baseURL) — but we override it too so // the auth POST comes here. if (req.url === '/oauth2/access_token' && req.method === 'POST') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ access_token: FAKE_TOKEN, expires_in: 900 })); return; } // Mandatory SASE unified SD-WAN session priming call. Every // token acquisition should be followed by exactly one hit // against this URL — we respond with a stub profile. 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', tenant_id: 'stub-tenant' })); return; } // Match on method + path (ignoring query string) so tests // don't have to encode every ?limit=1000&cursor=... permutation. // Query params are still preserved on `req.query` and the raw // `req.url` for handlers that need them. const pathOnly = (req.url || '').split('?')[0]; const rawQuery = (req.url || '').includes('?') ? req.url.split('?')[1] : ''; const query = Object.fromEntries(new URLSearchParams(rawQuery)); const handler = handlers[`${req.method} ${req.url}`] || // exact match wins handlers[`${req.method} ${pathOnly}`]; // path-only fallback if (handler) { const parsed = body ? JSON.parse(body) : null; const result = handler({ req, body: parsed, query }); 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 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 = 'test-client'; process.env.PRISMA_CLIENT_SECRET = 'test-secret'; process.env.PRISMA_TSG_ID = 'test-tsg'; } function clearPrismaEnv() { delete process.env.PRISMA_AUTH_MODE; delete process.env.PRISMA_SASE_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_LEGACY_BASE_URL; delete process.env.PRISMA_EMAIL; delete process.env.PRISMA_PASSWORD; } // ─── siteNameForStore (pure) ──────────────────────────────────────── test('siteNameForStore: pads 3-digit store to 5 with CG prefix', () => { assert.equal(siteNameForStore(782), 'CG00782'); assert.equal(siteNameForStore('782'), 'CG00782'); }); test('siteNameForStore: pads 5-digit store correctly', () => { assert.equal(siteNameForStore(2477), 'CG02477'); assert.equal(siteNameForStore(305), 'CG00305'); }); test('siteNameForStore: handles 4-digit stores', () => { assert.equal(siteNameForStore(1234), 'CG01234'); }); test('siteNameForStore: strips non-digits before padding', () => { assert.equal(siteNameForStore('store-782'), 'CG00782'); }); test('siteNameForStore: throws on empty / non-numeric input', () => { assert.throws(() => siteNameForStore(''), /no digits/i); assert.throws(() => siteNameForStore('abc'), /no digits/i); assert.throws(() => siteNameForStore(null), /no digits/i); }); // ─── findSdwanSiteForStore + cache ────────────────────────────────── test('findSdwanSiteForStore: exact match on CG00782', async () => { _resetSitesCache(); _resetPrismaAuthCache(); const fake = await makeFakePrisma({ 'GET /sdwan/v4.13/api/sites': () => ({ status: 200, body: { items: [ { id: 'site-1', name: 'CG00305', description: 'store 305' }, { id: 'site-2', name: 'CG00782', description: 'store 782' }, { id: 'site-3', name: 'CG02477', description: 'store 2477' }, ], }, }), }); setSaseEnv(fake.baseUrl); try { const site = await findSdwanSiteForStore(782); assert.ok(site, 'expected site to resolve'); assert.equal(site.name, 'CG00782'); assert.equal(site.id, 'site-2'); } finally { await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearPrismaEnv(); } }); test('findSdwanSiteForStore: null when no match', async () => { _resetSitesCache(); _resetPrismaAuthCache(); const fake = await makeFakePrisma({ 'GET /sdwan/v4.13/api/sites': () => ({ status: 200, body: { items: [{ id: 'site-x', name: 'CG99999', description: '' }] }, }), }); setSaseEnv(fake.baseUrl); try { const site = await findSdwanSiteForStore(782); assert.equal(site, null); } finally { await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearPrismaEnv(); } }); test('getAllSites: caches — second call does not re-fetch', async () => { _resetSitesCache(); _resetPrismaAuthCache(); let fetchCount = 0; const fake = await makeFakePrisma({ 'GET /sdwan/v4.13/api/sites': () => { fetchCount += 1; return { status: 200, body: { items: [{ id: 's', name: 'CG00782' }] } }; }, }); setSaseEnv(fake.baseUrl); try { await getAllSites(); await getAllSites(); await getAllSites(); assert.equal(fetchCount, 1, 'only one refresh expected within TTL'); } finally { await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearPrismaEnv(); } }); test('getAllSites: uses GET (not POST) so no request body → no schema games', async () => { // Regression guard: the observed SASE tenant rejects POST // /sites/query with a moving target of body-schema errors // (getDeleted expected bool, total_count expected long, etc.) // even when we send those fields correctly. The tenant's SASE // proxy appears to auto-inject fields with `{}` defaults into // the body before schema validation, making POST unusable. // GET has no body, so no auto-injection, so no drift. _resetSitesCache(); _resetPrismaAuthCache(); const seen = { method: null, path: null, query: null, bodyLen: 0 }; const fake = await makeFakePrisma({ 'GET /sdwan/v4.13/api/sites': ({ req, body, query }) => { seen.method = req.method; seen.path = (req.url || '').split('?')[0]; seen.query = query; seen.bodyLen = body ? JSON.stringify(body).length : 0; return { status: 200, body: { items: [{ id: 's', name: 'CG00782' }] } }; }, }); setSaseEnv(fake.baseUrl); try { await getAllSites(); assert.equal(seen.method, 'GET', 'MUST be GET, not POST'); assert.equal(seen.path, '/sdwan/v4.13/api/sites'); assert.equal(seen.bodyLen, 0, 'no request body'); assert.equal(seen.query.limit, '1000', 'first-page limit maximised'); } finally { await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearPrismaEnv(); } }); test('getAllSites: forceRefresh bypasses cache', async () => { _resetSitesCache(); _resetPrismaAuthCache(); let fetchCount = 0; const fake = await makeFakePrisma({ 'GET /sdwan/v4.13/api/sites': () => { fetchCount += 1; return { status: 200, body: { items: [{ id: 's', name: 'CG00782' }] } }; }, }); setSaseEnv(fake.baseUrl); try { await getAllSites(); await getAllSites(true); assert.equal(fetchCount, 2); } finally { await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearPrismaEnv(); } }); test('getAllSites: follows next_query cursor across multiple pages', async () => { _resetSitesCache(); _resetPrismaAuthCache(); let pageCallCount = 0; const pages = [ // Page 1: full page (1000 items) + a cursor that gets echoed // back verbatim as the next request body. { items: Array.from({ length: 1000 }, (_, i) => ({ id: `s${i}`, name: `CG${String(i).padStart(5, '0')}` })), next_query: { limit: 1000, getDeleted: false, cursor: 'page-2' }, }, // Page 2: full page + another cursor. { items: Array.from({ length: 1000 }, (_, i) => ({ id: `s${1000 + i}`, name: `CG${String(1000 + i).padStart(5, '0')}` })), next_query: { limit: 1000, getDeleted: false, cursor: 'page-3' }, }, // Page 3: short page → terminates the loop by "fewer than limit". { items: Array.from({ length: 234 }, (_, i) => ({ id: `s${2000 + i}`, name: `CG${String(2000 + i).padStart(5, '0')}` })), next_query: null, }, ]; const fake = await makeFakePrisma({ 'GET /sdwan/v4.13/api/sites': () => { const page = pages[pageCallCount] || { items: [], next_query: null }; pageCallCount += 1; return { status: 200, body: page }; }, }); setSaseEnv(fake.baseUrl); try { const sites = await getAllSites(); assert.equal(pageCallCount, 3, 'should fetch all three pages'); assert.equal(sites.length, 1000 + 1000 + 234, 'should union every page'); assert.equal(sites[0].name, 'CG00000'); assert.equal(sites.at(-1).name, 'CG02233'); } finally { await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearPrismaEnv(); } }); test('getAllSites: keeps paginating when Prisma caps pages below the requested limit', async () => { // Regression guard for a live bug: Prisma silently caps some // tenants at ~200 rows per page regardless of the requested // `limit: 1000`. The original short-page termination heuristic // cut the sweep off after the first page, hiding 800+ sites from // the store-lookup resolver. `next_query` alone must decide. _resetSitesCache(); _resetPrismaAuthCache(); let pageCallCount = 0; const pages = [ { // Page 1: 200 items (capped, WELL below the requested 1000) // BUT a non-empty next_query says there's more. items: Array.from({ length: 200 }, (_, i) => ({ id: `s${i}`, name: `CG${String(i).padStart(5, '0')}` })), next_query: { cursor: 'p2', limit: 1000 }, }, { // Page 2: another capped 200 + more. items: Array.from({ length: 200 }, (_, i) => ({ id: `s${200 + i}`, name: `CG${String(200 + i).padStart(5, '0')}` })), next_query: { cursor: 'p3', limit: 1000 }, }, { // Page 3: final short page + empty cursor. items: Array.from({ length: 42 }, (_, i) => ({ id: `s${400 + i}`, name: `CG${String(400 + i).padStart(5, '0')}` })), next_query: null, }, ]; const fake = await makeFakePrisma({ 'GET /sdwan/v4.13/api/sites': () => { const page = pages[pageCallCount] || { items: [], next_query: null }; pageCallCount += 1; return { status: 200, body: page }; }, }); setSaseEnv(fake.baseUrl); try { const sites = await getAllSites(); assert.equal(pageCallCount, 3, 'must not short-circuit on the capped first page'); assert.equal(sites.length, 442, 'all 3 pages unioned'); assert.equal(sites.at(-1).name, 'CG00441'); } finally { await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearPrismaEnv(); } }); test('getAllSites: terminates on empty next_query even if page is full-sized', async () => { _resetSitesCache(); _resetPrismaAuthCache(); let pageCallCount = 0; const fake = await makeFakePrisma({ 'GET /sdwan/v4.13/api/sites': () => { pageCallCount += 1; // Full page (== limit) but next_query is an empty object → // terminate. Guards against a Prisma quirk where the last // page happens to be exactly the page-size boundary. return { status: 200, body: { items: Array.from({ length: 1000 }, (_, i) => ({ id: `s${i}`, name: `CG${String(i).padStart(5, '0')}` })), next_query: {}, }, }; }, }); setSaseEnv(fake.baseUrl); try { const sites = await getAllSites(); assert.equal(pageCallCount, 1); assert.equal(sites.length, 1000); } finally { await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearPrismaEnv(); } }); test('getAllSites: findSdwanSiteForStore resolves a store on page 3 of a paginated tenant', async () => { _resetSitesCache(); _resetPrismaAuthCache(); let pageCallCount = 0; const pages = [ { items: Array.from({ length: 1000 }, (_, i) => ({ id: `s${i}`, name: `CG${String(i).padStart(5, '0')}` })), next_query: { cursor: 'p2' } }, { items: Array.from({ length: 1000 }, (_, i) => ({ id: `s${1000 + i}`, name: `CG${String(1000 + i).padStart(5, '0')}` })), next_query: { cursor: 'p3' } }, // Store 2200 lives on page 3. { items: [{ id: 'target', name: 'CG02200' }], next_query: null }, ]; const fake = await makeFakePrisma({ 'GET /sdwan/v4.13/api/sites': () => { const page = pages[pageCallCount] || { items: [], next_query: null }; pageCallCount += 1; return { status: 200, body: page }; }, }); setSaseEnv(fake.baseUrl); try { const site = await findSdwanSiteForStore(2200); assert.ok(site, 'expected store 2200 to resolve'); assert.equal(site.name, 'CG02200'); assert.equal(site.id, 'target'); } finally { await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearPrismaEnv(); } }); test('getAllSites: returns empty array on network error with no cache', async () => { _resetSitesCache(); _resetPrismaAuthCache(); const fake = await makeFakePrisma({ 'GET /sdwan/v4.13/api/sites': () => ({ status: 500, body: { error: 'boom' }, }), }); setSaseEnv(fake.baseUrl); try { const sites = await getAllSites(); assert.deepEqual(sites, []); } finally { await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearPrismaEnv(); } }); test('getElementsForSite: single tenant-wide GET, indexed by site_id in-memory', async () => { _resetSitesCache(); _resetPrismaAuthCache(); const seen = { count: 0, urls: [] }; const fake = await makeFakePrisma({ 'GET /sdwan/v3.1/api/elements': ({ req }) => { seen.count += 1; seen.urls.push(req.url); return { status: 200, body: { items: [ { id: 'el-1', name: 'ION-1000-A', model_name: 'ION 1000', serial_number: 'SN1', connected: true, site_id: 'site-abc' }, { id: 'el-2', name: 'ION-1000-B', model_name: 'ION 1000', serial_number: 'SN2', connected: false, site_id: 'site-abc' }, { id: 'el-3', name: 'ION-3000-X', model_name: 'ION 3000', serial_number: 'SN3', connected: true, site_id: 'site-xyz' }, ], }, }; }, }); setSaseEnv(fake.baseUrl); try { const abc = await getElementsForSite('site-abc'); assert.equal(abc.length, 2); assert.equal(abc[0].id, 'el-1'); assert.equal(abc[0].model, 'ION 1000'); const xyz = await getElementsForSite('site-xyz'); assert.equal(xyz.length, 1); assert.equal(xyz[0].id, 'el-3'); const unknown = await getElementsForSite('site-nope'); assert.deepEqual(unknown, [], 'unknown site returns empty (no extra fetch)'); // Critical: only ONE network call across three site lookups. assert.equal(seen.count, 1, 'tenant-wide cache serves subsequent site lookups'); assert.equal(seen.urls[0], '/sdwan/v3.1/api/elements', 'no ?site_id= filter — full tenant fetch'); } finally { await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearPrismaEnv(); } }); test('getElementsForSite: elements with no site_id are dropped', async () => { _resetSitesCache(); _resetPrismaAuthCache(); const fake = await makeFakePrisma({ 'GET /sdwan/v3.1/api/elements': () => ({ status: 200, body: { items: [ { id: 'el-1', name: 'A', connected: true, site_id: 'site-abc' }, { id: 'el-orphan', name: 'unclaimed', connected: false, site_id: null }, ], }, }), }); setSaseEnv(fake.baseUrl); try { const els = await getElementsForSite('site-abc'); assert.equal(els.length, 1); assert.equal(els[0].id, 'el-1'); } finally { await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearPrismaEnv(); } }); test('getElementsForSite: caches tenant-wide (one fetch across many sites)', async () => { _resetSitesCache(); _resetPrismaAuthCache(); let fetchCount = 0; const fake = await makeFakePrisma({ 'GET /sdwan/v3.1/api/elements': () => { fetchCount += 1; return { status: 200, body: { items: [ { id: 'el-a', name: 'a', connected: true, site_id: 'site-1' }, { id: 'el-b', name: 'b', connected: true, site_id: 'site-2' }, { id: 'el-c', name: 'c', connected: true, site_id: 'site-3' }, ] } }; }, }); setSaseEnv(fake.baseUrl); try { await getElementsForSite('site-1'); await getElementsForSite('site-2'); await getElementsForSite('site-3'); await getElementsForSite('site-1'); // re-hit, no extra fetch assert.equal(fetchCount, 1); } finally { await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearPrismaEnv(); } }); test('getWanInterfacesForSite: GETs per-site waninterfaces, normalises to rows with admin state', async () => { _resetSitesCache(); _resetPrismaAuthCache(); const fake = await makeFakePrisma({ 'GET /sdwan/v2.10/api/sites/site-abc/waninterfaces': () => ({ status: 200, body: { items: [ { id: 'wi-1', name: 'MPLS Circuit', admin_up: true, used_for: 'primary', wan_network_id: 'net-1', bw_config_mode: 'manual' }, { id: 'wi-2', name: 'Broadband', admin_up: true, used_for: 'secondary', wan_network_id: 'net-2', bw_config_mode: 'manual' }, { id: 'wi-3', name: 'LTE Backup', admin_up: false, used_for: 'backup', wan_network_id: 'net-3', bw_config_mode: 'manual' }, ], }, }), }); setSaseEnv(fake.baseUrl); try { const wans = await getWanInterfacesForSite('site-abc'); assert.equal(wans.length, 3); assert.equal(wans[0].id, 'wi-1'); assert.equal(wans[0].name, 'MPLS Circuit'); assert.equal(wans[0].adminUp, true); assert.equal(wans[0].usedFor, 'primary'); assert.equal(wans[2].adminUp, false); } finally { await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearPrismaEnv(); } }); test('getWanInterfacesForSite: missing admin_up field → adminUp:null (not false)', async () => { // Live regression: some tenant schema variants don't return an // `admin_up` field on the waninterface config. Old code did // `w.admin_up === true`, which falsy-coerced missing → false and // then rendered every circuit as ❌ DOWN — a misleading false // positive. The parser must distinguish missing (unknown) from // an explicit `false` value. _resetSitesCache(); _resetPrismaAuthCache(); const fake = await makeFakePrisma({ 'GET /sdwan/v2.10/api/sites/site-x/waninterfaces': () => ({ status: 200, body: { items: [ { id: 'wi-a', name: 'no admin_up field at all' }, { id: 'wi-b', name: 'admin_up=null', admin_up: null }, { id: 'wi-c', name: 'admin_up="true" string', admin_up: 'true' }, { id: 'wi-d', name: 'admin_up=true bool', admin_up: true }, { id: 'wi-e', name: 'admin_up=false bool', admin_up: false }, ], }, }), }); setSaseEnv(fake.baseUrl); try { const wans = await getWanInterfacesForSite('site-x'); assert.equal(wans[0].adminUp, null, 'missing field → null (unknown)'); assert.equal(wans[1].adminUp, null, 'explicit null → null (unknown)'); assert.equal(wans[2].adminUp, null, 'stringy "true" is not a boolean → null'); assert.equal(wans[3].adminUp, true, 'explicit true stays true'); assert.equal(wans[4].adminUp, false, 'explicit false stays false (admin-disabled)'); } finally { await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearPrismaEnv(); } }); test('getWanInterfacesForSite: caches per-site', async () => { _resetSitesCache(); _resetPrismaAuthCache(); let fetchCount = 0; const fake = await makeFakePrisma({ 'GET /sdwan/v2.10/api/sites/site-x/waninterfaces': () => { fetchCount += 1; return { status: 200, body: { items: [{ id: 'wi', name: 'x', admin_up: true }] } }; }, }); setSaseEnv(fake.baseUrl); try { await getWanInterfacesForSite('site-x'); await getWanInterfacesForSite('site-x'); assert.equal(fetchCount, 1); } finally { await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearPrismaEnv(); } }); test('getWanInterfacesForSite: returns empty list on 500 with no cache', async () => { _resetSitesCache(); _resetPrismaAuthCache(); const fake = await makeFakePrisma({ 'GET /sdwan/v2.10/api/sites/broken/waninterfaces': () => ({ status: 500, body: { error: 'nope' } }), }); setSaseEnv(fake.baseUrl); try { const wans = await getWanInterfacesForSite('broken'); assert.deepEqual(wans, []); } finally { await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearPrismaEnv(); } });