From 07c3cc72ccbccb993c6ce07d4a2162ede368a3df Mon Sep 17 00:00:00 2001 From: "Peter.Morton" Date: Wed, 22 Apr 2026 22:21:00 -0500 Subject: [PATCH] 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> --- .../kmeContentSourceAdapterHelpers.js | 128 ++++++++++++++++ src/proxyScripts/kmeContentSourceAdapter.js | 143 +++--------------- tests/contract/proxy-http.test.js | 107 ++++++------- tests/unit/proxy.test.js | 50 +++++- 4 files changed, 251 insertions(+), 177 deletions(-) create mode 100644 src/globalVariables/kmeContentSourceAdapterHelpers.js diff --git a/src/globalVariables/kmeContentSourceAdapterHelpers.js b/src/globalVariables/kmeContentSourceAdapterHelpers.js new file mode 100644 index 0000000..0483f8d --- /dev/null +++ b/src/globalVariables/kmeContentSourceAdapterHelpers.js @@ -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() { })() +// 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 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} 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, +}; diff --git a/src/proxyScripts/kmeContentSourceAdapter.js b/src/proxyScripts/kmeContentSourceAdapter.js index 00be392..b6197dd 100644 --- a/src/proxyScripts/kmeContentSourceAdapter.js +++ b/src/proxyScripts/kmeContentSourceAdapter.js @@ -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 }); - res.writeHead(500, { 'Content-Type': 'text/plain' }); - res.end('Configuration error: missing required field: ' + field); - return; - } + 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: ' + 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); diff --git a/tests/contract/proxy-http.test.js b/tests/contract/proxy-http.test.js index 213aa03..d1b9478 100644 --- a/tests/contract/proxy-http.test.js +++ b/tests/contract/proxy-http.test.js @@ -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 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({ - URLSearchParams, - console, - axios, - xmlBuilder, - redis: makeRedisFake(), - kme_CSA_settings: { - tokenUrl: mock.url, - username: 'user', - password: 'pass', - clientId: 'client', - scope: 'openid', - }, + ...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 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({ - URLSearchParams, - console, - axios, - xmlBuilder, - redis: makeRedisFake(), - kme_CSA_settings: { - tokenUrl: mock.url, - username: 'bad-user', - password: 'bad-pass', - clientId: 'client', - scope: 'openid', - }, + ...deps, + kmeContentSourceAdapterHelpers: makeHelpers(deps), req: { url: '/', method: 'GET', headers: {} }, res, }); @@ -160,22 +168,20 @@ describe('sitemap endpoint', () => { redis.hSet('authorization', 'expiry', '9999999999'); 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({ - URLSearchParams, - console, - 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', - }, + ...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 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({ - URLSearchParams, - console, - axios, - xmlBuilder, - redis: makeRedisFake(), - kme_CSA_settings: { - tokenUrl: mock.url, - username: 'user', - password: 'pass', - clientId: 'client', - scope: 'openid', - }, + ...deps, + kmeContentSourceAdapterHelpers: makeHelpers(deps), req: { url: '/', method: 'GET', headers: {} }, res, }); diff --git a/tests/unit/proxy.test.js b/tests/unit/proxy.test.js index df29eac..eb278c9 100644 --- a/tests/unit/proxy.test.js +++ b/tests/unit/proxy.test.js @@ -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, });