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 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 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 }>} */ 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). */ /** 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 // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // 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, URL, console, axios, xmlbuilder2, 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(''), '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(''), '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(); } }); }); // --------------------------------------------------------------------------- // Content fetch: happy path contract test — T008 // --------------------------------------------------------------------------- describe('content fetch: happy path', () => { test('GET /?kmeURL= → 200 text/html with article body (SC-001 < 11s)', async () => { // Mock content server returning a valid article JSON-LD response const contentMock = await startMockServer(200, { 'vkm:name': 'Contract Article', 'vkm:articleBody': '

Contract test article

' }); try { const redis = makeRedisFake(); // Pre-seed token — no real token exchange needed await redis.hSet('authorization', 'token', 'content-contract-token'); await redis.hSet('authorization', 'expiry', '9999999999'); const res = makeRes(); const kme_CSA_settings = { tokenUrl: 'http://127.0.0.1:1', // unreachable — not used (cache hit) username: 'user', password: 'pass', clientId: 'client', scope: 'openid', }; const deps = { URLSearchParams, URL, console, axios, xmlbuilder2, redis, kme_CSA_settings }; const ctx = vm.createContext({ ...deps, kmeContentSourceAdapterHelpers: makeHelpers(deps), req: { url: `/?kmeURL=${encodeURIComponent(contentMock.url)}`, method: 'GET', headers: {} }, res, }); const start = Date.now(); await proxyScript.runInContext(ctx); const elapsed = Date.now() - start; assert.strictEqual(res.statusCode, 200); assert.ok( res.headers['Content-Type'].startsWith('text/html'), `Content-Type was: ${res.headers['Content-Type']}`, ); assert.ok(res.body.includes(''), 'body should contain DOCTYPE'); assert.ok(res.body.includes('Contract Article'), 'body should contain title'); assert.ok(res.body.includes('

Contract test article

'), 'body should contain article content verbatim'); assert.ok(elapsed < 11000, `Round-trip should be under 11 s, took ${elapsed}ms`); } finally { await contentMock.close(); } }); }); // --------------------------------------------------------------------------- // Content fetch: error handling contract tests — T012 // --------------------------------------------------------------------------- describe('content fetch: error handling', () => { /** Build a content-fetch VM context wired to the given upstream URL. */ function makeContentFetchCtx(contentUrl) { const redis = makeRedisFake(); // Pre-seed token so no real auth server is needed redis.hSet('authorization', 'token', 'content-contract-token'); redis.hSet('authorization', 'expiry', '9999999999'); const res = makeRes(); const kme_CSA_settings = { tokenUrl: 'http://127.0.0.1:1', // not used (cache hit) username: 'user', password: 'pass', clientId: 'client', scope: 'openid', }; const deps = { URLSearchParams, URL, console, axios, xmlbuilder2, redis, kme_CSA_settings }; const ctx = vm.createContext({ ...deps, kmeContentSourceAdapterHelpers: makeHelpers(deps), req: { url: `/?kmeURL=${encodeURIComponent(contentUrl)}`, method: 'GET', headers: {} }, res, }); ctx._res = res; return ctx; } test('mock upstream returns 404 → proxy returns 404', async () => { const mock = await startMockServer(404, { error: 'Not Found' }); try { const ctx = makeContentFetchCtx(mock.url); await proxyScript.runInContext(ctx); assert.strictEqual(ctx._res.statusCode, 404, `body was: ${ctx._res.body}`); } finally { await mock.close(); } }); test('mock upstream returns 503 → proxy returns 502', async () => { const mock = await startMockServer(503, { error: 'Service Unavailable' }); try { const ctx = makeContentFetchCtx(mock.url); await proxyScript.runInContext(ctx); assert.strictEqual(ctx._res.statusCode, 502, `body was: ${ctx._res.body}`); } finally { await mock.close(); } }); test('server accepts connection but never responds → proxy returns 502 within 12s', async () => { const hangServer = await new Promise((resolve, reject) => { const s = http.createServer(() => { /* intentionally hang — never respond */ }); 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 = makeContentFetchCtx(hangServer.url); const start = Date.now(); await proxyScript.runInContext(ctx); const elapsed = Date.now() - start; assert.strictEqual(ctx._res.statusCode, 502, `body was: ${ctx._res.body}`); assert.ok(elapsed < 12000, `Should respond within 12s, took ${elapsed}ms`); } finally { await hangServer.close(); } }); });