- Paginate sitemap using hydra:view['hydra:last'] (0-based item index model) - Select latest vkm:datePublished fragment per SearchResultItem - Cap sitemap at 50,000 URLs per sitemaps.org protocol - Wrap content fetch response in full HTML document (DOCTYPE, head, body) - Add <head><title> populated from vkm:name field - Remove oidcAuthFlow route (404 for unmatched paths) - All 51 tests passing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
325 lines
12 KiB
JavaScript
325 lines
12 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 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<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).
|
|
*/
|
|
|
|
/** 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('<?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();
|
|
}
|
|
});
|
|
});
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Content fetch: happy path contract test — T008
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('content fetch: happy path', () => {
|
|
test('GET /?kmeURL=<mock-server> → 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': '<p>Contract test article</p>' });
|
|
|
|
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('<!DOCTYPE html>'), 'body should contain DOCTYPE');
|
|
assert.ok(res.body.includes('<title>Contract Article</title>'), 'body should contain title');
|
|
assert.ok(res.body.includes('<p>Contract test article</p>'), '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();
|
|
}
|
|
});
|
|
});
|