import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import vm from 'node:vm'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import xmlbuilder2 from 'xmlbuilder2'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const proxyPath = join(__dirname, '../../src/proxyScripts/kmeContentSourceAdapter.js'); const proxyCode = readFileSync(proxyPath, 'utf-8'); const proxyScript = new vm.Script(proxyCode, { filename: 'kmeContentSourceAdapter.js' }); const helpersPath = join(__dirname, '../../src/globalVariables/kmeContentSourceAdapterHelpers.js'); const helpersCode = readFileSync(helpersPath, 'utf-8'); const helpersWrapped = `(function() {\n${helpersCode}\n})()`; const helpersScript = new vm.Script(helpersWrapped, { filename: 'kmeContentSourceAdapterHelpers.js' }); /** * Evaluate the helpers file in a context built from the provided deps, returning * the helpers object. Mirrors how server.js loads globalVariables/ JS files. */ function makeHelpers(deps) { return helpersScript.runInContext(vm.createContext(deps)); } /** * Build a minimal VM context satisfying the vm-context contract. * @param {import('node:test').TestContext} t * @param {object} [overrides] – shallow-merge over defaults */ function makeContext(t, overrides = {}) { const _store = {}; const redis = { hSet: t.mock.fn(async (key, field, value) => { _store[`${key}:${field}`] = value; return 1; }), hGet: t.mock.fn(async (key, field) => _store[`${key}:${field}`] ?? null), }; let statusCode = null; let body = ''; const headers = {}; const res = { writeHead: t.mock.fn((code, hdrs = {}) => { statusCode = code; Object.assign(headers, hdrs); }), end: t.mock.fn((b = '') => { body += String(b); }), get statusCode() { return statusCode; }, get body() { return body; }, get headers() { return headers; }, }; const defaultSettings = { tokenUrl: 'https://auth.example.com/token', username: 'testuser', password: 'testpass', clientId: 'test-client', scope: 'openid', }; const defaultAxiosMock = { post: t.mock.fn(async () => ({ data: { id_token: 'mock-token', expires_in: 9_999_999_999 }, })), get: t.mock.fn(async () => ({ data: { 'hydra:member': [] }, })), }; // Resolve the final axios and settings — overrides take precedence. // Helpers must close over the SAME axios/settings that the VM context will use, // otherwise tests that pass error-throwing axios overrides would get helpers // that still use the success-returning default. const resolvedAxios = overrides.axios ?? defaultAxiosMock; const resolvedSettings = overrides.kme_CSA_settings ?? defaultSettings; const kmeContentSourceAdapterHelpers = makeHelpers({ URLSearchParams, console, axios: resolvedAxios, redis, kme_CSA_settings: resolvedSettings, xmlbuilder2, }); const ctx = vm.createContext({ URLSearchParams, URL, console, axios: resolvedAxios, redis, kme_CSA_settings: defaultSettings, xmlbuilder2, kmeContentSourceAdapterHelpers, req: { url: '/', method: 'GET', headers: {} }, res, ...overrides, }); // Expose helpers for assertion ctx._redis = redis; ctx._res = res; ctx._store = _store; ctx._axios = resolvedAxios; return ctx; } /** Run the proxy script in a context and await the async IIFE completing. */ async function runScript(ctx) { // script.runInContext() returns the Promise from the async IIFE const result = proxyScript.runInContext(ctx); if (result && typeof result.then === 'function') { await result; } } // --------------------------------------------------------------------------- // User Story 1 — Successful Authenticated Request // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Phase 6 — Stampede Guard (FR-013) // --------------------------------------------------------------------------- describe('stampede guard', () => { test('concurrent requests → exactly one fetch, both get 200', async (t) => { // Shared kme_CSA_settings across both contexts (same reference) const kme_CSA_settings = { tokenUrl: 'https://auth.example.com/token', username: 'testuser', password: 'testpass', clientId: 'test-client', scope: 'openid', }; // Shared Redis store const _store = {}; const redis = { hSet: t.mock.fn(async (key, field, value) => { _store[`${key}:${field}`] = value; return 1; }), hGet: t.mock.fn(async (key, field) => _store[`${key}:${field}`] ?? null), }; // Slow axios post mock — 50ms delay before returning token const mockAxiosPost = t.mock.fn(async () => { await new Promise(resolve => setTimeout(resolve, 50)); return { data: { id_token: 'stampede-token', expires_in: 9_999_999_999 } }; }); const mockAxiosGet = t.mock.fn(async () => ({ data: { 'vkm:articleBody': '

Article

' }, })); const sharedAxios = { post: mockAxiosPost, get: mockAxiosGet }; // Build two contexts sharing kme_CSA_settings, redis, and axios references function makeRes(tctx) { let statusCode = null; let body = ''; return { writeHead: tctx.mock.fn((code) => { statusCode = code; }), end: tctx.mock.fn((b = '') => { body += String(b); }), get statusCode() { return statusCode; }, get body() { return body; }, }; } const res1 = makeRes(t); const res2 = makeRes(t); // Helpers must share the same redis/kme_CSA_settings/axios so the stampede guard works const sharedHelpers = makeHelpers({ URLSearchParams, console, axios: sharedAxios, redis, kme_CSA_settings, xmlbuilder2, }); const kmeURL = encodeURIComponent('https://kme.example.com/article/1'); const ctx1 = vm.createContext({ URLSearchParams, URL, console, axios: sharedAxios, redis, kme_CSA_settings, xmlbuilder2, kmeContentSourceAdapterHelpers: sharedHelpers, req: { url: `/?kmeURL=${kmeURL}`, method: 'GET', headers: {} }, res: res1, }); const ctx2 = vm.createContext({ URLSearchParams, URL, console, axios: sharedAxios, redis, kme_CSA_settings, xmlbuilder2, kmeContentSourceAdapterHelpers: sharedHelpers, req: { url: `/?kmeURL=${kmeURL}`, method: 'GET', headers: {} }, res: res2, }); // Fire both concurrently const p1 = proxyScript.runInContext(ctx1); const p2 = proxyScript.runInContext(ctx2); await Promise.all([p1, p2]); assert.strictEqual(mockAxiosPost.mock.calls.length, 1, 'stampede guard: only one fetch'); assert.strictEqual(res1.statusCode, 200); assert.strictEqual(res2.statusCode, 200); assert.ok(res1.body.includes('

Article

')); assert.ok(res2.body.includes('

Article

')); }); }); // --------------------------------------------------------------------------- // Sitemap flow — US1 (T004) // --------------------------------------------------------------------------- describe('sitemap flow', () => { function makeSitemapContext(t, axiosGetImpl, settingsOverrides = {}) { const ctx = makeContext(t, { req: { url: '/sitemap.xml', method: 'GET', headers: { host: 'proxy.example.com', 'x-forwarded-proto': 'https' } }, }); // Add sitemap-specific settings ctx.kme_CSA_settings.searchApiBaseUrl = 'https://search.example.com/api'; ctx.kme_CSA_settings.tenant = 'test-tenant'; Object.assign(ctx.kme_CSA_settings, settingsOverrides); // Pre-seed token cache so getValidToken() returns immediately ctx._store['authorization:token'] = 'sitemap-token'; ctx._store['authorization:expiry'] = '9999999999'; // Replace axios.get with the provided implementation ctx._axios.get = t.mock.fn(axiosGetImpl ?? (async () => ({ data: { 'hydra:member': [] }, }))); return ctx; } test('happy path — items present → 200 with correct XML and loc values', async (t) => { const ctx = makeSitemapContext(t, async () => ({ data: { 'hydra:member': [ { 'hydra:member': [{ 'vkm:url': 'https://kme.example.com/doc-1' }] }, { 'hydra:member': [{ 'vkm:url': 'https://kme.example.com/doc-2' }] }, ], }, })); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 200); assert.strictEqual(ctx._res.headers['Content-Type'], 'application/xml'); assert.ok(ctx._res.body.includes('https://proxy.example.com/?kmeURL=https%3A%2F%2Fkme.example.com%2Fdoc-1'), 'body should contain encoded loc for doc-1', ); assert.ok( ctx._res.body.includes('https://proxy.example.com/?kmeURL=https%3A%2F%2Fkme.example.com%2Fdoc-2'), 'body should contain encoded loc for doc-2', ); }); test('happy path — zero items → 200 with empty urlset', async (t) => { const ctx = makeSitemapContext(t, async () => ({ data: { 'hydra:member': [] } })); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 200); assert.strictEqual(ctx._res.headers['Content-Type'], 'application/xml'); assert.ok(ctx._res.body.includes(''), 'body should not contain url elements'); }); test('items with empty vkm:url filtered — only valid items appear', async (t) => { const ctx = makeSitemapContext(t, async () => ({ data: { 'hydra:member': [ { 'hydra:member': [{ 'vkm:url': '' }] }, { 'hydra:member': [{ 'vkm:url': 'https://kme.example.com/valid' }] }, ], }, })); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 200); const locMatches = ctx._res.body.match(//g); assert.strictEqual(locMatches?.length ?? 0, 1, 'exactly one element expected'); assert.ok(ctx._res.body.includes('valid'), 'the valid URL should appear in the loc'); }); // US3 error scenarios (T011b) test('upstream 503 → 502 with Search service error message', async (t) => { const searchErr = Object.assign(new Error('Request failed with status code 503'), { response: { status: 503 }, }); const ctx = makeSitemapContext(t, async () => { throw searchErr; }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 502); assert.ok(ctx._res.body.includes('Search service error: HTTP 503'), `body was: ${ctx._res.body}`); }); test('timeout ECONNABORTED → 504 Search service timeout', async (t) => { const timeoutErr = Object.assign(new Error('timeout'), { code: 'ECONNABORTED' }); const ctx = makeSitemapContext(t, async () => { throw timeoutErr; }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 504); assert.ok(ctx._res.body.includes('Search service timeout'), `body was: ${ctx._res.body}`); }); test('timeout ERR_CANCELED → 504 Search service timeout', async (t) => { const timeoutErr = Object.assign(new Error('canceled'), { code: 'ERR_CANCELED' }); const ctx = makeSitemapContext(t, async () => { throw timeoutErr; }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 504); assert.ok(ctx._res.body.includes('Search service timeout'), `body was: ${ctx._res.body}`); }); test('missing searchApiBaseUrl → 500 Configuration error', async (t) => { const ctx = makeSitemapContext(t, null, { searchApiBaseUrl: undefined }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 500); assert.strictEqual(ctx._res.body, 'Configuration error: missing required field: searchApiBaseUrl'); }); test('missing tenant → 500 Configuration error', async (t) => { const ctx = makeSitemapContext(t, null, { tenant: undefined }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 500); assert.strictEqual(ctx._res.body, 'Configuration error: missing required field: tenant'); }); }); // --------------------------------------------------------------------------- // extractArticleBody helper — T004 // --------------------------------------------------------------------------- describe('extractArticleBody helper', () => { const minimalDeps = { URLSearchParams, URL, console, axios: { post: async () => ({}), get: async () => ({}) }, redis: { hGet: async () => null, hSet: async () => 1 }, kme_CSA_settings: {}, xmlbuilder2, }; const helpers = makeHelpers(minimalDeps); test('valid HTML string → returns the string', () => { assert.strictEqual(helpers.extractArticleBody({ 'vkm:articleBody': '

Hello

' }), '

Hello

'); }); test('empty string → null', () => { assert.strictEqual(helpers.extractArticleBody({ 'vkm:articleBody': '' }), null); }); test('whitespace-only string → null', () => { assert.strictEqual(helpers.extractArticleBody({ 'vkm:articleBody': ' ' }), null); }); test('null field value → null', () => { assert.strictEqual(helpers.extractArticleBody({ 'vkm:articleBody': null }), null); }); test('field absent ({}) → null', () => { assert.strictEqual(helpers.extractArticleBody({}), null); }); test('null input → null', () => { assert.strictEqual(helpers.extractArticleBody(null), null); }); test('non-object input (string) → null', () => { assert.strictEqual(helpers.extractArticleBody('not-an-object'), null); }); }); // --------------------------------------------------------------------------- // US-content-fetch: happy path — T007 // --------------------------------------------------------------------------- describe('US-content-fetch: happy path', () => { test('cached token + valid article response → 200 text/html with body', async (t) => { const contentAxios = { post: t.mock.fn(async () => ({ data: { id_token: 'mock-token', expires_in: 9_999_999_999 } })), get: t.mock.fn(async () => ({ data: { 'vkm:articleBody': '

Hello

' } })), }; const ctx = makeContext(t, { req: { url: '/?kmeURL=https://kme.example.com/content/article/123', method: 'GET', headers: {} }, axios: contentAxios, }); // Pre-seed token cache → cache hit, no axios.post call ctx._store['authorization:token'] = 'cached-token'; ctx._store['authorization:expiry'] = '9999999999'; await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 200); assert.ok( ctx._res.headers['Content-Type'].startsWith('text/html'), `Content-Type was: ${ctx._res.headers['Content-Type']}`, ); assert.strictEqual(ctx._res.body, '

Hello

'); assert.strictEqual(contentAxios.post.mock.calls.length, 0, 'should not re-fetch token on cache hit'); }); test('cache miss (fresh token acquired) → 200 text/html with body', async (t) => { const contentAxios = { post: t.mock.fn(async () => ({ data: { id_token: 'fresh-token', expires_in: 9_999_999_999 } })), get: t.mock.fn(async () => ({ data: { 'vkm:articleBody': '

Hello

' } })), }; const ctx = makeContext(t, { req: { url: '/?kmeURL=https://kme.example.com/content/article/123', method: 'GET', headers: {} }, axios: contentAxios, }); // No pre-seeded token → cache miss → axios.post will be called for fresh token await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 200); assert.ok( ctx._res.headers['Content-Type'].startsWith('text/html'), `Content-Type was: ${ctx._res.headers['Content-Type']}`, ); assert.strictEqual(ctx._res.body, '

Hello

'); assert.strictEqual(contentAxios.post.mock.calls.length, 1, 'should have fetched fresh token'); }); }); // --------------------------------------------------------------------------- // US-content-fetch: input validation — T009 // --------------------------------------------------------------------------- describe('US-content-fetch: input validation', () => { test('no kmeURL param → 404 Not Found, axios.get not called', async (t) => { const ctx = makeContext(t, { req: { url: '/?someOtherParam=value', method: 'GET', headers: {} }, }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 404); assert.strictEqual(ctx._axios.get.mock.calls.length, 0, 'axios.get should not be called'); }); test('empty kmeURL → 400 kmeURL parameter is required', async (t) => { const ctx = makeContext(t, { req: { url: '/?kmeURL=', method: 'GET', headers: {} }, }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 400); assert.ok(ctx._res.body.includes('kmeURL parameter is required'), `body was: ${ctx._res.body}`); assert.strictEqual(ctx._axios.get.mock.calls.length, 0, 'axios.get should not be called'); }); test('whitespace-only kmeURL (%20) → 400', async (t) => { const ctx = makeContext(t, { req: { url: '/?kmeURL=%20', method: 'GET', headers: {} }, }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 400); assert.strictEqual(ctx._axios.get.mock.calls.length, 0, 'axios.get should not be called'); }); test('relative path kmeURL → 400 well-formed absolute http/https URL', async (t) => { const ctx = makeContext(t, { req: { url: '/?kmeURL=relative/path', method: 'GET', headers: {} }, }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 400); assert.ok( ctx._res.body.includes('well-formed absolute http/https URL'), `body was: ${ctx._res.body}`, ); assert.strictEqual(ctx._axios.get.mock.calls.length, 0, 'axios.get should not be called'); }); test('ftp protocol kmeURL → 400', async (t) => { const ctx = makeContext(t, { req: { url: '/?kmeURL=ftp://example.com/article', method: 'GET', headers: {} }, }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 400); assert.strictEqual(ctx._axios.get.mock.calls.length, 0, 'axios.get should not be called'); }); test(':::malformed kmeURL → 400', async (t) => { const ctx = makeContext(t, { req: { url: '/?kmeURL=:::malformed', method: 'GET', headers: {} }, }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 400); assert.strictEqual(ctx._axios.get.mock.calls.length, 0, 'axios.get should not be called'); }); }); // --------------------------------------------------------------------------- // US-content-fetch: upstream errors — T010 // --------------------------------------------------------------------------- describe('US-content-fetch: upstream errors', () => { /** Build a context with kmeURL set and (optionally) a pre-seeded token. */ function makeUpstreamErrCtx(t, axiosGetImpl, { seedToken = true } = {}) { const ctx = makeContext(t, { req: { url: '/?kmeURL=https://kme.example.com/article', method: 'GET', headers: {} }, axios: { post: t.mock.fn(async () => ({ data: { id_token: 'mock-token', expires_in: 9_999_999_999 } })), get: t.mock.fn(axiosGetImpl), }, }); if (seedToken) { ctx._store['authorization:token'] = 'cached-tok'; ctx._store['authorization:expiry'] = '9999999999'; } return ctx; } test('getValidToken throws → 502 token acquisition failed', async (t) => { const ctx = makeContext(t, { req: { url: '/?kmeURL=https://kme.example.com/article', method: 'GET', headers: {} }, axios: { post: t.mock.fn(async () => { throw new Error('token service down'); }), get: t.mock.fn(), }, }); // No pre-seeded token → getValidToken will try to POST → throw await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 502); assert.ok(ctx._res.body.includes('token acquisition failed'), `body was: ${ctx._res.body}`); }); test('upstream 404 → proxy 404 article not found at upstream', async (t) => { const err = Object.assign(new Error('Not Found'), { response: { status: 404 } }); const ctx = makeUpstreamErrCtx(t, async () => { throw err; }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 404); assert.ok(ctx._res.body.includes('article not found at upstream'), `body was: ${ctx._res.body}`); }); test('upstream 410 → proxy 404', async (t) => { const err = Object.assign(new Error('Gone'), { response: { status: 410 } }); const ctx = makeUpstreamErrCtx(t, async () => { throw err; }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 404); }); test('upstream 503 → proxy 502 upstream error HTTP 503', async (t) => { const err = Object.assign(new Error('Service Unavailable'), { response: { status: 503 } }); const ctx = makeUpstreamErrCtx(t, async () => { throw err; }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 502); assert.ok(ctx._res.body.includes('upstream error HTTP 503'), `body was: ${ctx._res.body}`); }); test('ECONNABORTED → proxy 502 upstream request timed out', async (t) => { const err = Object.assign(new Error('timeout'), { code: 'ECONNABORTED' }); const ctx = makeUpstreamErrCtx(t, async () => { throw err; }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 502); assert.ok(ctx._res.body.includes('upstream request timed out'), `body was: ${ctx._res.body}`); }); test('ERR_CANCELED → proxy 502', async (t) => { const err = Object.assign(new Error('canceled'), { code: 'ERR_CANCELED' }); const ctx = makeUpstreamErrCtx(t, async () => { throw err; }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 502); }); test('network error (no response, no code) → proxy 502 containing Bad Gateway:', async (t) => { const err = new Error('ENOTFOUND'); const ctx = makeUpstreamErrCtx(t, async () => { throw err; }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 502); assert.ok(ctx._res.body.includes('Bad Gateway:'), `body was: ${ctx._res.body}`); }); }); // --------------------------------------------------------------------------- // US-content-fetch: body parsing — T011 // --------------------------------------------------------------------------- describe('US-content-fetch: body parsing', () => { /** Build a context that will produce a given data value from axios.get. */ function makeBodyCtx(t, dataValue) { const ctx = makeContext(t, { req: { url: '/?kmeURL=https://kme.example.com/article', method: 'GET', headers: {} }, axios: { post: t.mock.fn(async () => ({ data: { id_token: 'mock-token', expires_in: 9_999_999_999 } })), get: t.mock.fn(async () => ({ data: dataValue })), }, }); ctx._store['authorization:token'] = 'cached-tok'; ctx._store['authorization:expiry'] = '9999999999'; return ctx; } test('unparseable string response → 502 unparseable response from upstream', async (t) => { const ctx = makeBodyCtx(t, 'not json{{{'); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 502); assert.ok( ctx._res.body.includes('unparseable response from upstream'), `body was: ${ctx._res.body}`, ); }); test('vkm:articleBody absent (undefined) → 404 article body not present', async (t) => { const ctx = makeBodyCtx(t, { 'vkm:articleBody': undefined }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 404); assert.ok(ctx._res.body.includes('article body not present'), `body was: ${ctx._res.body}`); }); test('vkm:articleBody is null → 404', async (t) => { const ctx = makeBodyCtx(t, { 'vkm:articleBody': null }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 404); }); test('vkm:articleBody is empty string → 404', async (t) => { const ctx = makeBodyCtx(t, { 'vkm:articleBody': '' }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 404); }); test('vkm:articleBody is whitespace-only → 404', async (t) => { const ctx = makeBodyCtx(t, { 'vkm:articleBody': ' ' }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 404); }); }); // --------------------------------------------------------------------------- // US-content-fetch: passthrough preserved — T013 // --------------------------------------------------------------------------- describe('US-content-fetch: passthrough preserved', () => { test('GET with no kmeURL, not sitemap → 404 Not Found, axios.get not called', async (t) => { const ctx = makeContext(t, { req: { url: '/?someOtherParam=value', method: 'GET', headers: {} }, }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 404); assert.strictEqual(ctx._axios.get.mock.calls.length, 0, 'axios.get must not be called'); }); });