import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import vm from 'node:vm'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const proxyPath = join(__dirname, '../../src/proxyScripts/kmeContentSourceAdapter.js'); const proxyCode = readFileSync(proxyPath, 'utf-8'); const proxyScript = new vm.Script(proxyCode, { filename: 'kmeContentSourceAdapter.js' }); /** * Build a minimal VM context satisfying the vm-context contract. * @param {import('node:test').TestContext} t * @param {object} [overrides] – shallow-merge over defaults */ function makeContext(t, overrides = {}) { const _store = {}; const redis = { hSet: t.mock.fn(async (key, field, value) => { _store[`${key}:${field}`] = value; return 1; }), hGet: t.mock.fn(async (key, field) => _store[`${key}:${field}`] ?? null), }; let statusCode = null; let body = ''; const headers = {}; const res = { writeHead: t.mock.fn((code, hdrs = {}) => { statusCode = code; Object.assign(headers, hdrs); }), end: t.mock.fn((b = '') => { body += String(b); }), get statusCode() { return statusCode; }, get body() { return body; }, get headers() { return headers; }, }; const kme_CSA_settings = { tokenUrl: 'https://auth.example.com/token', username: 'testuser', password: 'testpass', clientId: 'test-client', scope: 'openid', }; const axiosMock = { post: t.mock.fn(async () => ({ data: { id_token: 'mock-token', expires_in: 9_999_999_999 }, })), }; const ctx = vm.createContext({ URLSearchParams, console, axios: axiosMock, redis, kme_CSA_settings, req: { url: '/', method: 'GET', headers: {} }, res, ...overrides, }); // Expose helpers for assertion ctx._redis = redis; ctx._res = res; ctx._store = _store; ctx._axios = axiosMock; return ctx; } /** Run the proxy script in a context and await the async IIFE completing. */ async function runScript(ctx) { // script.runInContext() returns the Promise from the async IIFE const result = proxyScript.runInContext(ctx); if (result && typeof result.then === 'function') { await result; } } // --------------------------------------------------------------------------- // User Story 1 — Successful Authenticated Request // --------------------------------------------------------------------------- describe('US1: successful authenticated request', () => { test('cache miss → fresh fetch → 200 OK', async (t) => { const ctx = makeContext(t); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 200); assert.strictEqual(ctx._res.body, 'Authorized'); assert.strictEqual(ctx._axios.post.mock.calls.length, 1); }); test('cache hit → no fetch → 200 OK', async (t) => { const ctx = makeContext(t, {}); // Pre-seed Redis store via the fake ctx._store['authorization:token'] = 'cached-tok'; ctx._store['authorization:expiry'] = '9999999999'; await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 200); assert.strictEqual(ctx._res.body, 'Authorized'); assert.strictEqual(ctx._axios.post.mock.calls.length, 0); }); }); // --------------------------------------------------------------------------- // User Story 2 — Token Expiry and Refresh // --------------------------------------------------------------------------- describe('US2: token expiry and refresh', () => { test('expired token → re-fetch → 200 OK', async (t) => { const ctx = makeContext(t); ctx._store['authorization:token'] = 'old-tok'; ctx._store['authorization:expiry'] = '1'; // epoch far in the past await runScript(ctx); assert.strictEqual(ctx._axios.post.mock.calls.length, 1, 'should re-fetch'); assert.strictEqual(ctx._res.statusCode, 200); assert.strictEqual(ctx._res.body, 'Authorized'); // New token should have been written to Redis const hSetCalls = ctx._redis.hSet.mock.calls; assert.ok(hSetCalls.length >= 2, 'hSet should be called for token and expiry'); }); test('future expiry → no re-fetch → 200 OK', async (t) => { const ctx = makeContext(t); ctx._store['authorization:token'] = 'fresh-tok'; ctx._store['authorization:expiry'] = '9999999999'; await runScript(ctx); assert.strictEqual(ctx._axios.post.mock.calls.length, 0, 'should not re-fetch'); assert.strictEqual(ctx._res.statusCode, 200); assert.strictEqual(ctx._res.body, 'Authorized'); }); }); // --------------------------------------------------------------------------- // User Story 3 — Authentication Failure Handling // --------------------------------------------------------------------------- describe('US3: authentication failure handling', () => { test('HTTP 401 from token service → 401 Unauthorized: HTTP 401', async (t) => { const axiosError = Object.assign(new Error('Request failed with status code 401'), { response: { status: 401 }, }); const ctx = makeContext(t, { axios: { post: t.mock.fn(async () => { throw axiosError; }) }, }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 401); assert.strictEqual(ctx._res.body, 'Unauthorized: HTTP 401'); }); test('timeout (ECONNABORTED) → 401 Unauthorized: token service timeout', async (t) => { const axiosError = Object.assign(new Error('timeout'), { code: 'ECONNABORTED' }); const ctx = makeContext(t, { axios: { post: t.mock.fn(async () => { throw axiosError; }) }, }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 401); assert.strictEqual(ctx._res.body, 'Unauthorized: token service timeout'); }); test('timeout (ERR_CANCELED) → 401 Unauthorized: token service timeout', async (t) => { const axiosError = Object.assign(new Error('canceled'), { code: 'ERR_CANCELED' }); const ctx = makeContext(t, { axios: { post: t.mock.fn(async () => { throw axiosError; }) }, }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 401); assert.strictEqual(ctx._res.body, 'Unauthorized: token service timeout'); }); test('missing id_token in response → 401 Unauthorized: id_token missing from response', async (t) => { const ctx = makeContext(t, { axios: { post: t.mock.fn(async () => ({ data: { expires_in: 9999 } })), }, }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 401); assert.strictEqual(ctx._res.body, 'Unauthorized: id_token missing from response'); }); test('missing expires_in in response → 401 Unauthorized: expires_in missing from response', async (t) => { const ctx = makeContext(t, { axios: { post: t.mock.fn(async () => ({ data: { id_token: 'a-token' } })), }, }); await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 401); assert.strictEqual(ctx._res.body, 'Unauthorized: expires_in missing from response'); }); test('missing tokenUrl in kme_CSA_settings → 401 missing required field: tokenUrl', async (t) => { const ctx = makeContext(t); ctx.kme_CSA_settings.tokenUrl = ''; await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 401); assert.strictEqual(ctx._res.body, 'Unauthorized: missing required field: tokenUrl'); }); test('missing username in kme_CSA_settings → 401 missing required field: username', async (t) => { const ctx = makeContext(t); ctx.kme_CSA_settings.username = undefined; await runScript(ctx); assert.strictEqual(ctx._res.statusCode, 401); assert.strictEqual(ctx._res.body, 'Unauthorized: missing required field: username'); }); }); // --------------------------------------------------------------------------- // Phase 6 — Stampede Guard (FR-013) // --------------------------------------------------------------------------- describe('stampede guard', () => { test('concurrent requests → exactly one fetch, both get 200', async (t) => { // Shared kme_CSA_settings across both contexts (same reference) const kme_CSA_settings = { tokenUrl: 'https://auth.example.com/token', username: 'testuser', password: 'testpass', clientId: 'test-client', scope: 'openid', }; // Shared Redis store const _store = {}; const redis = { hSet: t.mock.fn(async (key, field, value) => { _store[`${key}:${field}`] = value; return 1; }), hGet: t.mock.fn(async (key, field) => _store[`${key}:${field}`] ?? null), }; // Slow axios mock — 50ms delay before returning token const mockAxiosPost = t.mock.fn(async () => { await new Promise(resolve => setTimeout(resolve, 50)); return { data: { id_token: 'stampede-token', expires_in: 9_999_999_999 } }; }); const sharedAxios = { post: mockAxiosPost }; // Build two contexts sharing kme_CSA_settings, redis, and axios references function makeRes(tctx) { let statusCode = null; let body = ''; return { writeHead: tctx.mock.fn((code) => { statusCode = code; }), end: tctx.mock.fn((b = '') => { body += String(b); }), get statusCode() { return statusCode; }, get body() { return body; }, }; } const res1 = makeRes(t); const res2 = makeRes(t); const ctx1 = vm.createContext({ URLSearchParams, console, axios: sharedAxios, redis, kme_CSA_settings, req: { url: '/', method: 'GET', headers: {} }, res: res1, }); const ctx2 = vm.createContext({ URLSearchParams, console, axios: sharedAxios, redis, kme_CSA_settings, req: { url: '/', method: 'GET', headers: {} }, res: res2, }); // Fire both concurrently const p1 = proxyScript.runInContext(ctx1); const p2 = proxyScript.runInContext(ctx2); await Promise.all([p1, p2]); assert.strictEqual(mockAxiosPost.mock.calls.length, 1, 'stampede guard: only one fetch'); assert.strictEqual(res1.statusCode, 200); assert.strictEqual(res2.statusCode, 200); assert.strictEqual(res1.body, 'Authorized'); assert.strictEqual(res2.body, 'Authorized'); }); });