Files
kme_content_adapter/tests/unit/proxy.test.js
Peter.Morton d1563e8190 feat: sitemap pagination, HTML wrapper, and title from vkm:name
- 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>
2026-04-23 19:07:06 -05:00

849 lines
32 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 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');
});
});