feat: content fetch, sitemap fixes, remove oidcAuthFlow
- Add contentFetchFlow() to proxy (FR-001 through FR-012) - Add extractArticleBody() helper with vkm:articleBody / articleBody fallback - Dynamic proxyBaseUrl derivation from x-forwarded-proto/host headers - Forward query/size/category params on /sitemap.xml requests - Add Accept: application/ld+json header to content API calls - Remove oidcAuthFlow() - unmatched requests now return 404 Not Found - Fix xmlbuilder2 import: default import, call as xmlbuilder2.create(...) - Version bump 0.2.0 → 0.3.0 - 45/45 tests passing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import vm from 'node:vm';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { create as xmlBuilder } from 'xmlbuilder2';
|
||||
import xmlbuilder2 from 'xmlbuilder2';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -86,16 +86,17 @@ function makeContext(t, overrides = {}) {
|
||||
axios: resolvedAxios,
|
||||
redis,
|
||||
kme_CSA_settings: resolvedSettings,
|
||||
xmlBuilder,
|
||||
xmlbuilder2,
|
||||
});
|
||||
|
||||
const ctx = vm.createContext({
|
||||
URLSearchParams,
|
||||
URL,
|
||||
console,
|
||||
axios: resolvedAxios,
|
||||
redis,
|
||||
kme_CSA_settings: defaultSettings,
|
||||
xmlBuilder,
|
||||
xmlbuilder2,
|
||||
kmeContentSourceAdapterHelpers,
|
||||
req: { url: '/', method: 'GET', headers: {} },
|
||||
res,
|
||||
@@ -124,156 +125,6 @@ async function runScript(ctx) {
|
||||
// User Story 1 — Successful Authenticated Request
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('US1: successful authenticated request', () => {
|
||||
test('cache miss → fresh fetch → 200 OK', async (t) => {
|
||||
const ctx = makeContext(t);
|
||||
await runScript(ctx);
|
||||
|
||||
assert.strictEqual(ctx._res.statusCode, 200);
|
||||
assert.strictEqual(ctx._res.body, 'Authorized');
|
||||
assert.strictEqual(ctx._axios.post.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
test('cache hit → no fetch → 200 OK', async (t) => {
|
||||
const ctx = makeContext(t, {});
|
||||
// Pre-seed Redis store via the fake
|
||||
ctx._store['authorization:token'] = 'cached-tok';
|
||||
ctx._store['authorization:expiry'] = '9999999999';
|
||||
|
||||
await runScript(ctx);
|
||||
|
||||
assert.strictEqual(ctx._res.statusCode, 200);
|
||||
assert.strictEqual(ctx._res.body, 'Authorized');
|
||||
assert.strictEqual(ctx._axios.post.mock.calls.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User Story 2 — Token Expiry and Refresh
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('US2: token expiry and refresh', () => {
|
||||
test('expired token → re-fetch → 200 OK', async (t) => {
|
||||
const ctx = makeContext(t);
|
||||
ctx._store['authorization:token'] = 'old-tok';
|
||||
ctx._store['authorization:expiry'] = '1'; // epoch far in the past
|
||||
|
||||
await runScript(ctx);
|
||||
|
||||
assert.strictEqual(ctx._axios.post.mock.calls.length, 1, 'should re-fetch');
|
||||
assert.strictEqual(ctx._res.statusCode, 200);
|
||||
assert.strictEqual(ctx._res.body, 'Authorized');
|
||||
|
||||
// New token should have been written to Redis
|
||||
const hSetCalls = ctx._redis.hSet.mock.calls;
|
||||
assert.ok(hSetCalls.length >= 2, 'hSet should be called for token and expiry');
|
||||
});
|
||||
|
||||
test('future expiry → no re-fetch → 200 OK', async (t) => {
|
||||
const ctx = makeContext(t);
|
||||
ctx._store['authorization:token'] = 'fresh-tok';
|
||||
ctx._store['authorization:expiry'] = '9999999999';
|
||||
|
||||
await runScript(ctx);
|
||||
|
||||
assert.strictEqual(ctx._axios.post.mock.calls.length, 0, 'should not re-fetch');
|
||||
assert.strictEqual(ctx._res.statusCode, 200);
|
||||
assert.strictEqual(ctx._res.body, 'Authorized');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User Story 3 — Authentication Failure Handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('US3: authentication failure handling', () => {
|
||||
test('HTTP 401 from token service → 401 Unauthorized: HTTP 401', async (t) => {
|
||||
const axiosError = Object.assign(new Error('Request failed with status code 401'), {
|
||||
response: { status: 401 },
|
||||
});
|
||||
const ctx = makeContext(t, {
|
||||
axios: { post: t.mock.fn(async () => { throw axiosError; }), get: t.mock.fn() },
|
||||
});
|
||||
|
||||
await runScript(ctx);
|
||||
|
||||
assert.strictEqual(ctx._res.statusCode, 401);
|
||||
assert.strictEqual(ctx._res.body, 'Unauthorized: HTTP 401');
|
||||
});
|
||||
|
||||
test('timeout (ECONNABORTED) → 401 Unauthorized: token service timeout', async (t) => {
|
||||
const axiosError = Object.assign(new Error('timeout'), { code: 'ECONNABORTED' });
|
||||
const ctx = makeContext(t, {
|
||||
axios: { post: t.mock.fn(async () => { throw axiosError; }), get: t.mock.fn() },
|
||||
});
|
||||
|
||||
await runScript(ctx);
|
||||
|
||||
assert.strictEqual(ctx._res.statusCode, 401);
|
||||
assert.strictEqual(ctx._res.body, 'Unauthorized: token service timeout');
|
||||
});
|
||||
|
||||
test('timeout (ERR_CANCELED) → 401 Unauthorized: token service timeout', async (t) => {
|
||||
const axiosError = Object.assign(new Error('canceled'), { code: 'ERR_CANCELED' });
|
||||
const ctx = makeContext(t, {
|
||||
axios: { post: t.mock.fn(async () => { throw axiosError; }), get: t.mock.fn() },
|
||||
});
|
||||
|
||||
await runScript(ctx);
|
||||
|
||||
assert.strictEqual(ctx._res.statusCode, 401);
|
||||
assert.strictEqual(ctx._res.body, 'Unauthorized: token service timeout');
|
||||
});
|
||||
|
||||
test('missing id_token in response → 401 Unauthorized: id_token missing from response', async (t) => {
|
||||
const ctx = makeContext(t, {
|
||||
axios: {
|
||||
post: t.mock.fn(async () => ({ data: { expires_in: 9999 } })),
|
||||
get: t.mock.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
await runScript(ctx);
|
||||
|
||||
assert.strictEqual(ctx._res.statusCode, 401);
|
||||
assert.strictEqual(ctx._res.body, 'Unauthorized: id_token missing from response');
|
||||
});
|
||||
|
||||
test('missing expires_in in response → 401 Unauthorized: expires_in missing from response', async (t) => {
|
||||
const ctx = makeContext(t, {
|
||||
axios: {
|
||||
post: t.mock.fn(async () => ({ data: { id_token: 'a-token' } })),
|
||||
get: t.mock.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
await runScript(ctx);
|
||||
|
||||
assert.strictEqual(ctx._res.statusCode, 401);
|
||||
assert.strictEqual(ctx._res.body, 'Unauthorized: expires_in missing from response');
|
||||
});
|
||||
|
||||
test('missing tokenUrl in kme_CSA_settings → 401 missing required field: tokenUrl', async (t) => {
|
||||
const ctx = makeContext(t);
|
||||
ctx.kme_CSA_settings.tokenUrl = '';
|
||||
|
||||
await runScript(ctx);
|
||||
|
||||
assert.strictEqual(ctx._res.statusCode, 401);
|
||||
assert.strictEqual(ctx._res.body, 'Unauthorized: missing required field: tokenUrl');
|
||||
});
|
||||
|
||||
test('missing username in kme_CSA_settings → 401 missing required field: username', async (t) => {
|
||||
const ctx = makeContext(t);
|
||||
ctx.kme_CSA_settings.username = undefined;
|
||||
|
||||
await runScript(ctx);
|
||||
|
||||
assert.strictEqual(ctx._res.statusCode, 401);
|
||||
assert.strictEqual(ctx._res.body, 'Unauthorized: missing required field: username');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 6 — Stampede Guard (FR-013)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -299,12 +150,15 @@ describe('stampede guard', () => {
|
||||
hGet: t.mock.fn(async (key, field) => _store[`${key}:${field}`] ?? null),
|
||||
};
|
||||
|
||||
// Slow axios mock — 50ms delay before returning token
|
||||
// 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 sharedAxios = { post: mockAxiosPost, get: t.mock.fn() };
|
||||
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) {
|
||||
@@ -324,21 +178,22 @@ describe('stampede guard', () => {
|
||||
// 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, xmlBuilder,
|
||||
redis, kme_CSA_settings, xmlbuilder2,
|
||||
});
|
||||
|
||||
const kmeURL = encodeURIComponent('https://kme.example.com/article/1');
|
||||
const ctx1 = vm.createContext({
|
||||
URLSearchParams, console, axios: sharedAxios,
|
||||
redis, kme_CSA_settings, xmlBuilder,
|
||||
URLSearchParams, URL, console, axios: sharedAxios,
|
||||
redis, kme_CSA_settings, xmlbuilder2,
|
||||
kmeContentSourceAdapterHelpers: sharedHelpers,
|
||||
req: { url: '/', method: 'GET', headers: {} },
|
||||
req: { url: `/?kmeURL=${kmeURL}`, method: 'GET', headers: {} },
|
||||
res: res1,
|
||||
});
|
||||
const ctx2 = vm.createContext({
|
||||
URLSearchParams, console, axios: sharedAxios,
|
||||
redis, kme_CSA_settings, xmlBuilder,
|
||||
URLSearchParams, URL, console, axios: sharedAxios,
|
||||
redis, kme_CSA_settings, xmlbuilder2,
|
||||
kmeContentSourceAdapterHelpers: sharedHelpers,
|
||||
req: { url: '/', method: 'GET', headers: {} },
|
||||
req: { url: `/?kmeURL=${kmeURL}`, method: 'GET', headers: {} },
|
||||
res: res2,
|
||||
});
|
||||
|
||||
@@ -350,8 +205,8 @@ describe('stampede guard', () => {
|
||||
assert.strictEqual(mockAxiosPost.mock.calls.length, 1, 'stampede guard: only one fetch');
|
||||
assert.strictEqual(res1.statusCode, 200);
|
||||
assert.strictEqual(res2.statusCode, 200);
|
||||
assert.strictEqual(res1.body, 'Authorized');
|
||||
assert.strictEqual(res2.body, 'Authorized');
|
||||
assert.ok(res1.body.includes('<p>Article</p>'));
|
||||
assert.ok(res2.body.includes('<p>Article</p>'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -362,12 +217,11 @@ describe('stampede guard', () => {
|
||||
describe('sitemap flow', () => {
|
||||
function makeSitemapContext(t, axiosGetImpl, settingsOverrides = {}) {
|
||||
const ctx = makeContext(t, {
|
||||
req: { url: '/sitemap.xml', method: 'GET', headers: {} },
|
||||
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';
|
||||
ctx.kme_CSA_settings.proxyBaseUrl = 'https://proxy.example.com';
|
||||
Object.assign(ctx.kme_CSA_settings, settingsOverrides);
|
||||
|
||||
// Pre-seed token cache so getValidToken() returns immediately
|
||||
@@ -399,11 +253,11 @@ describe('sitemap flow', () => {
|
||||
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>'),
|
||||
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>'),
|
||||
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',
|
||||
);
|
||||
});
|
||||
@@ -488,71 +342,356 @@ describe('sitemap flow', () => {
|
||||
assert.strictEqual(ctx._res.statusCode, 500);
|
||||
assert.strictEqual(ctx._res.body, 'Configuration error: missing required field: tenant');
|
||||
});
|
||||
});
|
||||
|
||||
test('missing proxyBaseUrl → 500 Configuration error', async (t) => {
|
||||
const ctx = makeSitemapContext(t, null, { proxyBaseUrl: undefined });
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractArticleBody helper — T004
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
await runScript(ctx);
|
||||
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);
|
||||
|
||||
assert.strictEqual(ctx._res.statusCode, 500);
|
||||
assert.strictEqual(ctx._res.body, 'Configuration error: missing required field: proxyBaseUrl');
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Non-sitemap URL routing — regression guard (T009)
|
||||
// US-content-fetch: happy path — T007
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('non-sitemap URL routing', () => {
|
||||
test('cache hit → no fetch → 200 Authorized', async (t) => {
|
||||
describe('US-content-fetch: happy path', () => {
|
||||
test('cached token + valid article response → 200 text/html with body', 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:articleBody': '<p>Hello</p>' } })),
|
||||
};
|
||||
const ctx = makeContext(t, {
|
||||
req: { url: '/', method: 'GET', headers: {} },
|
||||
axios: {
|
||||
post: t.mock.fn(async () => { throw new Error('should not be called'); }),
|
||||
get: t.mock.fn(),
|
||||
},
|
||||
req: { url: '/?kmeURL=https://kme.example.com/content/article/123', method: 'GET', headers: {} },
|
||||
axios: contentAxios,
|
||||
});
|
||||
// Pre-seed valid token
|
||||
ctx._store['authorization:token'] = 'cached-tok';
|
||||
// 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.strictEqual(ctx._res.body, 'Authorized');
|
||||
// axios.post was set to throw, so if it was called the test would fail
|
||||
assert.ok(
|
||||
ctx._res.headers['Content-Type'].startsWith('text/html'),
|
||||
`Content-Type was: ${ctx._res.headers['Content-Type']}`,
|
||||
);
|
||||
assert.strictEqual(ctx._res.body, '<p>Hello</p>');
|
||||
assert.strictEqual(contentAxios.post.mock.calls.length, 0, 'should not re-fetch token on cache hit');
|
||||
});
|
||||
|
||||
test('cache miss → fresh fetch → 200 Authorized', async (t) => {
|
||||
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:articleBody': '<p>Hello</p>' } })),
|
||||
};
|
||||
const ctx = makeContext(t, {
|
||||
req: { url: '/', method: 'GET', headers: {} },
|
||||
req: { url: '/?kmeURL=https://kme.example.com/content/article/123', method: 'GET', headers: {} },
|
||||
axios: contentAxios,
|
||||
});
|
||||
// No pre-seeded token → cache miss
|
||||
// No pre-seeded token → cache miss → axios.post will be called for fresh token
|
||||
|
||||
await runScript(ctx);
|
||||
|
||||
assert.strictEqual(ctx._res.statusCode, 200);
|
||||
assert.strictEqual(ctx._res.body, 'Authorized');
|
||||
// Verify token was written to Redis
|
||||
const hSetCalls = ctx._redis.hSet.mock.calls;
|
||||
const tokenCall = hSetCalls.find(c => c.arguments[0] === 'authorization' && c.arguments[1] === 'token');
|
||||
assert.ok(tokenCall, 'hSet should be called with token');
|
||||
assert.strictEqual(tokenCall.arguments[2], 'mock-token');
|
||||
assert.ok(
|
||||
ctx._res.headers['Content-Type'].startsWith('text/html'),
|
||||
`Content-Type was: ${ctx._res.headers['Content-Type']}`,
|
||||
);
|
||||
assert.strictEqual(ctx._res.body, '<p>Hello</p>');
|
||||
assert.strictEqual(contentAxios.post.mock.calls.length, 1, 'should have fetched fresh token');
|
||||
});
|
||||
});
|
||||
|
||||
test('token service down (ECONNABORTED) → 401 Unauthorized', async (t) => {
|
||||
const timeoutErr = Object.assign(new Error('timeout'), { code: 'ECONNABORTED' });
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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: '/', method: 'GET', headers: {} },
|
||||
axios: {
|
||||
post: t.mock.fn(async () => { throw timeoutErr; }),
|
||||
get: t.mock.fn(),
|
||||
},
|
||||
req: { url: '/?someOtherParam=value', method: 'GET', headers: {} },
|
||||
});
|
||||
|
||||
await runScript(ctx);
|
||||
|
||||
assert.strictEqual(ctx._res.statusCode, 401);
|
||||
assert.ok(ctx._res.body.startsWith('Unauthorized:'), `body was: ${ctx._res.body}`);
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user