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 () => {
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
async function oidcAuthFlow() {
// Validate required kme_CSA_settings fields
const requiredFields = ['tokenUrl', 'username', 'password', 'clientId', 'scope'];
for (const field of requiredFields) {
if (!kme_CSA_settings[field]) {
throw new Error('missing required field: ' + field);
}
}
const missingField = kmeContentSourceAdapterHelpers.validateSettings(
kme_CSA_settings,
['tokenUrl', 'username', 'password', 'clientId', 'scope'],
);
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.end('Authorized');
}
@@ -98,33 +19,29 @@
// Sitemap flow — GET /sitemap.xml
// ---------------------------------------------------------------------------
async function sitemapFlow() {
// Settings validation guard (FR-011, R-005)
const requiredSitemapFields = ['searchApiBaseUrl', 'tenant', 'proxyBaseUrl'];
for (const field of requiredSitemapFields) {
if (!kme_CSA_settings[field]) {
console.error({ message: 'Sitemap config error', missingField: field });
const missingSitemapField = kmeContentSourceAdapterHelpers.validateSettings(
kme_CSA_settings,
['searchApiBaseUrl', 'tenant', 'proxyBaseUrl'],
);
if (missingSitemapField) {
console.error({ message: 'Sitemap config error', missingField: missingSitemapField });
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Configuration error: missing required field: ' + field);
res.end('Configuration error: missing required field: ' + missingSitemapField);
return;
}
}
const { searchApiBaseUrl, tenant, proxyBaseUrl } = kme_CSA_settings;
// Also validate OIDC fields before attempting token fetch
const requiredOidcFields = ['tokenUrl', 'username', 'password', 'clientId', 'scope'];
for (const field of requiredOidcFields) {
if (!kme_CSA_settings[field]) {
throw new Error('missing required field: ' + field);
}
}
const missingOidcField = kmeContentSourceAdapterHelpers.validateSettings(
kme_CSA_settings,
['tokenUrl', 'username', 'password', 'clientId', 'scope'],
);
if (missingOidcField) throw new Error('missing required field: ' + missingOidcField);
try {
// Obtain valid token (shared cache + stampede guard)
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`;
console.info({ message: 'Sitemap flow: calling search API', url: searchUrl });
const searchResponse = await axios.get(searchUrl, {
@@ -132,24 +49,10 @@
timeout: 10000,
});
// Extract vkm:SearchResultItemFragment objects from two-level hydra:member structure:
// 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'] ?? []);
const items = kmeContentSourceAdapterHelpers.extractHydraItems(searchResponse.data);
console.debug({ message: 'Sitemap flow: items received', count: items.length });
// Build sitemap XML (R-003, FR-008)
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 });
const xml = kmeContentSourceAdapterHelpers.buildSitemapXml(items, proxyBaseUrl);
console.info({ message: 'Sitemap flow: sending response', items: items.length });
res.writeHead(200, { 'Content-Type': 'application/xml' });
res.end(xml);

View File

@@ -15,6 +15,16 @@ const proxyPath = join(__dirname, '../../src/proxyScripts/kmeContentSourceAdapte
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 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.
* @param {number} statusCode
@@ -78,19 +88,18 @@ describe('proxy HTTP contract: 200 OK', () => {
try {
const res = makeRes();
const ctx = vm.createContext({
URLSearchParams,
console,
axios,
xmlBuilder,
redis: makeRedisFake(),
kme_CSA_settings: {
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,
});
@@ -116,19 +125,18 @@ describe('proxy HTTP contract: 401 Unauthorized', () => {
try {
const res = makeRes();
const ctx = vm.createContext({
URLSearchParams,
console,
axios,
xmlBuilder,
redis: makeRedisFake(),
kme_CSA_settings: {
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,
});
@@ -160,13 +168,7 @@ describe('sitemap endpoint', () => {
redis.hSet('authorization', 'expiry', '9999999999');
const res = makeRes();
const ctx = vm.createContext({
URLSearchParams,
console,
axios,
xmlBuilder,
redis,
kme_CSA_settings: {
const kme_CSA_settings = {
tokenUrl: tokenUrl ?? 'http://127.0.0.1:1', // not used (cache hit)
username: 'user',
password: 'pass',
@@ -175,7 +177,11 @@ describe('sitemap endpoint', () => {
searchApiBaseUrl: searchUrl,
tenant: 'test',
proxyBaseUrl: 'https://proxy.example.com',
},
};
const deps = { URLSearchParams, console, axios, xmlBuilder, redis, kme_CSA_settings };
const ctx = vm.createContext({
...deps,
kmeContentSourceAdapterHelpers: makeHelpers(deps),
req: { url: '/sitemap.xml', method: 'GET', headers: {} },
res,
});
@@ -273,19 +279,18 @@ describe('non-sitemap endpoint (regression)', () => {
try {
const res = makeRes();
const ctx = vm.createContext({
URLSearchParams,
console,
axios,
xmlBuilder,
redis: makeRedisFake(),
kme_CSA_settings: {
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,
});

View File

@@ -13,6 +13,19 @@ const proxyPath = join(__dirname, '../../src/proxyScripts/kmeContentSourceAdapte
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
@@ -43,7 +56,7 @@ function makeContext(t, overrides = {}) {
get headers() { return headers; },
};
const kme_CSA_settings = {
const defaultSettings = {
tokenUrl: 'https://auth.example.com/token',
username: 'testuser',
password: 'testpass',
@@ -51,22 +64,39 @@ function makeContext(t, overrides = {}) {
scope: 'openid',
};
const axiosMock = {
const defaultAxiosMock = {
post: t.mock.fn(async () => ({
data: { id_token: 'mock-token', expires_in: 9_999_999_999 },
})),
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({
URLSearchParams,
console,
axios: axiosMock,
axios: resolvedAxios,
redis,
kme_CSA_settings,
kme_CSA_settings: defaultSettings,
xmlBuilder,
kmeContentSourceAdapterHelpers,
req: { url: '/', method: 'GET', headers: {} },
res,
...overrides,
@@ -76,7 +106,7 @@ function makeContext(t, overrides = {}) {
ctx._redis = redis;
ctx._res = res;
ctx._store = _store;
ctx._axios = axiosMock;
ctx._axios = resolvedAxios;
return ctx;
}
@@ -291,15 +321,23 @@ describe('stampede guard', () => {
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, xmlBuilder,
});
const ctx1 = vm.createContext({
URLSearchParams, console, axios: sharedAxios,
redis, kme_CSA_settings, xmlBuilder,
kmeContentSourceAdapterHelpers: sharedHelpers,
req: { url: '/', method: 'GET', headers: {} },
res: res1,
});
const ctx2 = vm.createContext({
URLSearchParams, console, axios: sharedAxios,
redis, kme_CSA_settings, xmlBuilder,
kmeContentSourceAdapterHelpers: sharedHelpers,
req: { url: '/', method: 'GET', headers: {} },
res: res2,
});