- 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>
849 lines
32 KiB
JavaScript
849 lines
32 KiB
JavaScript
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': '<p>Article</p>' },
|
||
}));
|
||
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('<p>Article</p>'));
|
||
assert.ok(res2.body.includes('<p>Article</p>'));
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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', 'vkm:datePublished': '2024-01-01T00:00:00Z' }] },
|
||
{ 'hydra:member': [{ 'vkm:url': 'https://kme.example.com/doc-2', 'vkm:datePublished': '2024-06-01T00:00:00Z' }] },
|
||
],
|
||
},
|
||
}));
|
||
|
||
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');
|
||
});
|
||
|
||
test('multiple fragments per SearchResultItem → only latest vkm:datePublished wins', async (t) => {
|
||
const ctx = makeSitemapContext(t, async () => ({
|
||
data: {
|
||
'hydra:member': [
|
||
{
|
||
'hydra:member': [
|
||
{ 'vkm:url': 'https://kme.example.com/doc/v1', 'vkm:datePublished': '2023-01-01T00:00:00Z' },
|
||
{ 'vkm:url': 'https://kme.example.com/doc/v3', 'vkm:datePublished': '2024-06-01T00:00:00Z' },
|
||
{ 'vkm:url': 'https://kme.example.com/doc/v2', 'vkm:datePublished': '2023-12-01T00:00:00Z' },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
}));
|
||
|
||
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 (latest version only)');
|
||
assert.ok(ctx._res.body.includes('doc%2Fv3'), 'the latest fragment (v3) should be the loc');
|
||
assert.ok(!ctx._res.body.includes('doc%2Fv1'), 'older fragment v1 should not appear');
|
||
assert.ok(!ctx._res.body.includes('doc%2Fv2'), 'older fragment v2 should not appear');
|
||
});
|
||
|
||
// Pagination: hydra:last nested inside hydra:view drives multi-page fetching.
|
||
// hydra:view is absent when all results fit on one page — no pagination needed.
|
||
// e.g. 22 results, size=5 → hydra:view['hydra:last'] start=20, fetch start=5,10,15,20
|
||
|
||
test('hydra:last (22 results, size=5, start=20) → fetches 4 extra pages, all 5 pages combined', async (t) => {
|
||
// Simulate the example from the spec: 22 results, page size 5
|
||
// First call has no start param; subsequent pages: start=5,10,15,20
|
||
const base = 'https://search.example.com/api/test-tenant/search?query=*&size=5&category=vkm%3AArticleCategory';
|
||
const pageData = {
|
||
[`${base}`]: {
|
||
'hydra:view': { 'hydra:last': `${base}&start=20` },
|
||
'hydra:member': [
|
||
{ 'hydra:member': [{ 'vkm:url': 'https://kme.example.com/doc-p1', 'vkm:datePublished': '2024-01-01T00:00:00Z' }] },
|
||
],
|
||
},
|
||
[`${base}&start=5`]: {
|
||
'hydra:member': [
|
||
{ 'hydra:member': [{ 'vkm:url': 'https://kme.example.com/doc-p2', 'vkm:datePublished': '2024-02-01T00:00:00Z' }] },
|
||
],
|
||
},
|
||
[`${base}&start=10`]: {
|
||
'hydra:member': [
|
||
{ 'hydra:member': [{ 'vkm:url': 'https://kme.example.com/doc-p3', 'vkm:datePublished': '2024-03-01T00:00:00Z' }] },
|
||
],
|
||
},
|
||
[`${base}&start=15`]: {
|
||
'hydra:member': [
|
||
{ 'hydra:member': [{ 'vkm:url': 'https://kme.example.com/doc-p4', 'vkm:datePublished': '2024-04-01T00:00:00Z' }] },
|
||
],
|
||
},
|
||
[`${base}&start=20`]: {
|
||
'hydra:member': [
|
||
{ 'hydra:member': [{ 'vkm:url': 'https://kme.example.com/doc-p5', 'vkm:datePublished': '2024-05-01T00:00:00Z' }] },
|
||
],
|
||
},
|
||
};
|
||
|
||
// Build context with size=5 in the request URL
|
||
const ctx = makeContext(t, {
|
||
req: { url: '/sitemap.xml?size=5', method: 'GET', headers: { host: 'proxy.example.com', 'x-forwarded-proto': 'https' } },
|
||
});
|
||
ctx.kme_CSA_settings.searchApiBaseUrl = 'https://search.example.com/api';
|
||
ctx.kme_CSA_settings.tenant = 'test-tenant';
|
||
ctx._store['authorization:token'] = 'sitemap-token';
|
||
ctx._store['authorization:expiry'] = '9999999999';
|
||
ctx._axios.get = t.mock.fn(async (url) => ({ data: pageData[url] ?? { 'hydra:member': [] } }));
|
||
|
||
await runScript(ctx);
|
||
|
||
assert.strictEqual(ctx._res.statusCode, 200);
|
||
assert.strictEqual(ctx._axios.get.mock.calls.length, 5, 'should make 5 GET calls (start 0,5,10,15,20)');
|
||
const locMatches = ctx._res.body.match(/<loc>/g);
|
||
assert.strictEqual(locMatches?.length ?? 0, 5, 'all 5 items from all pages should appear');
|
||
assert.ok(ctx._res.body.includes('doc-p1'));
|
||
assert.ok(ctx._res.body.includes('doc-p5'));
|
||
});
|
||
|
||
test('hydra:view absent (all results on one page) → no additional pages fetched', async (t) => {
|
||
const ctx = makeSitemapContext(t, async () => ({
|
||
data: {
|
||
// No hydra:view — all 22 results fit in size=50
|
||
'hydra:member': [
|
||
{ 'hydra:member': [{ 'vkm:url': 'https://kme.example.com/only-doc', 'vkm:datePublished': '2024-01-01T00:00:00Z' }] },
|
||
],
|
||
},
|
||
}));
|
||
|
||
await runScript(ctx);
|
||
|
||
assert.strictEqual(ctx._res.statusCode, 200);
|
||
assert.strictEqual(ctx._axios.get.mock.calls.length, 1, 'only one GET call when hydra:view absent');
|
||
const locMatches = ctx._res.body.match(/<loc>/g);
|
||
assert.strictEqual(locMatches?.length ?? 0, 1);
|
||
});
|
||
|
||
test('hydra:view present but hydra:last start=0 → no additional pages fetched', async (t) => {
|
||
const ctx = makeSitemapContext(t, async () => ({
|
||
data: {
|
||
'hydra:view': { 'hydra:last': 'https://search.example.com/api/test-tenant/search?query=*&size=100&category=vkm%3AArticleCategory&start=0' },
|
||
'hydra:member': [
|
||
{ 'hydra:member': [{ 'vkm:url': 'https://kme.example.com/only-doc', 'vkm:datePublished': '2024-01-01T00:00:00Z' }] },
|
||
],
|
||
},
|
||
}));
|
||
|
||
await runScript(ctx);
|
||
|
||
assert.strictEqual(ctx._res.statusCode, 200);
|
||
assert.strictEqual(ctx._axios.get.mock.calls.length, 1, 'only one GET call when hydra:last start=0');
|
||
const locMatches = ctx._res.body.match(/<loc>/g);
|
||
assert.strictEqual(locMatches?.length ?? 0, 1);
|
||
});
|
||
|
||
test('more than 50,000 items → sitemap truncated to exactly 50,000 <loc> elements', async (t) => {
|
||
const LIMIT = 50_000;
|
||
// Build a response with LIMIT + 5 items
|
||
const members = Array.from({ length: LIMIT + 5 }, (_, i) => ({
|
||
'hydra:member': [{ 'vkm:url': `https://kme.example.com/doc-${i}`, 'vkm:datePublished': '2024-01-01T00:00:00Z' }],
|
||
}));
|
||
const ctx = makeSitemapContext(t, async () => ({ data: { 'hydra:member': members } }));
|
||
|
||
await runScript(ctx);
|
||
|
||
assert.strictEqual(ctx._res.statusCode, 200);
|
||
const locMatches = ctx._res.body.match(/<loc>/g);
|
||
assert.strictEqual(locMatches?.length ?? 0, LIMIT, `should be capped at ${LIMIT}`);
|
||
});
|
||
|
||
// 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': '<p>Hello</p>' }), '<p>Hello</p>');
|
||
});
|
||
|
||
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 and title', 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:name': 'My Article', 'vkm:articleBody': '<p>Hello</p>' } })),
|
||
};
|
||
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.ok(ctx._res.body.includes('<!DOCTYPE html>'), 'body should contain DOCTYPE');
|
||
assert.ok(ctx._res.body.includes('<title>My Article</title>'), 'body should contain title');
|
||
assert.ok(ctx._res.body.includes('<p>Hello</p>'), 'body should contain article content verbatim');
|
||
assert.ok(!ctx._res.body.includes('<p><p>'), 'article content should not be double-wrapped in <p>');
|
||
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:name': 'Fresh Article', 'vkm:articleBody': '<p>Hello</p>' } })),
|
||
};
|
||
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.ok(ctx._res.body.includes('<!DOCTYPE html>'), 'body should contain DOCTYPE');
|
||
assert.ok(ctx._res.body.includes('<title>Fresh Article</title>'), 'body should contain title');
|
||
assert.ok(ctx._res.body.includes('<p>Hello</p>'), 'body should contain article content');
|
||
assert.strictEqual(contentAxios.post.mock.calls.length, 1, 'should have fetched fresh token');
|
||
});
|
||
|
||
test('vkm:name absent → title element is empty', async (t) => {
|
||
const contentAxios = {
|
||
post: t.mock.fn(),
|
||
get: t.mock.fn(async () => ({ data: { 'vkm:articleBody': '<p>No title</p>' } })),
|
||
};
|
||
const ctx = makeContext(t, {
|
||
req: { url: '/?kmeURL=https://kme.example.com/content/article/123', method: 'GET', headers: {} },
|
||
axios: contentAxios,
|
||
});
|
||
ctx._store['authorization:token'] = 'cached-token';
|
||
ctx._store['authorization:expiry'] = '9999999999';
|
||
|
||
await runScript(ctx);
|
||
|
||
assert.strictEqual(ctx._res.statusCode, 200);
|
||
assert.ok(ctx._res.body.includes('<title></title>'), 'title should be empty when vkm:name absent');
|
||
assert.ok(ctx._res.body.includes('<p>No title</p>'));
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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');
|
||
});
|
||
});
|