[Spec Kit] Implementation progress
This commit is contained in:
137
tests/contract/proxy-http.test.js
Normal file
137
tests/contract/proxy-http.test.js
Normal file
@@ -0,0 +1,137 @@
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import vm from 'node:vm';
|
||||
import http from 'node:http';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import axios from 'axios';
|
||||
|
||||
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' });
|
||||
|
||||
/**
|
||||
* Start a minimal HTTP server that handles all POST requests with a fixed JSON body.
|
||||
* @param {number} statusCode
|
||||
* @param {object} responseBody
|
||||
* @returns {Promise<{ server: http.Server, url: string, close: () => Promise<void> }>}
|
||||
*/
|
||||
function startMockTokenServer(statusCode, responseBody) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(responseBody));
|
||||
});
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const { port } = server.address();
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
const close = () => new Promise((res, rej) => server.close(err => err ? rej(err) : res()));
|
||||
resolve({ server, url, close });
|
||||
});
|
||||
server.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Build an in-memory Redis fake. */
|
||||
function makeRedisFake() {
|
||||
const _store = {};
|
||||
return {
|
||||
hSet: async (key, field, value) => { _store[`${key}:${field}`] = value; return 1; },
|
||||
hGet: async (key, field) => _store[`${key}:${field}`] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a capturable res object. */
|
||||
function makeRes() {
|
||||
let statusCode = null;
|
||||
let body = '';
|
||||
const headers = {};
|
||||
return {
|
||||
writeHead: (code, hdrs = {}) => { statusCode = code; Object.assign(headers, hdrs); },
|
||||
end: (b = '') => { body += String(b); },
|
||||
get statusCode() { return statusCode; },
|
||||
get body() { return body; },
|
||||
get headers() { return headers; },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Contract: 200 OK — successful OIDC token fetch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('proxy HTTP contract: 200 OK', () => {
|
||||
test('fresh token fetch → 200 Authorized with Content-Type text/plain', async () => {
|
||||
const mock = await startMockTokenServer(200, {
|
||||
id_token: 'contract-token',
|
||||
expires_in: 9_999_999_999,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = makeRes();
|
||||
const ctx = vm.createContext({
|
||||
URLSearchParams,
|
||||
console,
|
||||
axios,
|
||||
redis: makeRedisFake(),
|
||||
kme_CSA_settings: {
|
||||
tokenUrl: mock.url,
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
clientId: 'client',
|
||||
scope: 'openid',
|
||||
},
|
||||
req: { url: '/', method: 'GET', headers: {} },
|
||||
res,
|
||||
});
|
||||
|
||||
await proxyScript.runInContext(ctx);
|
||||
|
||||
assert.strictEqual(res.statusCode, 200);
|
||||
assert.strictEqual(res.body, 'Authorized');
|
||||
assert.strictEqual(res.headers['Content-Type'], 'text/plain');
|
||||
} finally {
|
||||
await mock.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Contract: 401 Unauthorized — token service returns 4xx
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('proxy HTTP contract: 401 Unauthorized', () => {
|
||||
test('token service 401 → proxy 401 with Unauthorized: prefix', async () => {
|
||||
const mock = await startMockTokenServer(401, {});
|
||||
|
||||
try {
|
||||
const res = makeRes();
|
||||
const ctx = vm.createContext({
|
||||
URLSearchParams,
|
||||
console,
|
||||
axios,
|
||||
redis: makeRedisFake(),
|
||||
kme_CSA_settings: {
|
||||
tokenUrl: mock.url,
|
||||
username: 'bad-user',
|
||||
password: 'bad-pass',
|
||||
clientId: 'client',
|
||||
scope: 'openid',
|
||||
},
|
||||
req: { url: '/', method: 'GET', headers: {} },
|
||||
res,
|
||||
});
|
||||
|
||||
await proxyScript.runInContext(ctx);
|
||||
|
||||
assert.strictEqual(res.statusCode, 401);
|
||||
assert.match(res.body, /^Unauthorized: .+/);
|
||||
assert.strictEqual(res.headers['Content-Type'], 'text/plain');
|
||||
} finally {
|
||||
await mock.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
311
tests/unit/proxy.test.js
Normal file
311
tests/unit/proxy.test.js
Normal file
@@ -0,0 +1,311 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user