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:
@@ -6,7 +6,7 @@ import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import axios from 'axios';
|
||||
import { create as xmlBuilder } from 'xmlbuilder2';
|
||||
import xmlbuilder2 from 'xmlbuilder2';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -50,7 +50,6 @@ function startMockServer(statusCode, responseBody) {
|
||||
/**
|
||||
* Start a mock token server (alias for backwards compatibility).
|
||||
*/
|
||||
const startMockTokenServer = startMockServer;
|
||||
|
||||
/** Build an in-memory Redis fake. */
|
||||
function makeRedisFake() {
|
||||
@@ -79,79 +78,6 @@ function makeRes() {
|
||||
// Contract: 200 OK — successful OIDC token fetch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('proxy HTTP contract: 200 OK', () => {
|
||||
test('fresh token fetch → 200 Authorized with Content-Type text/plain', async () => {
|
||||
const mock = await startMockTokenServer(200, {
|
||||
id_token: 'contract-token',
|
||||
expires_in: 9_999_999_999,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = makeRes();
|
||||
const redis = makeRedisFake();
|
||||
const kme_CSA_settings = {
|
||||
tokenUrl: mock.url,
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
clientId: 'client',
|
||||
scope: 'openid',
|
||||
};
|
||||
const deps = { URLSearchParams, console, axios, xmlBuilder, redis, kme_CSA_settings };
|
||||
const ctx = vm.createContext({
|
||||
...deps,
|
||||
kmeContentSourceAdapterHelpers: makeHelpers(deps),
|
||||
req: { url: '/', method: 'GET', headers: {} },
|
||||
res,
|
||||
});
|
||||
|
||||
await proxyScript.runInContext(ctx);
|
||||
|
||||
assert.strictEqual(res.statusCode, 200);
|
||||
assert.strictEqual(res.body, 'Authorized');
|
||||
assert.strictEqual(res.headers['Content-Type'], 'text/plain');
|
||||
} finally {
|
||||
await mock.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Contract: 401 Unauthorized — token service returns 4xx
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('proxy HTTP contract: 401 Unauthorized', () => {
|
||||
test('token service 401 → proxy 401 with Unauthorized: prefix', async () => {
|
||||
const mock = await startMockTokenServer(401, {});
|
||||
|
||||
try {
|
||||
const res = makeRes();
|
||||
const redis = makeRedisFake();
|
||||
const kme_CSA_settings = {
|
||||
tokenUrl: mock.url,
|
||||
username: 'bad-user',
|
||||
password: 'bad-pass',
|
||||
clientId: 'client',
|
||||
scope: 'openid',
|
||||
};
|
||||
const deps = { URLSearchParams, console, axios, xmlBuilder, redis, kme_CSA_settings };
|
||||
const ctx = vm.createContext({
|
||||
...deps,
|
||||
kmeContentSourceAdapterHelpers: makeHelpers(deps),
|
||||
req: { url: '/', method: 'GET', headers: {} },
|
||||
res,
|
||||
});
|
||||
|
||||
await proxyScript.runInContext(ctx);
|
||||
|
||||
assert.strictEqual(res.statusCode, 401);
|
||||
assert.match(res.body, /^Unauthorized: .+/);
|
||||
assert.strictEqual(res.headers['Content-Type'], 'text/plain');
|
||||
} finally {
|
||||
await mock.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Contract: sitemap endpoint (T005, T012)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -178,7 +104,7 @@ describe('sitemap endpoint', () => {
|
||||
tenant: 'test',
|
||||
proxyBaseUrl: 'https://proxy.example.com',
|
||||
};
|
||||
const deps = { URLSearchParams, console, axios, xmlBuilder, redis, kme_CSA_settings };
|
||||
const deps = { URLSearchParams, URL, console, axios, xmlbuilder2, redis, kme_CSA_settings };
|
||||
const ctx = vm.createContext({
|
||||
...deps,
|
||||
kmeContentSourceAdapterHelpers: makeHelpers(deps),
|
||||
@@ -266,41 +192,131 @@ describe('sitemap endpoint', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Non-sitemap endpoint regression (T010)
|
||||
// Content fetch: happy path contract test — T008
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('non-sitemap endpoint (regression)', () => {
|
||||
test('GET / with valid OIDC credentials → 200 Authorized', async () => {
|
||||
const mock = await startMockTokenServer(200, {
|
||||
id_token: 'regression-token',
|
||||
expires_in: 9_999_999_999,
|
||||
});
|
||||
describe('content fetch: happy path', () => {
|
||||
test('GET /?kmeURL=<mock-server> → 200 text/html with article body (SC-001 < 11s)', async () => {
|
||||
// Mock content server returning a valid article JSON-LD response
|
||||
const contentMock = await startMockServer(200, { 'vkm:articleBody': '<p>Contract test article</p>' });
|
||||
|
||||
try {
|
||||
const res = makeRes();
|
||||
const redis = makeRedisFake();
|
||||
// Pre-seed token — no real token exchange needed
|
||||
await redis.hSet('authorization', 'token', 'content-contract-token');
|
||||
await redis.hSet('authorization', 'expiry', '9999999999');
|
||||
|
||||
const res = makeRes();
|
||||
const kme_CSA_settings = {
|
||||
tokenUrl: mock.url,
|
||||
tokenUrl: 'http://127.0.0.1:1', // unreachable — not used (cache hit)
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
clientId: 'client',
|
||||
scope: 'openid',
|
||||
};
|
||||
const deps = { URLSearchParams, console, axios, xmlBuilder, redis, kme_CSA_settings };
|
||||
const deps = { URLSearchParams, URL, console, axios, xmlbuilder2, redis, kme_CSA_settings };
|
||||
const ctx = vm.createContext({
|
||||
...deps,
|
||||
kmeContentSourceAdapterHelpers: makeHelpers(deps),
|
||||
req: { url: '/', method: 'GET', headers: {} },
|
||||
req: { url: `/?kmeURL=${encodeURIComponent(contentMock.url)}`, method: 'GET', headers: {} },
|
||||
res,
|
||||
});
|
||||
|
||||
const start = Date.now();
|
||||
await proxyScript.runInContext(ctx);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
assert.strictEqual(res.statusCode, 200);
|
||||
assert.strictEqual(res.body, 'Authorized');
|
||||
assert.ok(
|
||||
res.headers['Content-Type'].startsWith('text/html'),
|
||||
`Content-Type was: ${res.headers['Content-Type']}`,
|
||||
);
|
||||
assert.strictEqual(res.body, '<p>Contract test article</p>');
|
||||
assert.ok(elapsed < 11000, `Round-trip should be under 11 s, took ${elapsed}ms`);
|
||||
} finally {
|
||||
await contentMock.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content fetch: error handling contract tests — T012
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('content fetch: error handling', () => {
|
||||
/** Build a content-fetch VM context wired to the given upstream URL. */
|
||||
function makeContentFetchCtx(contentUrl) {
|
||||
const redis = makeRedisFake();
|
||||
// Pre-seed token so no real auth server is needed
|
||||
redis.hSet('authorization', 'token', 'content-contract-token');
|
||||
redis.hSet('authorization', 'expiry', '9999999999');
|
||||
|
||||
const res = makeRes();
|
||||
const kme_CSA_settings = {
|
||||
tokenUrl: 'http://127.0.0.1:1', // not used (cache hit)
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
clientId: 'client',
|
||||
scope: 'openid',
|
||||
};
|
||||
const deps = { URLSearchParams, URL, console, axios, xmlbuilder2, redis, kme_CSA_settings };
|
||||
const ctx = vm.createContext({
|
||||
...deps,
|
||||
kmeContentSourceAdapterHelpers: makeHelpers(deps),
|
||||
req: { url: `/?kmeURL=${encodeURIComponent(contentUrl)}`, method: 'GET', headers: {} },
|
||||
res,
|
||||
});
|
||||
ctx._res = res;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
test('mock upstream returns 404 → proxy returns 404', async () => {
|
||||
const mock = await startMockServer(404, { error: 'Not Found' });
|
||||
|
||||
try {
|
||||
const ctx = makeContentFetchCtx(mock.url);
|
||||
await proxyScript.runInContext(ctx);
|
||||
assert.strictEqual(ctx._res.statusCode, 404, `body was: ${ctx._res.body}`);
|
||||
} finally {
|
||||
await mock.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('mock upstream returns 503 → proxy returns 502', async () => {
|
||||
const mock = await startMockServer(503, { error: 'Service Unavailable' });
|
||||
|
||||
try {
|
||||
const ctx = makeContentFetchCtx(mock.url);
|
||||
await proxyScript.runInContext(ctx);
|
||||
assert.strictEqual(ctx._res.statusCode, 502, `body was: ${ctx._res.body}`);
|
||||
} finally {
|
||||
await mock.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('server accepts connection but never responds → proxy returns 502 within 12s', async () => {
|
||||
const hangServer = await new Promise((resolve, reject) => {
|
||||
const s = http.createServer(() => { /* intentionally hang — never respond */ });
|
||||
s.listen(0, '127.0.0.1', () => {
|
||||
const { port } = s.address();
|
||||
const close = () => new Promise((res, rej) => s.close(err => err ? rej(err) : res()));
|
||||
resolve({ server: s, url: `http://127.0.0.1:${port}`, close });
|
||||
});
|
||||
s.once('error', reject);
|
||||
});
|
||||
|
||||
try {
|
||||
const ctx = makeContentFetchCtx(hangServer.url);
|
||||
const start = Date.now();
|
||||
await proxyScript.runInContext(ctx);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
assert.strictEqual(ctx._res.statusCode, 502, `body was: ${ctx._res.body}`);
|
||||
assert.ok(elapsed < 12000, `Should respond within 12s, took ${elapsed}ms`);
|
||||
} finally {
|
||||
await hangServer.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user