diff --git a/package.json b/package.json index 6b7a574..2c8a283 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "local_restart": "bash scripts/local_restart.sh", "pro_restart": "bash scripts/pro_restart.sh", "build": "vite build", + "test:orchestrator": "node --test server/orchestrator-routes.test.mjs", "preview": "node scripts/preview.mjs", "dev:preview": "vite preview" }, diff --git a/server/orchestrator-routes.test.mjs b/server/orchestrator-routes.test.mjs new file mode 100644 index 0000000..f42e958 --- /dev/null +++ b/server/orchestrator-routes.test.mjs @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import test from 'node:test'; +import { createAdminApp } from './app.mjs'; + +function createServices({ role = 'admin' } = {}) { + const calls = { + update: [], + shadow: [], + plans: [], + detail: [], + }; + const configState = { + config: { + mode: 'shadow', + primaryEngine: 'langgraph', + fallbackEngine: 'native', + serviceUrl: 'http://127.0.0.1:8093', + requestTimeoutMs: 5000, + rolloutPercent: 0, + userAllowlist: [], + workflowAllowlist: ['code-run-v1'], + fallbackToNative: true, + requireHealthy: true, + executionEnabled: false, + }, + configVersion: 4, + updatedBy: 'admin-user', + updatedAt: 1, + source: 'admin-db', + runtime: { + effective: true, + shadowsLangGraph: true, + executesLangGraph: false, + executionHandoff: { enabled: false }, + }, + engines: [], + executors: [], + }; + + return { + calls, + services: { + ready: Promise.resolve(), + parseCookies: () => ({ test_session: 'token' }), + USER_COOKIE: 'test_session', + userLoginCookies: () => [], + clearUserSessionCookie: () => {}, + resolveCookieDomainForRequest: () => undefined, + userAuth: { + getMe: async () => ({ id: 'john-id', username: 'john', role }), + }, + orchestratorConfigService: { + getAdminConfig: async () => configState, + updateAdminConfig: async (config, context) => { + calls.update.push({ config, context }); + return { ...configState, config, updatedBy: context.updatedBy }; + }, + getRuntimeState: async ({ probe }) => ({ + ...configState, + serviceHealth: { ok: true, status: 'healthy', probe }, + }), + }, + orchestratorObservabilityService: { + listShadowRuns: async (query) => { + calls.shadow.push(query); + return { metrics: { observations: 0 }, runs: [] }; + }, + listExecutionPlans: async (query) => { + calls.plans.push(query); + return { metrics: { decisions: 0 }, plans: [] }; + }, + getCanaryReadiness: async () => ({ + ready: false, + recommendation: 'keep_shadow', + }), + getShadowRun: async (runId) => { + calls.detail.push(runId); + return runId === 'missing' ? null : { native: { runId }, remote: { available: true } }; + }, + }, + }, + }; +} + +async function startApp(options) { + const harness = createServices(options); + const server = createAdminApp(harness.services).listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + return { + ...harness, + request: (path, init = {}) => fetch(`http://127.0.0.1:${address.port}${path}`, { + ...init, + headers: { + Cookie: 'test_session=token', + 'Content-Type': 'application/json', + ...init.headers, + }, + }), + close: () => new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }), + }; +} + +test('admin can read orchestrator config, runtime and observability endpoints', async (t) => { + const app = await startApp(); + t.after(app.close); + + const config = await app.request('/admin-api/orchestrator/config'); + assert.equal(config.status, 200); + assert.equal((await config.json()).config.mode, 'shadow'); + + const runtime = await app.request('/admin-api/orchestrator/runtime'); + assert.equal(runtime.status, 200); + assert.deepEqual((await runtime.json()).serviceHealth, { + ok: true, + status: 'healthy', + probe: true, + }); + + const shadow = await app.request( + '/admin-api/orchestrator/shadow-runs?hours=24&limit=50&status=failed', + ); + assert.equal(shadow.status, 200); + assert.deepEqual(app.calls.shadow, [{ hours: '24', limit: '50', status: 'failed' }]); + + const plans = await app.request( + '/admin-api/orchestrator/execution-plans?hours=168&limit=20&selection=native', + ); + assert.equal(plans.status, 200); + assert.deepEqual(app.calls.plans, [{ hours: '168', limit: '20', selection: 'native' }]); + + const readiness = await app.request('/admin-api/orchestrator/canary-readiness'); + assert.equal(readiness.status, 200); + assert.equal((await readiness.json()).recommendation, 'keep_shadow'); + + const detail = await app.request('/admin-api/orchestrator/shadow-runs/run-1'); + assert.equal(detail.status, 200); + assert.deepEqual(app.calls.detail, ['run-1']); + + const missing = await app.request('/admin-api/orchestrator/shadow-runs/missing'); + assert.equal(missing.status, 404); +}); + +test('admin config update records the authenticated administrator', async (t) => { + const app = await startApp(); + t.after(app.close); + + const response = await app.request('/admin-api/orchestrator/config', { + method: 'PUT', + body: JSON.stringify({ config: { mode: 'off', executionEnabled: false } }), + }); + assert.equal(response.status, 200); + assert.deepEqual(app.calls.update, [{ + config: { mode: 'off', executionEnabled: false }, + context: { updatedBy: 'john-id' }, + }]); +}); + +test('ordinary users cannot access or mutate orchestrator controls', async (t) => { + const app = await startApp({ role: 'user' }); + t.after(app.close); + + const read = await app.request('/admin-api/orchestrator/config'); + assert.equal(read.status, 403); + + const update = await app.request('/admin-api/orchestrator/config', { + method: 'PUT', + body: JSON.stringify({ config: { mode: 'active', executionEnabled: true } }), + }); + assert.equal(update.status, 403); + assert.equal(app.calls.update.length, 0); +});