Files
kme_content_adapter/tests/contract/proxy-http.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

307 lines
11 KiB
JavaScript

import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import http from 'node:http';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import axios from 'axios';
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 with the provided deps (mirrors server.js loadGlobalVariables). */
function makeHelpers(deps) {
return helpersScript.runInContext(vm.createContext(deps));
}
/**
* Start a minimal HTTP server that handles all requests with a fixed JSON body.
* @param {number} statusCode
* @param {object} responseBody
* @returns {Promise<{ server: http.Server, url: string, close: () => Promise<void> }>}
*/
function startMockServer(statusCode, responseBody) {
return new Promise((resolve, reject) => {
const server = http.createServer((req, res) => {
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(responseBody));
});
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
const url = `http://127.0.0.1:${port}`;
const close = () => new Promise((res, rej) => server.close(err => err ? rej(err) : res()));
resolve({ server, url, close });
});
server.once('error', reject);
});
}
/**
* Start a mock token server (alias for backwards compatibility).
*/
const startMockTokenServer = startMockServer;
/** Build an in-memory Redis fake. */
function makeRedisFake() {
const _store = {};
return {
hSet: async (key, field, value) => { _store[`${key}:${field}`] = value; return 1; },
hGet: async (key, field) => _store[`${key}:${field}`] ?? null,
};
}
/** Build a capturable res object. */
function makeRes() {
let statusCode = null;
let body = '';
const headers = {};
return {
writeHead: (code, hdrs = {}) => { statusCode = code; Object.assign(headers, hdrs); },
end: (b = '') => { body += String(b); },
get statusCode() { return statusCode; },
get body() { return body; },
get headers() { return headers; },
};
}
// ---------------------------------------------------------------------------
// Contract: 200 OK — successful OIDC token fetch
// ---------------------------------------------------------------------------
describe('proxy HTTP contract: 200 OK', () => {
test('fresh token fetch → 200 Authorized with Content-Type text/plain', async () => {
const mock = await startMockTokenServer(200, {
id_token: 'contract-token',
expires_in: 9_999_999_999,
});
try {
const res = makeRes();
const redis = makeRedisFake();
const kme_CSA_settings = {
tokenUrl: mock.url,
username: 'user',
password: 'pass',
clientId: 'client',
scope: 'openid',
};
const deps = { URLSearchParams, console, axios, xmlBuilder, redis, kme_CSA_settings };
const ctx = vm.createContext({
...deps,
kmeContentSourceAdapterHelpers: makeHelpers(deps),
req: { url: '/', method: 'GET', headers: {} },
res,
});
await proxyScript.runInContext(ctx);
assert.strictEqual(res.statusCode, 200);
assert.strictEqual(res.body, 'Authorized');
assert.strictEqual(res.headers['Content-Type'], 'text/plain');
} finally {
await mock.close();
}
});
});
// ---------------------------------------------------------------------------
// Contract: 401 Unauthorized — token service returns 4xx
// ---------------------------------------------------------------------------
describe('proxy HTTP contract: 401 Unauthorized', () => {
test('token service 401 → proxy 401 with Unauthorized: prefix', async () => {
const mock = await startMockTokenServer(401, {});
try {
const res = makeRes();
const redis = makeRedisFake();
const kme_CSA_settings = {
tokenUrl: mock.url,
username: 'bad-user',
password: 'bad-pass',
clientId: 'client',
scope: 'openid',
};
const deps = { URLSearchParams, console, axios, xmlBuilder, redis, kme_CSA_settings };
const ctx = vm.createContext({
...deps,
kmeContentSourceAdapterHelpers: makeHelpers(deps),
req: { url: '/', method: 'GET', headers: {} },
res,
});
await proxyScript.runInContext(ctx);
assert.strictEqual(res.statusCode, 401);
assert.match(res.body, /^Unauthorized: .+/);
assert.strictEqual(res.headers['Content-Type'], 'text/plain');
} finally {
await mock.close();
}
});
});
// ---------------------------------------------------------------------------
// Contract: sitemap endpoint (T005, T012)
// ---------------------------------------------------------------------------
describe('sitemap endpoint', () => {
/**
* Build a VM context wired to a real token server and a real search server.
* The token cache is pre-seeded so no real token exchange is needed.
*/
function makeSitemapCtx({ searchUrl, tokenUrl }) {
const redis = makeRedisFake();
// Pre-seed a valid token so no token fetch is needed
redis.hSet('authorization', 'token', 'sitemap-contract-token');
redis.hSet('authorization', 'expiry', '9999999999');
const res = makeRes();
const kme_CSA_settings = {
tokenUrl: tokenUrl ?? 'http://127.0.0.1:1', // not used (cache hit)
username: 'user',
password: 'pass',
clientId: 'client',
scope: 'openid',
searchApiBaseUrl: searchUrl,
tenant: 'test',
proxyBaseUrl: 'https://proxy.example.com',
};
const deps = { URLSearchParams, console, axios, xmlBuilder, redis, kme_CSA_settings };
const ctx = vm.createContext({
...deps,
kmeContentSourceAdapterHelpers: makeHelpers(deps),
req: { url: '/sitemap.xml', method: 'GET', headers: {} },
res,
});
ctx._res = res;
return ctx;
}
test('full round-trip GET /sitemap.xml → 200 application/xml with loc elements', async () => {
const searchMock = await startMockServer(200, {
'hydra:member': [
{ 'hydra:member': [{ 'vkm:url': 'https://kme.example.com/doc-1' }] },
],
});
try {
const ctx = makeSitemapCtx({ searchUrl: searchMock.url });
await proxyScript.runInContext(ctx);
assert.strictEqual(ctx._res.statusCode, 200);
assert.ok(ctx._res.headers['Content-Type'].includes('application/xml'),
`Content-Type was: ${ctx._res.headers['Content-Type']}`);
assert.ok(ctx._res.body.startsWith('<?xml'), 'body should start with XML declaration');
assert.ok(ctx._res.body.includes('<loc>'), 'body should contain a loc element');
} finally {
await searchMock.close();
}
});
test('empty results round-trip → 200 application/xml with urlset and no url element', async () => {
const searchMock = await startMockServer(200, { 'hydra:member': [] });
try {
const ctx = makeSitemapCtx({ searchUrl: searchMock.url });
await proxyScript.runInContext(ctx);
assert.strictEqual(ctx._res.statusCode, 200);
assert.ok(ctx._res.headers['Content-Type'].includes('application/xml'),
`Content-Type was: ${ctx._res.headers['Content-Type']}`);
assert.ok(ctx._res.body.includes('<urlset'), 'body should contain urlset');
assert.ok(!ctx._res.body.includes('<url>'), 'body should not contain url elements for empty results');
} finally {
await searchMock.close();
}
});
test('search server returns 503 → adapter returns 502', async () => {
const searchMock = await startMockServer(503, { error: 'Service Unavailable' });
try {
const ctx = makeSitemapCtx({ searchUrl: searchMock.url });
await proxyScript.runInContext(ctx);
assert.strictEqual(ctx._res.statusCode, 502, `body was: ${ctx._res.body}`);
} finally {
await searchMock.close();
}
});
test('search server hangs > 10s → adapter returns 504 within 12s', async () => {
// Server that accepts connections but never responds
const server = await new Promise((resolve, reject) => {
const s = http.createServer(() => { /* intentionally hang */ });
s.listen(0, '127.0.0.1', () => {
const { port } = s.address();
const close = () => new Promise((res, rej) => s.close(err => err ? rej(err) : res()));
resolve({ server: s, url: `http://127.0.0.1:${port}`, close });
});
s.once('error', reject);
});
try {
const ctx = makeSitemapCtx({ searchUrl: server.url });
const start = Date.now();
await proxyScript.runInContext(ctx);
const elapsed = Date.now() - start;
assert.strictEqual(ctx._res.statusCode, 504, `body was: ${ctx._res.body}`);
assert.ok(elapsed < 12000, `Should respond within 12s, took ${elapsed}ms`);
} finally {
await server.close();
}
});
});
// ---------------------------------------------------------------------------
// Non-sitemap endpoint regression (T010)
// ---------------------------------------------------------------------------
describe('non-sitemap endpoint (regression)', () => {
test('GET / with valid OIDC credentials → 200 Authorized', async () => {
const mock = await startMockTokenServer(200, {
id_token: 'regression-token',
expires_in: 9_999_999_999,
});
try {
const res = makeRes();
const redis = makeRedisFake();
const kme_CSA_settings = {
tokenUrl: mock.url,
username: 'user',
password: 'pass',
clientId: 'client',
scope: 'openid',
};
const deps = { URLSearchParams, console, axios, xmlBuilder, redis, kme_CSA_settings };
const ctx = vm.createContext({
...deps,
kmeContentSourceAdapterHelpers: makeHelpers(deps),
req: { url: '/', method: 'GET', headers: {} },
res,
});
await proxyScript.runInContext(ctx);
assert.strictEqual(res.statusCode, 200);
assert.strictEqual(res.body, 'Authorized');
} finally {
await mock.close();
}
});
});