Files
kme_content_adapter/tests/unit/proxy.test.js
Peter.Morton 07c3cc72cc refactor: extract helpers into kmeContentSourceAdapterHelpers.js
Move getValidToken, validateSettings, extractHydraItems, and buildSitemapXml
out of the proxy IIFE into src/globalVariables/kmeContentSourceAdapterHelpers.js
following the literal function body pattern (auto-loaded by server.js, injected
as 'kmeContentSourceAdapterHelpers' into VM context).

oidcAuthFlow() and sitemapFlow() remain in the proxy script as they own req/res.

Update unit and contract tests to evaluate the helpers file with the same mock
dependencies used in each VM context, ensuring error-throwing axios overrides
are correctly seen by the helpers' closures.

All 31 tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 22:21:00 -05:00

559 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { create as xmlBuilder } 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,
xmlBuilder,
});
const ctx = vm.createContext({
URLSearchParams,
console,
axios: resolvedAxios,
redis,
kme_CSA_settings: defaultSettings,
xmlBuilder,
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
// ---------------------------------------------------------------------------
describe('US1: successful authenticated request', () => {
test('cache miss → fresh fetch → 200 OK', async (t) => {
const ctx = makeContext(t);
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 200);
assert.strictEqual(ctx._res.body, 'Authorized');
assert.strictEqual(ctx._axios.post.mock.calls.length, 1);
});
test('cache hit → no fetch → 200 OK', async (t) => {
const ctx = makeContext(t, {});
// Pre-seed Redis store via the fake
ctx._store['authorization:token'] = 'cached-tok';
ctx._store['authorization:expiry'] = '9999999999';
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 200);
assert.strictEqual(ctx._res.body, 'Authorized');
assert.strictEqual(ctx._axios.post.mock.calls.length, 0);
});
});
// ---------------------------------------------------------------------------
// User Story 2 — Token Expiry and Refresh
// ---------------------------------------------------------------------------
describe('US2: token expiry and refresh', () => {
test('expired token → re-fetch → 200 OK', async (t) => {
const ctx = makeContext(t);
ctx._store['authorization:token'] = 'old-tok';
ctx._store['authorization:expiry'] = '1'; // epoch far in the past
await runScript(ctx);
assert.strictEqual(ctx._axios.post.mock.calls.length, 1, 'should re-fetch');
assert.strictEqual(ctx._res.statusCode, 200);
assert.strictEqual(ctx._res.body, 'Authorized');
// New token should have been written to Redis
const hSetCalls = ctx._redis.hSet.mock.calls;
assert.ok(hSetCalls.length >= 2, 'hSet should be called for token and expiry');
});
test('future expiry → no re-fetch → 200 OK', async (t) => {
const ctx = makeContext(t);
ctx._store['authorization:token'] = 'fresh-tok';
ctx._store['authorization:expiry'] = '9999999999';
await runScript(ctx);
assert.strictEqual(ctx._axios.post.mock.calls.length, 0, 'should not re-fetch');
assert.strictEqual(ctx._res.statusCode, 200);
assert.strictEqual(ctx._res.body, 'Authorized');
});
});
// ---------------------------------------------------------------------------
// User Story 3 — Authentication Failure Handling
// ---------------------------------------------------------------------------
describe('US3: authentication failure handling', () => {
test('HTTP 401 from token service → 401 Unauthorized: HTTP 401', async (t) => {
const axiosError = Object.assign(new Error('Request failed with status code 401'), {
response: { status: 401 },
});
const ctx = makeContext(t, {
axios: { post: t.mock.fn(async () => { throw axiosError; }), get: t.mock.fn() },
});
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 401);
assert.strictEqual(ctx._res.body, 'Unauthorized: HTTP 401');
});
test('timeout (ECONNABORTED) → 401 Unauthorized: token service timeout', async (t) => {
const axiosError = Object.assign(new Error('timeout'), { code: 'ECONNABORTED' });
const ctx = makeContext(t, {
axios: { post: t.mock.fn(async () => { throw axiosError; }), get: t.mock.fn() },
});
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 401);
assert.strictEqual(ctx._res.body, 'Unauthorized: token service timeout');
});
test('timeout (ERR_CANCELED) → 401 Unauthorized: token service timeout', async (t) => {
const axiosError = Object.assign(new Error('canceled'), { code: 'ERR_CANCELED' });
const ctx = makeContext(t, {
axios: { post: t.mock.fn(async () => { throw axiosError; }), get: t.mock.fn() },
});
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 401);
assert.strictEqual(ctx._res.body, 'Unauthorized: token service timeout');
});
test('missing id_token in response → 401 Unauthorized: id_token missing from response', async (t) => {
const ctx = makeContext(t, {
axios: {
post: t.mock.fn(async () => ({ data: { expires_in: 9999 } })),
get: t.mock.fn(),
},
});
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 401);
assert.strictEqual(ctx._res.body, 'Unauthorized: id_token missing from response');
});
test('missing expires_in in response → 401 Unauthorized: expires_in missing from response', async (t) => {
const ctx = makeContext(t, {
axios: {
post: t.mock.fn(async () => ({ data: { id_token: 'a-token' } })),
get: t.mock.fn(),
},
});
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 401);
assert.strictEqual(ctx._res.body, 'Unauthorized: expires_in missing from response');
});
test('missing tokenUrl in kme_CSA_settings → 401 missing required field: tokenUrl', async (t) => {
const ctx = makeContext(t);
ctx.kme_CSA_settings.tokenUrl = '';
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 401);
assert.strictEqual(ctx._res.body, 'Unauthorized: missing required field: tokenUrl');
});
test('missing username in kme_CSA_settings → 401 missing required field: username', async (t) => {
const ctx = makeContext(t);
ctx.kme_CSA_settings.username = undefined;
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 401);
assert.strictEqual(ctx._res.body, 'Unauthorized: missing required field: username');
});
});
// ---------------------------------------------------------------------------
// 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 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 sharedAxios = { post: mockAxiosPost, get: t.mock.fn() };
// 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, xmlBuilder,
});
const ctx1 = vm.createContext({
URLSearchParams, console, axios: sharedAxios,
redis, kme_CSA_settings, xmlBuilder,
kmeContentSourceAdapterHelpers: sharedHelpers,
req: { url: '/', method: 'GET', headers: {} },
res: res1,
});
const ctx2 = vm.createContext({
URLSearchParams, console, axios: sharedAxios,
redis, kme_CSA_settings, xmlBuilder,
kmeContentSourceAdapterHelpers: sharedHelpers,
req: { url: '/', 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.strictEqual(res1.body, 'Authorized');
assert.strictEqual(res2.body, 'Authorized');
});
});
// ---------------------------------------------------------------------------
// Sitemap flow — US1 (T004)
// ---------------------------------------------------------------------------
describe('sitemap flow', () => {
function makeSitemapContext(t, axiosGetImpl, settingsOverrides = {}) {
const ctx = makeContext(t, {
req: { url: '/sitemap.xml', method: 'GET', headers: {} },
});
// Add sitemap-specific settings
ctx.kme_CSA_settings.searchApiBaseUrl = 'https://search.example.com/api';
ctx.kme_CSA_settings.tenant = 'test-tenant';
ctx.kme_CSA_settings.proxyBaseUrl = 'https://proxy.example.com';
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('<?xml'), 'body should start with XML declaration');
assert.ok(ctx._res.body.includes('<urlset'), 'body should contain urlset');
assert.ok(
ctx._res.body.includes('<loc>https://proxy.example.com?kmeURL=https%3A%2F%2Fkme.example.com%2Fdoc-1</loc>'),
'body should contain encoded loc for doc-1',
);
assert.ok(
ctx._res.body.includes('<loc>https://proxy.example.com?kmeURL=https%3A%2F%2Fkme.example.com%2Fdoc-2</loc>'),
'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('<urlset'), 'body should contain urlset');
assert.ok(!ctx._res.body.includes('<url>'), '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(/<loc>/g);
assert.strictEqual(locMatches?.length ?? 0, 1, 'exactly one <loc> 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');
});
test('missing proxyBaseUrl → 500 Configuration error', async (t) => {
const ctx = makeSitemapContext(t, null, { proxyBaseUrl: undefined });
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 500);
assert.strictEqual(ctx._res.body, 'Configuration error: missing required field: proxyBaseUrl');
});
});
// ---------------------------------------------------------------------------
// Non-sitemap URL routing — regression guard (T009)
// ---------------------------------------------------------------------------
describe('non-sitemap URL routing', () => {
test('cache hit → no fetch → 200 Authorized', async (t) => {
const ctx = makeContext(t, {
req: { url: '/', method: 'GET', headers: {} },
axios: {
post: t.mock.fn(async () => { throw new Error('should not be called'); }),
get: t.mock.fn(),
},
});
// Pre-seed valid token
ctx._store['authorization:token'] = 'cached-tok';
ctx._store['authorization:expiry'] = '9999999999';
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 200);
assert.strictEqual(ctx._res.body, 'Authorized');
// axios.post was set to throw, so if it was called the test would fail
});
test('cache miss → fresh fetch → 200 Authorized', async (t) => {
const ctx = makeContext(t, {
req: { url: '/', method: 'GET', headers: {} },
});
// No pre-seeded token → cache miss
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 200);
assert.strictEqual(ctx._res.body, 'Authorized');
// Verify token was written to Redis
const hSetCalls = ctx._redis.hSet.mock.calls;
const tokenCall = hSetCalls.find(c => c.arguments[0] === 'authorization' && c.arguments[1] === 'token');
assert.ok(tokenCall, 'hSet should be called with token');
assert.strictEqual(tokenCall.arguments[2], 'mock-token');
});
test('token service down (ECONNABORTED) → 401 Unauthorized', async (t) => {
const timeoutErr = Object.assign(new Error('timeout'), { code: 'ECONNABORTED' });
const ctx = makeContext(t, {
req: { url: '/', method: 'GET', headers: {} },
axios: {
post: t.mock.fn(async () => { throw timeoutErr; }),
get: t.mock.fn(),
},
});
await runScript(ctx);
assert.strictEqual(ctx._res.statusCode, 401);
assert.ok(ctx._res.body.startsWith('Unauthorized:'), `body was: ${ctx._res.body}`);
});
});