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:
128
src/globalVariables/kmeContentSourceAdapterHelpers.js
Normal file
128
src/globalVariables/kmeContentSourceAdapterHelpers.js
Normal 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,
|
||||||
|
};
|
||||||
@@ -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) {
|
||||||
|
console.error({ message: 'Sitemap config error', missingField: missingSitemapField });
|
||||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||||
res.end('Configuration error: missing required field: ' + field);
|
res.end('Configuration error: missing required field: ' + missingSitemapField);
|
||||||
return;
|
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);
|
||||||
|
|||||||
@@ -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 ctx = vm.createContext({
|
const redis = makeRedisFake();
|
||||||
URLSearchParams,
|
const kme_CSA_settings = {
|
||||||
console,
|
|
||||||
axios,
|
|
||||||
xmlBuilder,
|
|
||||||
redis: makeRedisFake(),
|
|
||||||
kme_CSA_settings: {
|
|
||||||
tokenUrl: mock.url,
|
tokenUrl: mock.url,
|
||||||
username: 'user',
|
username: 'user',
|
||||||
password: 'pass',
|
password: 'pass',
|
||||||
clientId: 'client',
|
clientId: 'client',
|
||||||
scope: 'openid',
|
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: {} },
|
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 ctx = vm.createContext({
|
const redis = makeRedisFake();
|
||||||
URLSearchParams,
|
const kme_CSA_settings = {
|
||||||
console,
|
|
||||||
axios,
|
|
||||||
xmlBuilder,
|
|
||||||
redis: makeRedisFake(),
|
|
||||||
kme_CSA_settings: {
|
|
||||||
tokenUrl: mock.url,
|
tokenUrl: mock.url,
|
||||||
username: 'bad-user',
|
username: 'bad-user',
|
||||||
password: 'bad-pass',
|
password: 'bad-pass',
|
||||||
clientId: 'client',
|
clientId: 'client',
|
||||||
scope: 'openid',
|
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: {} },
|
req: { url: '/', method: 'GET', headers: {} },
|
||||||
res,
|
res,
|
||||||
});
|
});
|
||||||
@@ -160,13 +168,7 @@ describe('sitemap endpoint', () => {
|
|||||||
redis.hSet('authorization', 'expiry', '9999999999');
|
redis.hSet('authorization', 'expiry', '9999999999');
|
||||||
|
|
||||||
const res = makeRes();
|
const res = makeRes();
|
||||||
const ctx = vm.createContext({
|
const kme_CSA_settings = {
|
||||||
URLSearchParams,
|
|
||||||
console,
|
|
||||||
axios,
|
|
||||||
xmlBuilder,
|
|
||||||
redis,
|
|
||||||
kme_CSA_settings: {
|
|
||||||
tokenUrl: tokenUrl ?? 'http://127.0.0.1:1', // not used (cache hit)
|
tokenUrl: tokenUrl ?? 'http://127.0.0.1:1', // not used (cache hit)
|
||||||
username: 'user',
|
username: 'user',
|
||||||
password: 'pass',
|
password: 'pass',
|
||||||
@@ -175,7 +177,11 @@ describe('sitemap endpoint', () => {
|
|||||||
searchApiBaseUrl: searchUrl,
|
searchApiBaseUrl: searchUrl,
|
||||||
tenant: 'test',
|
tenant: 'test',
|
||||||
proxyBaseUrl: 'https://proxy.example.com',
|
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: {} },
|
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 ctx = vm.createContext({
|
const redis = makeRedisFake();
|
||||||
URLSearchParams,
|
const kme_CSA_settings = {
|
||||||
console,
|
|
||||||
axios,
|
|
||||||
xmlBuilder,
|
|
||||||
redis: makeRedisFake(),
|
|
||||||
kme_CSA_settings: {
|
|
||||||
tokenUrl: mock.url,
|
tokenUrl: mock.url,
|
||||||
username: 'user',
|
username: 'user',
|
||||||
password: 'pass',
|
password: 'pass',
|
||||||
clientId: 'client',
|
clientId: 'client',
|
||||||
scope: 'openid',
|
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: {} },
|
req: { url: '/', method: 'GET', headers: {} },
|
||||||
res,
|
res,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user