refactor: extract helpers into kmeContentSourceAdapterHelpers.js

Move getValidToken, validateSettings, extractHydraItems, and buildSitemapXml
out of the proxy IIFE into src/globalVariables/kmeContentSourceAdapterHelpers.js
following the literal function body pattern (auto-loaded by server.js, injected
as 'kmeContentSourceAdapterHelpers' into VM context).

oidcAuthFlow() and sitemapFlow() remain in the proxy script as they own req/res.

Update unit and contract tests to evaluate the helpers file with the same mock
dependencies used in each VM context, ensuring error-throwing axios overrides
are correctly seen by the helpers' closures.

All 31 tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-22 22:21:00 -05:00
parent 979521800c
commit 07c3cc72cc
4 changed files with 251 additions and 177 deletions

View File

@@ -0,0 +1,128 @@
// Helpers for kmeContentSourceAdapter.js
// This file is the literal body of a function — no imports or exports.
// server.js wraps and executes it as: (function() { <this file> })()
// Context globals available: redis, axios, console, xmlBuilder, URLSearchParams, kme_CSA_settings
/**
* Returns the first missing required field name, or null if all present.
* @param {object} settings
* @param {string[]} requiredFields
* @returns {string|null}
*/
function validateSettings(settings, requiredFields) {
for (const field of requiredFields) {
if (!settings[field]) return field;
}
return null;
}
/**
* Extracts vkm:SearchResultItemFragment objects from the two-level hydra:member
* structure returned by the KME Knowledge Search Service:
* data["hydra:member"][n] → SearchResultItem
* data["hydra:member"][n]["hydra:member"] → SearchResultItemFragment[] (has vkm:url)
* @param {object} data response.data from the search API
* @returns {object[]}
*/
function extractHydraItems(data) {
const topMembers = data['hydra:member'] ?? [];
return topMembers.flatMap(resultItem => resultItem['hydra:member'] ?? []);
}
/**
* Builds a Sitemaps-protocol 0.9 XML document from the given items.
* Uses xmlBuilder from the enclosing VM context.
* @param {object[]} items SearchResultItemFragment objects with vkm:url
* @param {string} proxyBaseUrl base URL for <loc> values
* @returns {string} serialised XML
*/
function buildSitemapXml(items, proxyBaseUrl) {
const doc = xmlBuilder({ version: '1.0', encoding: 'UTF-8' });
const urlset = doc.ele('urlset', { xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9' });
for (const item of items) {
const vkmUrl = item['vkm:url'];
if (!vkmUrl) continue; // silently omit items with empty/missing vkm:url
const loc = `${proxyBaseUrl}?kmeURL=${encodeURIComponent(vkmUrl)}`;
urlset.ele('url').ele('loc').txt(loc).up().up();
}
return doc.end({ prettyPrint: false });
}
/**
* Obtains a valid OIDC id_token using the shared Redis cache and stampede guard.
* Closes over redis, kme_CSA_settings, axios, console, URLSearchParams from VM context.
* Throws on any failure — callers are responsible for error handling.
* @param {string} [reqUrl] used only for debug logging
* @param {string} [reqMethod] used only for debug logging
* @returns {Promise<string>} id_token
*/
async function getValidToken(reqUrl, reqMethod) {
const { tokenUrl, username, clientId, scope } = kme_CSA_settings;
console.debug({ message: 'Checking token cache', url: reqUrl, method: reqMethod });
const cachedToken = await redis.hGet('authorization', 'token');
const expiry = parseFloat(await redis.hGet('authorization', 'expiry') ?? '0');
const isValid = cachedToken !== null && Date.now() / 1000 < expiry;
if (isValid) {
console.debug({ message: 'Token cache hit', expiresIn: Math.round(expiry - Date.now() / 1000) + 's' });
return cachedToken;
}
// Stampede guard — if a fetch is already in flight, queue on it
if (kme_CSA_settings._pendingFetch && typeof kme_CSA_settings._pendingFetch.then === 'function') {
console.debug({ message: 'Token fetch in flight, queuing request' });
await kme_CSA_settings._pendingFetch;
console.debug({ message: 'Queued request unblocked, responding' });
return await redis.hGet('authorization', 'token');
}
console.info({ message: 'Token cache miss, fetching fresh token', tokenUrl });
const params = new URLSearchParams({
grant_type: 'password',
username,
password: kme_CSA_settings.password,
client_id: clientId,
scope,
});
let resolvePending;
let rejectPending;
kme_CSA_settings._pendingFetch = new Promise((resolve, reject) => {
resolvePending = resolve;
rejectPending = reject;
});
kme_CSA_settings._pendingFetch.catch(() => {});
try {
console.debug({ message: 'Requesting new token', url: tokenUrl, method: 'POST' });
const response = await axios.post(tokenUrl, params, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: 5000,
});
const { id_token, expires_in } = response.data;
if (!id_token) throw new Error('id_token missing from response');
if (!expires_in) throw new Error('expires_in missing from response');
await redis.hSet('authorization', 'token', id_token);
await redis.hSet('authorization', 'expiry', String(expires_in));
console.info({ message: 'Token fetched and cached', expiresAt: new Date(expires_in * 1000).toISOString() });
resolvePending();
return id_token;
} catch (fetchErr) {
console.error({ message: 'Token fetch failed', error: fetchErr.message, code: fetchErr.code });
rejectPending(fetchErr);
throw fetchErr;
} finally {
kme_CSA_settings._pendingFetch = null;
}
}
return {
validateSettings,
extractHydraItems,
buildSitemapXml,
getValidToken,
};

View File

@@ -1,95 +1,16 @@
(async () => { (async () => {
// ---------------------------------------------------------------------------
// Shared helper: obtain a valid OIDC id_token (cache-hit → stampede-guard →
// fetch → hSet). Throws on any failure so callers can handle the error.
// ---------------------------------------------------------------------------
async function getValidToken() {
const { tokenUrl, username, clientId, scope } = kme_CSA_settings;
// Read token cache from Redis
console.debug({ message: 'Checking token cache', url: req.url, method: req.method });
const cachedToken = await redis.hGet('authorization', 'token');
const expiry = parseFloat(await redis.hGet('authorization', 'expiry') ?? '0');
const isValid = cachedToken !== null && Date.now() / 1000 < expiry;
// Cache HIT → return immediately
if (isValid) {
console.debug({ message: 'Token cache hit', expiresIn: Math.round(expiry - Date.now() / 1000) + 's' });
return cachedToken;
}
// Stampede guard — if a fetch is already in flight, queue on it
if (kme_CSA_settings._pendingFetch && typeof kme_CSA_settings._pendingFetch.then === 'function') {
console.debug({ message: 'Token fetch in flight, queuing request' });
await kme_CSA_settings._pendingFetch;
console.debug({ message: 'Queued request unblocked, responding' });
// Re-read token from Redis after the in-flight fetch completes
return await redis.hGet('authorization', 'token');
}
// Cache MISS → fetch fresh token
console.info({ message: 'Token cache miss, fetching fresh token', tokenUrl });
const params = new URLSearchParams({
grant_type: 'password',
username,
password: kme_CSA_settings.password,
client_id: clientId,
scope,
});
// Set up stampede guard before fetching
let resolvePending;
let rejectPending;
kme_CSA_settings._pendingFetch = new Promise((resolve, reject) => {
resolvePending = resolve;
rejectPending = reject;
});
// Prevent an unhandled-rejection when no concurrent request is waiting on this promise
kme_CSA_settings._pendingFetch.catch(() => {});
try {
console.debug({ message: 'Requesting new token', url: tokenUrl, method: 'POST' });
const response = await axios.post(tokenUrl, params, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: 5000,
});
const { id_token, expires_in } = response.data;
if (!id_token) throw new Error('id_token missing from response');
if (!expires_in) throw new Error('expires_in missing from response');
// Write to Redis cache
await redis.hSet('authorization', 'token', id_token);
await redis.hSet('authorization', 'expiry', String(expires_in));
console.info({ message: 'Token fetched and cached', expiresAt: new Date(expires_in * 1000).toISOString() });
// Resolve the pending fetch promise so waiting requests can proceed
resolvePending();
return id_token;
} catch (fetchErr) {
console.error({ message: 'Token fetch failed', error: fetchErr.message, code: fetchErr.code });
rejectPending(fetchErr);
throw fetchErr;
} finally {
kme_CSA_settings._pendingFetch = null;
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// OIDC auth flow — existing non-sitemap behaviour, unchanged // OIDC auth flow — existing non-sitemap behaviour, unchanged
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function oidcAuthFlow() { async function oidcAuthFlow() {
// Validate required kme_CSA_settings fields const missingField = kmeContentSourceAdapterHelpers.validateSettings(
const requiredFields = ['tokenUrl', 'username', 'password', 'clientId', 'scope']; kme_CSA_settings,
for (const field of requiredFields) { ['tokenUrl', 'username', 'password', 'clientId', 'scope'],
if (!kme_CSA_settings[field]) { );
throw new Error('missing required field: ' + field); if (missingField) throw new Error('missing required field: ' + missingField);
}
}
await getValidToken(); await kmeContentSourceAdapterHelpers.getValidToken(req.url, req.method);
// Respond success
res.writeHead(200, { 'Content-Type': 'text/plain' }); res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Authorized'); res.end('Authorized');
} }
@@ -98,33 +19,29 @@
// Sitemap flow — GET /sitemap.xml // Sitemap flow — GET /sitemap.xml
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function sitemapFlow() { async function sitemapFlow() {
// Settings validation guard (FR-011, R-005) const missingSitemapField = kmeContentSourceAdapterHelpers.validateSettings(
const requiredSitemapFields = ['searchApiBaseUrl', 'tenant', 'proxyBaseUrl']; kme_CSA_settings,
for (const field of requiredSitemapFields) { ['searchApiBaseUrl', 'tenant', 'proxyBaseUrl'],
if (!kme_CSA_settings[field]) { );
console.error({ message: 'Sitemap config error', missingField: field }); if (missingSitemapField) {
res.writeHead(500, { 'Content-Type': 'text/plain' }); console.error({ message: 'Sitemap config error', missingField: missingSitemapField });
res.end('Configuration error: missing required field: ' + field); res.writeHead(500, { 'Content-Type': 'text/plain' });
return; res.end('Configuration error: missing required field: ' + missingSitemapField);
} return;
} }
const { searchApiBaseUrl, tenant, proxyBaseUrl } = kme_CSA_settings; const { searchApiBaseUrl, tenant, proxyBaseUrl } = kme_CSA_settings;
// Also validate OIDC fields before attempting token fetch const missingOidcField = kmeContentSourceAdapterHelpers.validateSettings(
const requiredOidcFields = ['tokenUrl', 'username', 'password', 'clientId', 'scope']; kme_CSA_settings,
for (const field of requiredOidcFields) { ['tokenUrl', 'username', 'password', 'clientId', 'scope'],
if (!kme_CSA_settings[field]) { );
throw new Error('missing required field: ' + field); if (missingOidcField) throw new Error('missing required field: ' + missingOidcField);
}
}
try { try {
// Obtain valid token (shared cache + stampede guard)
console.debug({ message: 'Sitemap flow: obtaining token', url: req.url }); console.debug({ message: 'Sitemap flow: obtaining token', url: req.url });
const token = await getValidToken(); const token = await kmeContentSourceAdapterHelpers.getValidToken(req.url, req.method);
// Call Knowledge Search Service
const searchUrl = `${searchApiBaseUrl}/${tenant}/search?query=*&size=100&category=vkm:ArticleCategory`; const searchUrl = `${searchApiBaseUrl}/${tenant}/search?query=*&size=100&category=vkm:ArticleCategory`;
console.info({ message: 'Sitemap flow: calling search API', url: searchUrl }); console.info({ message: 'Sitemap flow: calling search API', url: searchUrl });
const searchResponse = await axios.get(searchUrl, { const searchResponse = await axios.get(searchUrl, {
@@ -132,24 +49,10 @@
timeout: 10000, timeout: 10000,
}); });
// Extract vkm:SearchResultItemFragment objects from two-level hydra:member structure: const items = kmeContentSourceAdapterHelpers.extractHydraItems(searchResponse.data);
// response.data["hydra:member"] → SearchResultItem[]
// each SearchResultItem["hydra:member"] → SearchResultItemFragment[] (contains vkm:url)
const topMembers = searchResponse.data['hydra:member'] ?? [];
const items = topMembers.flatMap(resultItem => resultItem['hydra:member'] ?? []);
console.debug({ message: 'Sitemap flow: items received', count: items.length }); console.debug({ message: 'Sitemap flow: items received', count: items.length });
// Build sitemap XML (R-003, FR-008) const xml = kmeContentSourceAdapterHelpers.buildSitemapXml(items, proxyBaseUrl);
const doc = xmlBuilder({ version: '1.0', encoding: 'UTF-8' });
const urlset = doc.ele('urlset', { xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9' });
for (const item of items) {
const vkmUrl = item['vkm:url'];
if (!vkmUrl) continue; // silently omit items with empty/missing vkm:url (FR-006)
const loc = `${proxyBaseUrl}?kmeURL=${encodeURIComponent(vkmUrl)}`;
urlset.ele('url').ele('loc').txt(loc).up().up();
}
const xml = doc.end({ prettyPrint: false });
console.info({ message: 'Sitemap flow: sending response', items: items.length }); console.info({ message: 'Sitemap flow: sending response', items: items.length });
res.writeHead(200, { 'Content-Type': 'application/xml' }); res.writeHead(200, { 'Content-Type': 'application/xml' });
res.end(xml); res.end(xml);

View File

@@ -15,6 +15,16 @@ const proxyPath = join(__dirname, '../../src/proxyScripts/kmeContentSourceAdapte
const proxyCode = readFileSync(proxyPath, 'utf-8'); const proxyCode = readFileSync(proxyPath, 'utf-8');
const proxyScript = new vm.Script(proxyCode, { filename: 'kmeContentSourceAdapter.js' }); const proxyScript = new vm.Script(proxyCode, { filename: 'kmeContentSourceAdapter.js' });
const helpersPath = join(__dirname, '../../src/globalVariables/kmeContentSourceAdapterHelpers.js');
const helpersCode = readFileSync(helpersPath, 'utf-8');
const helpersWrapped = `(function() {\n${helpersCode}\n})()`;
const helpersScript = new vm.Script(helpersWrapped, { filename: 'kmeContentSourceAdapterHelpers.js' });
/** Evaluate the helpers file with the provided deps (mirrors server.js loadGlobalVariables). */
function makeHelpers(deps) {
return helpersScript.runInContext(vm.createContext(deps));
}
/** /**
* Start a minimal HTTP server that handles all requests with a fixed JSON body. * Start a minimal HTTP server that handles all requests with a fixed JSON body.
* @param {number} statusCode * @param {number} statusCode
@@ -78,19 +88,18 @@ describe('proxy HTTP contract: 200 OK', () => {
try { try {
const res = makeRes(); 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({ const ctx = vm.createContext({
URLSearchParams, ...deps,
console, kmeContentSourceAdapterHelpers: makeHelpers(deps),
axios,
xmlBuilder,
redis: makeRedisFake(),
kme_CSA_settings: {
tokenUrl: mock.url,
username: 'user',
password: 'pass',
clientId: 'client',
scope: 'openid',
},
req: { url: '/', method: 'GET', headers: {} }, req: { url: '/', method: 'GET', headers: {} },
res, res,
}); });
@@ -116,19 +125,18 @@ describe('proxy HTTP contract: 401 Unauthorized', () => {
try { try {
const res = makeRes(); 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({ const ctx = vm.createContext({
URLSearchParams, ...deps,
console, kmeContentSourceAdapterHelpers: makeHelpers(deps),
axios,
xmlBuilder,
redis: makeRedisFake(),
kme_CSA_settings: {
tokenUrl: mock.url,
username: 'bad-user',
password: 'bad-pass',
clientId: 'client',
scope: 'openid',
},
req: { url: '/', method: 'GET', headers: {} }, req: { url: '/', method: 'GET', headers: {} },
res, res,
}); });
@@ -160,22 +168,20 @@ describe('sitemap endpoint', () => {
redis.hSet('authorization', 'expiry', '9999999999'); redis.hSet('authorization', 'expiry', '9999999999');
const res = makeRes(); const res = makeRes();
const kme_CSA_settings = {
tokenUrl: tokenUrl ?? 'http://127.0.0.1:1', // not used (cache hit)
username: 'user',
password: 'pass',
clientId: 'client',
scope: 'openid',
searchApiBaseUrl: searchUrl,
tenant: 'test',
proxyBaseUrl: 'https://proxy.example.com',
};
const deps = { URLSearchParams, console, axios, xmlBuilder, redis, kme_CSA_settings };
const ctx = vm.createContext({ const ctx = vm.createContext({
URLSearchParams, ...deps,
console, kmeContentSourceAdapterHelpers: makeHelpers(deps),
axios,
xmlBuilder,
redis,
kme_CSA_settings: {
tokenUrl: tokenUrl ?? 'http://127.0.0.1:1', // not used (cache hit)
username: 'user',
password: 'pass',
clientId: 'client',
scope: 'openid',
searchApiBaseUrl: searchUrl,
tenant: 'test',
proxyBaseUrl: 'https://proxy.example.com',
},
req: { url: '/sitemap.xml', method: 'GET', headers: {} }, req: { url: '/sitemap.xml', method: 'GET', headers: {} },
res, res,
}); });
@@ -273,19 +279,18 @@ describe('non-sitemap endpoint (regression)', () => {
try { try {
const res = makeRes(); 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({ const ctx = vm.createContext({
URLSearchParams, ...deps,
console, kmeContentSourceAdapterHelpers: makeHelpers(deps),
axios,
xmlBuilder,
redis: makeRedisFake(),
kme_CSA_settings: {
tokenUrl: mock.url,
username: 'user',
password: 'pass',
clientId: 'client',
scope: 'openid',
},
req: { url: '/', method: 'GET', headers: {} }, req: { url: '/', method: 'GET', headers: {} },
res, res,
}); });

View File

@@ -13,6 +13,19 @@ const proxyPath = join(__dirname, '../../src/proxyScripts/kmeContentSourceAdapte
const proxyCode = readFileSync(proxyPath, 'utf-8'); const proxyCode = readFileSync(proxyPath, 'utf-8');
const proxyScript = new vm.Script(proxyCode, { filename: 'kmeContentSourceAdapter.js' }); 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. * Build a minimal VM context satisfying the vm-context contract.
* @param {import('node:test').TestContext} t * @param {import('node:test').TestContext} t
@@ -43,7 +56,7 @@ function makeContext(t, overrides = {}) {
get headers() { return headers; }, get headers() { return headers; },
}; };
const kme_CSA_settings = { const defaultSettings = {
tokenUrl: 'https://auth.example.com/token', tokenUrl: 'https://auth.example.com/token',
username: 'testuser', username: 'testuser',
password: 'testpass', password: 'testpass',
@@ -51,22 +64,39 @@ function makeContext(t, overrides = {}) {
scope: 'openid', scope: 'openid',
}; };
const axiosMock = { const defaultAxiosMock = {
post: t.mock.fn(async () => ({ post: t.mock.fn(async () => ({
data: { id_token: 'mock-token', expires_in: 9_999_999_999 }, data: { id_token: 'mock-token', expires_in: 9_999_999_999 },
})), })),
get: t.mock.fn(async () => ({ get: t.mock.fn(async () => ({
data: { items: [] }, 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,
xmlBuilder,
});
const ctx = vm.createContext({ const ctx = vm.createContext({
URLSearchParams, URLSearchParams,
console, console,
axios: axiosMock, axios: resolvedAxios,
redis, redis,
kme_CSA_settings, kme_CSA_settings: defaultSettings,
xmlBuilder, xmlBuilder,
kmeContentSourceAdapterHelpers,
req: { url: '/', method: 'GET', headers: {} }, req: { url: '/', method: 'GET', headers: {} },
res, res,
...overrides, ...overrides,
@@ -76,7 +106,7 @@ function makeContext(t, overrides = {}) {
ctx._redis = redis; ctx._redis = redis;
ctx._res = res; ctx._res = res;
ctx._store = _store; ctx._store = _store;
ctx._axios = axiosMock; ctx._axios = resolvedAxios;
return ctx; return ctx;
} }
@@ -291,15 +321,23 @@ describe('stampede guard', () => {
const res1 = makeRes(t); const res1 = makeRes(t);
const res2 = 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, xmlBuilder,
});
const ctx1 = vm.createContext({ const ctx1 = vm.createContext({
URLSearchParams, console, axios: sharedAxios, URLSearchParams, console, axios: sharedAxios,
redis, kme_CSA_settings, xmlBuilder, redis, kme_CSA_settings, xmlBuilder,
kmeContentSourceAdapterHelpers: sharedHelpers,
req: { url: '/', method: 'GET', headers: {} }, req: { url: '/', method: 'GET', headers: {} },
res: res1, res: res1,
}); });
const ctx2 = vm.createContext({ const ctx2 = vm.createContext({
URLSearchParams, console, axios: sharedAxios, URLSearchParams, console, axios: sharedAxios,
redis, kme_CSA_settings, xmlBuilder, redis, kme_CSA_settings, xmlBuilder,
kmeContentSourceAdapterHelpers: sharedHelpers,
req: { url: '/', method: 'GET', headers: {} }, req: { url: '/', method: 'GET', headers: {} },
res: res2, res: res2,
}); });