import { ownerAccounts } from './helpers/owner-accounts.mjs'; // #797 / #1839 Task 3: the Console's request intake routes. The first half drives requestHTTP with a // fake request, response and bus transport. The second half signs in through the fake OP on loopback // and checks the listener's gates, as decide-sso.test.mjs does for decisions. The bus side is // packages/bus/tests/request-console.test.mjs and the page side request-browser.test.mjs. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { request as httpRequest } from 'node:http'; import { BusReadError } from '../src/bus.mjs'; import { CONSOLE_VERBS } from '../../bus/src/broker.mjs'; import { REQUEST_REFUSALS, REQUEST_ROUTE, requestHTTP } from '../src/request-http.mjs'; import { startServer } from '../src/serve.mjs'; import { createSso, SESSION_COOKIE } from '../src/sso.mjs'; import { consoleAccess } from '../src/access.mjs'; import { userOptionsFor } from '../src/console-bus.mjs'; import { close } from './fixture.mjs'; import { fakeOidc } from './helpers/fake-oidc.mjs'; const ORIGIN = 'https://console.test'; const SUBJECT = { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' }; const STATE = { version: 1, request: 'r1', business: 'demo', revision: 5, source_message: 'm1', thread: null, session: null, owner: 'jason', pm: 'pm', phase: 'discovery', project: 'old', chat_project: null, mode: 'autonomous', mode_revision: 1, plan_digest: 'a'.repeat(64), spec_commit: null, snapshot: null, tasks: [], delivery: null, question: { id: 'q1', text: 'Which platform?', options: [{ key: 'web', text: 'Web' }], answer: null }, candidate: null, selection: null, route: null, wait: { kind: 'question', code: 'question', subject: 'q1', resume_phase: 'discovery', observed_revision: 5 }, }; const PROJECTS = [{ project: 'old', name: 'Old', tracker: { root: 1, project: 2 }, business: 'demo' }, { project: 'older', name: 'Older' }]; const ANSWER = { revision: 5, question: 'q1', op: 'ans-1', text: 'Web, please.\nNo installs.' }; const BODIES = { answer: ANSWER, attach: { revision: 5, project: 'old', op: 'att-1' }, mode: { revision: 5, mode_revision: 1, mode: 'guided', op: 'mode-1' }, cancel: { revision: 5, op: 'cancel-1' }, revoke: { project: 'old', snapshot: 'prd-active', revision: 3, reason: 'Replace the draft', op: 'revoke-1' }, }; const CANDIDATE = { version: 1, request: 'r1', project: 'old', prd_version: '1.0', records_commit: 'a'.repeat(40), prd_path: 'docs/prd-1.0.md', prd_blob: 'b'.repeat(40), spec_commit: 'c'.repeat(40), mode_revision: 1, requirements: [{ id: 'R1', title: 'Timer', path: 'requirements/R1.json', blob: 'd'.repeat(40) }] }; const DRAFT = { candidate: CANDIDATE, prd: '# PRD\n', requirements: [{ recordVersion: 1, id: 'R1', title: 'Timer', text: 'Counts down', status: 'draft' }], snapshot: { id: 'prd-active', revision: 3 } }; const ACTIVE = { ...STATE, candidate: CANDIDATE, snapshot: 'prd-active', phase: 'planning' }; const REVOKED = { snapshot: 'prd-active', revision: 4, kind: 'revoked', cause: 'opaque host receipt' }; const VERBS = { answer: 'request.answer.console', attach: 'request.attach.console', mode: 'request.mode.console', cancel: 'request.cancel.console' }; function request({ method = 'POST', body = {}, headers = {}, raw } = {}) { const h = { origin: [ORIGIN], 'sec-fetch-site': ['same-origin'], 'content-type': ['application/json'], ...headers }; for (const k of Object.keys(h)) if (h[k] === null) delete h[k]; const data = Buffer.from(raw ?? JSON.stringify(body)); return { method, headersDistinct: h, async *[Symbol.asyncIterator]() { yield data; } }; } function response() { const r = { status: null, headers: null, text: '' }; r.writeHead = (s, h) => { r.status = s; r.headers = h; }; r.end = (t = '') => { r.text += t; }; r.json = () => JSON.parse(r.text); return r; } function setup({ read, write, verbs } = {}) { const session = { name: 'jason', ...SUBJECT }; const reads = [], writes = [], lines = []; const s = { session, reads, writes, lines, current: session, read: read ?? (async (verb, args) => (verb === 'request.projects.console' ? PROJECTS : { ...STATE, request: args.request })), write: write ?? (async (verb, args) => ({ ...STATE, request: args.request, revision: args.revision + 1 })), }; const requestRead = async (verb, args, ctx) => { reads.push({ verb, args, ctx }); return s.read(verb, args, ctx); }; const requestWrite = async (verb, args, ctx) => { writes.push({ verb, args, ctx }); return s.write(verb, args, ctx); }; s.run = async (path, req, extra = {}) => { const res = response(); await requestHTTP(req, res, { path, requestRead, requestWrite, session: s.session, live: () => s.current, origin: ORIGIN, log: l => lines.push(l), ...(verbs ? { verbs } : {}), ...extra }); return res; }; s.get = (path = '/api/requests/r1', extra) => s.run(path, request({ method: 'GET' }), extra); s.post = (action, body = BODIES[action], id = 'r1') => s.run(`/api/requests/${id}/${action}`, request({ body })); return s; } const refused = (res, code) => { assert.equal(res.status, REQUEST_REFUSALS[code][0], `${code}: ${res.text}`); assert.deepEqual(res.json(), { error: code, message: REQUEST_REFUSALS[code][1] }); assert.equal(res.headers['cache-control'], 'no-store'); }; test('the route pattern takes a request id and a known suffix, and nothing else', () => { for (const ok of ['/api/requests/r1', '/api/requests/r1/projects', '/api/requests/r1/draft', '/api/requests/r1/answer', '/api/requests/r1/attach', '/api/requests/r1/mode', '/api/requests/r1/cancel', '/api/requests/r1/revoke']) assert.match(ok, REQUEST_ROUTE); for (const bad of ['/api/requests', '/api/requests/', '/api/requests/r1/other', '/api/requests/r1/answer/x', '/api/requests/a/b/answer']) assert.doesNotMatch(bad, REQUEST_ROUTE); }); test('the owner reads a request and its authorized projects through the read verbs with only the session subject', async () => { const s = setup(); const state = await s.get(); assert.equal(state.status, 200, state.text); assert.deepEqual(state.json(), STATE); assert.equal(state.headers['cache-control'], 'no-store'); const projects = await s.get('/api/requests/r1/projects'); assert.equal(projects.status, 200, projects.text); assert.deepEqual(projects.json(), [{ project: 'old', name: 'Old' }, { project: 'older', name: 'Older' }], 'only the picker fields cross the seam'); assert.deepEqual(s.reads, [ { verb: 'request.get.console', args: { request: 'r1' }, ctx: { subject: SUBJECT } }, { verb: 'request.projects.console', args: { request: 'r1' }, ctx: { subject: SUBJECT } }, ]); assert.equal(s.writes.length, 0, 'a read never reaches the write transport'); assert.equal(s.lines.length, 2); assert.match(s.lines[0], / request user="jason" request="r1" action=get$/); }); test('each write sends exactly the validated fields and the session subject, and relays the new state', async () => { const s = setup(); for (const action of ['answer', 'attach', 'mode', 'cancel']) { const res = await s.post(action); assert.equal(res.status, 200, `${action}: ${res.text}`); assert.equal(res.json().revision, 6); assert.equal(res.json().request, 'r1'); assert.deepEqual(s.writes.at(-1), { verb: VERBS[action], args: { request: 'r1', ...BODIES[action] }, ctx: { subject: SUBJECT } }, action); } assert.equal(s.reads.length, 0, 'a write never reaches the read transport'); assert.doesNotMatch(s.lines.join('\n'), /No installs|Web, please/, 'answer text is never logged'); assert.match(s.lines[0], / request user="jason" request="r1" action=answer revision=5 outcome=ok$/); }); test('the draft read is Task 4\'s verb: it fails closed on a bus without it and relays the bound draft with it', async () => { assert.equal(CONSOLE_VERBS.includes('request.draft.console'), true, 'the bus ships the verb'); const s = setup({ verbs: CONSOLE_VERBS.filter(v => v !== 'request.draft.console') }); refused(await s.get('/api/requests/r1/draft'), 'draft-unavailable'); assert.equal(s.reads.length, 0, 'no bus call and no invented PRD content'); const record = DRAFT; const t = setup({ read: async () => record }); const ok = await t.get('/api/requests/r1/draft'); assert.equal(ok.status, 200, ok.text); assert.deepEqual(ok.json(), record); assert.deepEqual(t.reads, [{ verb: 'request.draft.console', args: { request: 'r1' }, ctx: { subject: SUBJECT } }]); for (const bad of [{ ...record, candidate: { ...record.candidate, request: 'r2' } }, { ...record, prd: 7 }, { ...record, requirements: {} }, { prd: '', requirements: [] }, [], null]) { const wrong = setup({ read: async () => bad }); refused(await wrong.get('/api/requests/r1/draft'), 'unavailable'); } }); test('the gates before the body: method, read-only, sign-in and same origin', async () => { const s = setup(); refused(await s.run('/api/requests/r1/answer', request({ method: 'GET' })), 'method-not-allowed'); refused(await s.run('/api/requests/r1', request({ method: 'POST' })), 'method-not-allowed'); refused(await s.run('/api/requests/r1/projects', request({ method: 'DELETE' })), 'method-not-allowed'); // No write transport: reads still work, writes say read-only. refused(await s.post('answer', ANSWER, 'r1').then(() => s.run('/api/requests/r1/answer', request({ body: ANSWER }), { requestWrite: null })), 'read-only'); assert.equal((await s.get()).status, 200); // No read transport: the read says so and nothing runs. refused(await s.get('/api/requests/r1', { requestRead: null }), 'unavailable'); // No session. refused(await s.run('/api/requests/r1/answer', request({ body: ANSWER }), { session: null, live: () => null }), 'sign-in-required'); refused(await s.run('/api/requests/r1', request({ method: 'GET' }), { session: null, live: () => null }), 'sign-in-required'); const before = s.writes.length; for (const headers of [{ origin: ['https://evil.test'] }, { origin: null }, { origin: [ORIGIN, ORIGIN] }, { 'sec-fetch-site': ['same-site'] }, { 'sec-fetch-site': ['cross-site'] }, { 'sec-fetch-site': null }]) refused(await s.run('/api/requests/r1/answer', request({ body: ANSWER, headers })), 'cross-origin'); assert.equal(s.writes.length, before, 'a denied origin never reaches the bus'); // A read from another site is refused too; a read with no fetch metadata and no origin is a plain navigation. refused(await s.run('/api/requests/r1', request({ method: 'GET', headers: { 'sec-fetch-site': ['cross-site'] } })), 'cross-origin'); refused(await s.run('/api/requests/r1', request({ method: 'GET', headers: { origin: ['https://evil.test'] } })), 'cross-origin'); }); test('a bad body, id or content type refuses before the bus', async () => { const s = setup(); const path = '/api/requests/r1/answer'; refused(await s.run(path, request({ body: ANSWER, headers: { 'content-type': ['text/plain'] } })), 'invalid-request'); refused(await s.run(path, request({ raw: '{' })), 'invalid-request'); refused(await s.run(path, request({ raw: '[]' })), 'invalid-request'); for (const bad of [ { ...ANSWER, extra: 1 }, { ...ANSWER, business: 'other' }, { ...ANSWER, subject: SUBJECT }, { ...ANSWER, request: 'r2' }, { ...ANSWER, revision: 0 }, { ...ANSWER, revision: '5' }, { ...ANSWER, revision: 1.5 }, { ...ANSWER, question: '' }, { ...ANSWER, question: '../x' }, { ...ANSWER, op: '' }, { ...ANSWER, op: 'bad op' }, { ...ANSWER, text: '' }, { ...ANSWER, text: 7 }, { ...ANSWER, text: 'a\0b' }, { ...ANSWER, text: 'x'.repeat(8001) }, { revision: 5, question: 'q1', op: 'ans-1' }, ]) refused(await s.run(path, request({ body: bad })), 'invalid-request'); for (const [action, bad] of [ ['attach', { ...BODIES.attach, project: '' }], ['attach', { ...BODIES.attach, project: '../x' }], ['attach', { ...BODIES.attach, text: 'x' }], ['mode', { ...BODIES.mode, mode: 'auto' }], ['mode', { ...BODIES.mode, mode_revision: 0 }], ['mode', { ...BODIES.mode, mode_revision: '1' }], ['mode', { revision: 5, mode: 'guided', op: 'm' }], ['cancel', { ...BODIES.cancel, project: 'old' }], ['cancel', { revision: 5 }], ]) refused(await s.post(action, bad), 'invalid-request'); refused(await s.run(path, request({ raw: JSON.stringify({ ...ANSWER, text: 'y'.repeat(70000) }) })), 'request-too-large'); refused(await s.run('/api/requests/r1/cancel', request({ raw: JSON.stringify({ ...BODIES.cancel, op: 'y'.repeat(5000) }) })), 'request-too-large'); refused(await s.run('/api/requests/..%2Fx/answer', request({ body: ANSWER })), 'request-not-found'); refused(await s.run('/api/requests/%E0%A4%A/answer', request({ body: ANSWER })), 'request-not-found'); refused(await s.get('/api/requests/..%2Fx'), 'request-not-found'); assert.equal(s.writes.length + s.reads.length, 0); }); test('bus refusals map by code, and an unknown code is unavailable at a read and outcome-unknown at a write, never retried', async () => { const cases = [ ['human-required', 'human-required'], ['agent-required', 'human-required'], ['access-revoked', 'human-required'], ['unauthenticated', 'human-required'], ['peercred-required', 'peercred-required'], ['request-not-found', 'request-not-found'], ['request-revision-conflict', 'request-revision-conflict'], ['request-phase-refused', 'request-phase-refused'], ['request-mode-revision-conflict', 'request-mode-revision-conflict'], ['request-question-closed', 'request-question-closed'], ['request-question-mismatch', 'request-question-mismatch'], ['request-project-refused', 'request-project-refused'], ['request-operation-conflict', 'request-operation-conflict'], ['invalid-request', 'invalid-request'], ['prd-candidate-required', 'prd-candidate-required'], ]; for (const [bus, code] of cases) { const w = setup({ write: async () => { throw new BusReadError(bus); } }); refused(await w.post('answer'), code); assert.equal(w.writes.length, 1, `${bus} is not retried`); const r = setup({ read: async () => { throw new BusReadError(bus); } }); refused(await r.get(), code); assert.equal(r.reads.length, 1); } for (const err of [new BusReadError('weird'), new BusReadError('outcome-unknown'), new Error('boom'), new BusReadError('invalid-response')]) { const w = setup({ write: async () => { throw err; } }); refused(await w.post('mode'), 'outcome-unknown'); assert.equal(w.writes.length, 1); const r = setup({ read: async () => { throw err; } }); refused(await r.get(), 'unavailable'); refused(await r.get('/api/requests/r1/projects'), 'unavailable'); } }); test('a reply that is not the state of this request is outcome-unknown, never relayed', async () => { for (const reply of [null, [], 'ok', { ...STATE, request: 'r2' }, { ...STATE, version: 2 }, { ...STATE, revision: 0 }, { ...STATE, revision: '5' }, { ...STATE, phase: 7 }]) { const w = setup({ write: async () => reply }); refused(await w.post('cancel'), 'outcome-unknown'); const r = setup({ read: async () => reply }); refused(await r.get(), 'unavailable'); } for (const reply of [null, {}, [{ name: 'Old' }], [{ project: 'old' }], [{ project: 'old', name: 7 }], 'old']) { const r = setup({ read: async () => reply }); refused(await r.get('/api/requests/r1/projects'), 'unavailable'); } }); test('a session that ends between the gate and the spawn refuses before the bus', async () => { const s = setup(); s.current = null; refused(await s.post('answer'), 'sign-in-required'); refused(await s.get(), 'sign-in-required'); s.current = { ...s.session }; refused(await s.post('answer'), 'sign-in-required'); assert.equal(s.writes.length + s.reads.length, 0); }); // The listener. decide-sso.test.mjs's harness: the fake OP, createSso over it, and the human listener. const HOST = 'console.example.test', LISTEN = `https://${HOST}`; function call(port, { method = 'GET', path = '/', headers = {}, body, host = HOST } = {}) { return new Promise((resolve, reject) => { const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) }; const req = httpRequest({ host: '127.0.0.1', port, method, path, headers: { host, ...length, ...headers } }, res => { let text = ''; res.on('data', c => (text += c)); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text })); }); req.on('error', reject); // A listener that stops answering fails the test here, fast and by name, instead of hanging the file. req.setTimeout(10000, () => req.destroy(new Error(`no response from ${method} ${path}`))); req.end(body); }); } const writeHeaders = cookie => ({ ...(cookie ? { cookie } : {}), origin: LISTEN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }); async function listener(t, server = {}, access = null) { const op = await fakeOidc(); t.after(() => op.close()); const iss = op.issuer('a'); const config = { publicOrigin: LISTEN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'user-a', name: 'person-a' }] }] }; const lines = []; const users = access && { users: consoleAccess({ load: () => access, business: () => 'demo' }) }; const sso = await createSso(config, { accounts: ownerAccounts(config), secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), ...users }); t.after(() => sso.close()); const reads = [], writes = []; const requestRead = async (verb, args, ctx) => { reads.push({ verb, args, ctx }); return { ...STATE, request: args.request }; }; const requestWrite = async (verb, args, ctx) => { writes.push({ verb, args, ctx }); return { ...STATE, request: args.request, revision: args.revision + 1 }; }; const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => []])); const userOptions = access && { userOptions: userOptionsFor({ owner: async () => ({}), send: true, transport: () => async () => [] }) }; const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus, sso, send: async () => ({ id: 'msg-0001' }), requestRead, requestWrite, log: l => lines.push(l), ...userOptions, ...server }); t.after(() => close(web)); const port = web.address().port; const signIn = async (sub = undefined) => { const start = await call(port, { path: '/auth/login' }); const cookie = [start.headers['set-cookie']].flat()[0].split(';')[0]; const g = op.grant(iss, start.headers.location, { sub }); const q = new URLSearchParams({ code: g.code, state: new URL(start.headers.location).searchParams.get('state') }); const r = await call(port, { path: `/auth/callback?${q}`, headers: { cookie } }); return [r.headers['set-cookie'] ?? []].flat().find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0')).split(';')[0]; }; const post = (cookie, path, body, headers = {}) => call(port, { method: 'POST', path, headers: { ...writeHeaders(cookie), ...headers }, body: JSON.stringify(body) }); return { port, reads, writes, lines, signIn, post, get: (cookie, path, headers = {}) => call(port, { path, headers: { ...(cookie ? { cookie } : {}), ...headers } }) }; } test('a signed-in owner reads and writes through the listener with the session subject, and the config says so', async t => { const env = await listener(t); const cookie = await env.signIn(); const config = await env.get(cookie, '/api/config'); assert.equal(config.body.requests, true); assert.equal(config.body.requestWrites, true); const read = await env.get(cookie, '/api/requests/r1'); assert.equal(read.status, 200, JSON.stringify(read.body)); assert.equal(read.body.request, 'r1'); const write = await env.post(cookie, '/api/requests/r1/answer', ANSWER); assert.equal(write.status, 200, JSON.stringify(write.body)); const subject = { provider: 'a', iss: env.reads[0].ctx.subject.iss, sub: 'user-a' }; assert.deepEqual(env.reads[0], { verb: 'request.get.console', args: { request: 'r1' }, ctx: { subject } }); assert.deepEqual(env.writes[0], { verb: 'request.answer.console', args: { request: 'r1', ...ANSWER }, ctx: { subject } }); assert.equal(env.lines.some(l => l.includes('No installs') || l.includes(cookie.split('=')[1])), false); }); test('without a write transport the listener reads but every intake write is read-only, and the config says so', async t => { const env = await listener(t, { requestWrite: null }); const cookie = await env.signIn(); const config = await env.get(cookie, '/api/config'); assert.equal(config.body.requests, true); assert.equal(config.body.requestWrites, false); assert.equal((await env.get(cookie, '/api/requests/r1')).status, 200); for (const action of ['answer', 'attach', 'mode', 'cancel', 'revoke']) { const r = await env.post(cookie, `/api/requests/r1/${action}`, BODIES[action]); assert.deepEqual([r.status, r.body.error], [403, 'read-only'], action); } assert.equal(env.writes.length, 0); const none = await listener(t, { requestRead: null, requestWrite: null }); const c2 = await none.signIn(); const cfg = await none.get(c2, '/api/config'); assert.equal('requests' in cfg.body && cfg.body.requests, false); assert.equal(cfg.body.requestWrites ?? false, false); assert.equal((await none.get(c2, '/api/requests/r1')).body.error, 'unavailable'); }); test('a signed-out caller, a denied origin and a granted user all stop before the transport', async t => { const access = { accessVersion: 1, revision: 1, users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'a', sub: 'alice-sub' }] }], grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator' }], }; const env = await listener(t, {}, access); const signedOut = await env.post(null, '/api/requests/r1/answer', ANSWER); assert.deepEqual([signedOut.status, signedOut.body.error], [401, 'sign-in-required']); assert.equal((await env.get(null, '/api/requests/r1')).status, 401); const owner = await env.signIn(); for (const headers of [{ origin: 'https://evil.test' }, { origin: null }, { 'sec-fetch-site': 'cross-site' }]) { const sent = { ...writeHeaders(owner), ...headers }; for (const k of Object.keys(sent)) if (sent[k] === null) delete sent[k]; const r = await call(env.port, { method: 'POST', path: '/api/requests/r1/answer', headers: sent, body: JSON.stringify(ANSWER) }); assert.equal(r.status, 403); assert.ok(['cross-origin'].includes(r.body.error) || r.body.error === 'cross-origin request refused', JSON.stringify(r.body)); } const alice = await env.signIn('alice-sub'); const config = await env.get(alice, '/api/config'); assert.equal(config.body.user, true); assert.equal(config.body.requests ?? false, false); assert.equal(config.body.requestWrites ?? false, false); for (const action of ['answer', 'attach', 'mode', 'cancel', 'revoke']) { const r = await env.post(alice, `/api/requests/r1/${action}`, BODIES[action]); assert.deepEqual([r.status, r.body.error], [403, 'owner-only'], action); } for (const path of ['/api/requests/r1', '/api/requests/r1/projects', '/api/requests/r1/draft']) { const r = await env.get(alice, path); assert.deepEqual([r.status, r.body.error], [403, 'owner-only'], path); } assert.equal(env.reads.length + env.writes.length, 0); }); // Review r1 I1: with no SSO there is no owner session, so a request URL is refused where the other intake refusals // are, and never reaches an SSO field. The first form of this bug was an uncaught TypeError that ended the process. const REFUSED_WITHOUT_OWNER = async (port, host, extra = {}) => { const seen = []; for (const path of ['/api/requests/r1', '/api/requests/r1/projects', '/api/requests/r1/draft']) { const r = await call(port, { path, host, headers: extra }); seen.push([path, r.status, r.body.error]); } for (const action of ['answer', 'attach', 'mode', 'cancel', 'revoke']) { const r = await call(port, { method: 'POST', path: `/api/requests/r1/${action}`, host, headers: { ...extra, 'content-type': 'application/json' }, body: JSON.stringify(BODIES[action]) }); seen.push([action, r.status, r.body.error]); } const wrong = [await call(port, { method: 'POST', path: '/api/requests/r1', host, headers: extra, body: '{}' }), await call(port, { path: '/api/requests/r1/answer', host, headers: extra })]; seen.push(['wrong-method', ...wrong.map(r => r.status)]); return seen; }; const EXPECTED_WITHOUT_OWNER = [['/api/requests/r1', 503, 'unavailable'], ['/api/requests/r1/projects', 503, 'unavailable'], ['/api/requests/r1/draft', 503, 'unavailable'], ['answer', 403, 'read-only'], ['attach', 403, 'read-only'], ['mode', 403, 'read-only'], ['cancel', 403, 'read-only'], ['revoke', 403, 'read-only'], ['wrong-method', 405, 405]]; test('without --sso a request URL is refused by the listener and the process keeps serving', async t => { const crashes = []; const onCrash = err => crashes.push(err); process.on('uncaughtException', onCrash); t.after(() => process.off('uncaughtException', onCrash)); const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', log: () => {} }); t.after(() => close(web)); const port = web.address().port, host = `127.0.0.1:${port}`; assert.deepEqual(await REFUSED_WITHOUT_OWNER(port, host), EXPECTED_WITHOUT_OWNER); // The same-origin form a page would send changes nothing: there is still no owner to act as. assert.deepEqual(await REFUSED_WITHOUT_OWNER(port, host, { origin: `http://${host}`, 'sec-fetch-site': 'same-origin' }), EXPECTED_WITHOUT_OWNER); const cross = await call(port, { path: '/api/requests/r1', host, headers: { origin: 'https://evil.test' } }); assert.deepEqual([cross.status, cross.body.error], [403, 'cross-origin']); assert.equal((await call(port, { path: '/api/config', host })).status, 200, 'the process still answers'); assert.equal((await call(port, { path: '/api/config', host })).body.requests ?? false, false); assert.deepEqual(crashes, []); }); test('in preview mode, named or loopback, a request URL is refused by the listener and the process keeps serving', async t => { const crashes = []; const onCrash = err => crashes.push(err); process.on('uncaughtException', onCrash); t.after(() => process.off('uncaughtException', onCrash)); const preview = { host: 'console.example', header: 'x-user', user: 'jason' }; const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => []])); const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', preview, bus, log: () => {} }); t.after(() => close(web)); const port = web.address().port; assert.deepEqual(await REFUSED_WITHOUT_OWNER(port, preview.host, { 'x-user': 'jason' }), EXPECTED_WITHOUT_OWNER, 'the named host, signed in by the proxy header'); assert.deepEqual(await REFUSED_WITHOUT_OWNER(port, preview.host, { 'x-user': 'jason', origin: `https://${preview.host}`, 'sec-fetch-site': 'same-origin' }), EXPECTED_WITHOUT_OWNER); const unsigned = await call(port, { path: '/api/requests/r1', host: preview.host }); assert.equal(unsigned.status, 401, 'the proxy header is still required first'); assert.deepEqual(await REFUSED_WITHOUT_OWNER(port, `127.0.0.1:${port}`), EXPECTED_WITHOUT_OWNER, 'a loopback Host'); assert.equal((await call(port, { path: '/api/config', host: preview.host, headers: { 'x-user': 'jason' } })).status, 200, 'the process still answers'); assert.deepEqual(crashes, []); }); test('startServer refuses request transports without sso, without send, or on the tester listener', async () => { const fn = async () => ({}); await assert.rejects(startServer({ port: 0, requestRead: fn }), /Console requests need --sso/); await assert.rejects(startServer({ port: 0, requestWrite: fn, send: async () => ({}) }), /Console requests need --sso/); await assert.rejects(startServer({ port: 0, tester: true, testerSocket: {}, requestRead: fn }), /Console requests need --sso/); await assert.rejects(startServer({ port: 0, requestRead: 'x' }), /Console requests need --sso/); }); test('the request verbs are the console wrapper\'s, and the Console names no owner CLI verb for them', async () => { const { readFileSync } = await import('node:fs'); for (const verb of ['request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.mode.console', 'request.cancel.console']) assert.ok(CONSOLE_VERBS.includes(verb), verb); const cli = readFileSync(new URL('../src/cli.mjs', import.meta.url), 'utf8'); assert.doesNotMatch(cli, /['"]request\.(get|projects|answer|attach|mode|cancel)(?!\.console)['"]/); const http = readFileSync(new URL('../src/request-http.mjs', import.meta.url), 'utf8'); assert.doesNotMatch(http, /['"]request\.(get|projects|answer|attach|mode|cancel|propose|open|question)['"]/, 'the HTTP seam names only .console verbs'); }); test('revoke binds the route to the live request project and snapshot, sends only catalogue fields, and validates its receipt', async () => { const s = setup({ read: async () => ACTIVE, write: async () => REVOKED }); const res = await s.post('revoke'); assert.equal(res.status, 200, res.text); assert.deepEqual(res.json(), REVOKED); assert.deepEqual(s.reads, [{ verb: 'request.get.console', args: { request: 'r1' }, ctx: { subject: SUBJECT } }]); assert.deepEqual(s.writes, [{ verb: 'prd.revoke.console', args: BODIES.revoke, ctx: { subject: SUBJECT } }]); assert.doesNotMatch(s.lines.join('\n'), /Replace the draft|opaque host receipt/); for (const body of [ { ...BODIES.revoke, project: 'other' }, { ...BODIES.revoke, snapshot: 'prd-other' }, ]) { const bound = setup({ read: async () => ACTIVE }); refused(await bound.post('revoke', body), 'requirement-snapshot-refused'); assert.equal(bound.writes.length, 0); } for (const reply of [null, { ...ACTIVE, request: 'r2' }, { ...ACTIVE, snapshot: null }]) { const bound = setup({ read: async () => reply }); const out = await bound.post('revoke'); assert.ok(out.status >= 400 && out.status !== 200, out.text); assert.equal(bound.writes.length, 0); } for (const reply of [null, ACTIVE, { ...REVOKED, snapshot: 'other' }, { ...REVOKED, kind: 'activated' }, { ...REVOKED, revision: 3 }]) { const bad = setup({ read: async () => ACTIVE, write: async () => reply }); refused(await bad.post('revoke'), 'outcome-unknown'); assert.equal(bad.writes.length, 1); } }); test('revoke enforces body, origin, transport and current-session gates before any write', async () => { const path = '/api/requests/r1/revoke', s = setup({ read: async () => ACTIVE }); for (const body of [ { ...BODIES.revoke, request: 'r2' }, { ...BODIES.revoke, business: 'demo' }, { ...BODIES.revoke, subject: SUBJECT }, { ...BODIES.revoke, revision: 0 }, { ...BODIES.revoke, project: '../x' }, { ...BODIES.revoke, snapshot: '' }, { ...BODIES.revoke, reason: '' }, { ...BODIES.revoke, reason: ' ' }, { ...BODIES.revoke, reason: 'x'.repeat(1025) }, { ...BODIES.revoke, reason: 'a\0b' }, { ...BODIES.revoke, op: '' }, ]) refused(await s.post('revoke', body), 'invalid-request'); refused(await s.run(path, request({ raw: '{' })), 'invalid-request'); refused(await s.run(path, request({ body: BODIES.revoke, headers: { 'content-type': ['text/plain'] } })), 'invalid-request'); refused(await s.run(path, request({ raw: 'x'.repeat(4097) })), 'request-too-large'); refused(await s.run(path, request({ method: 'GET' })), 'method-not-allowed'); refused(await s.run(path, request({ body: BODIES.revoke }), { requestWrite: null }), 'read-only'); refused(await s.run(path, request({ body: BODIES.revoke }), { requestRead: null }), 'unavailable'); refused(await s.run(path, request({ body: BODIES.revoke }), { session: null }), 'sign-in-required'); for (const headers of [{ origin: null }, { origin: ['https://evil.test'] }, { origin: [ORIGIN, ORIGIN] }, { 'sec-fetch-site': ['cross-site'] }, { 'sec-fetch-site': null }]) refused(await s.run(path, request({ body: BODIES.revoke, headers })), 'cross-origin'); s.current = null; refused(await s.post('revoke'), 'sign-in-required'); assert.equal(s.writes.length, 0); // Sign-out while the route's binding read is in flight must still stop the write. const late = setup({ read: async () => { late.current = null; return ACTIVE; } }); refused(await late.post('revoke'), 'sign-in-required'); assert.equal(late.writes.length, 0); }); test('draft panel data relays empty snapshots and refuses malformed counters and stale reads', async () => { const empty = { candidate: null, prd: null, requirements: [], snapshot: { id: null, revision: 2 } }; const s = setup({ read: async () => empty }); assert.deepEqual((await s.get('/api/requests/r1/draft')).json(), empty); for (const data of [{ ...DRAFT, snapshot: null }, { ...DRAFT, snapshot: { id: 'prd-active', revision: -1 } }, { ...DRAFT, snapshot: { id: 'prd-active', revision: 0 } }, { ...DRAFT, snapshot: { id: null, revision: '3' } }, { ...DRAFT, requirements: [{ ...DRAFT.requirements[0], id: 'other' }] }]) { const bad = setup({ read: async () => data }); refused(await bad.get('/api/requests/r1/draft'), 'unavailable'); } for (const code of ['prd-candidate-stale', 'stale-spec', 'request-revision-conflict']) { const bad = setup({ read: async () => { throw new BusReadError(code); } }); const res = await bad.get('/api/requests/r1/draft'); assert.equal(res.status, 409, res.text); assert.equal(res.json().error, code); } }); test('revoke refusals including stale revisions never become success or retry', async () => { for (const [code, status] of [['request-revision-conflict', 409], ['request-operation-conflict', 409], ['requirement-snapshot-refused', 403], ['requirement-snapshot-revoked', 409], ['human-required', 403], ['processing-halted', 409], ['read-only', 403]]) { const s = setup({ read: async () => ACTIVE, write: async () => { throw new BusReadError(code); } }); const res = await s.post('revoke'); assert.equal(res.status, status, res.text); assert.equal(res.json().error, code); assert.equal(s.writes.length, 1); } }); test('the signed-in owner draft and revoke listener use the snapshot revision and preserve stale refusals', async t => { const writes = []; const env = await listener(t, { requestRead: async verb => verb === 'request.draft.console' ? DRAFT : ACTIVE, requestWrite: async (verb, args, ctx) => { writes.push({ verb, args, ctx }); if (args.revision !== 3) throw new BusReadError('request-revision-conflict'); return REVOKED; }, }); const cookie = await env.signIn(); const draft = await env.get(cookie, '/api/requests/r1/draft'); assert.equal(draft.status, 200); assert.deepEqual(draft.body, DRAFT); const stale = await env.post(cookie, '/api/requests/r1/revoke', { ...BODIES.revoke, revision: 2 }); assert.deepEqual([stale.status, stale.body.error], [409, 'request-revision-conflict']); const ok = await env.post(cookie, '/api/requests/r1/revoke', { ...BODIES.revoke, revision: draft.body.snapshot.revision }); assert.equal(ok.status, 200); assert.deepEqual(ok.body, REVOKED); assert.equal(writes.length, 2, 'no implicit retry'); assert.equal(writes[1].verb, 'prd.revoke.console'); assert.deepEqual(writes[1].args, BODIES.revoke); assert.equal(writes[1].ctx.subject.sub, 'user-a'); }); test('an archived project attachment is a known 409 refusal', async () => { const s = setup({ write: async () => { throw new BusReadError('project-archived'); } }); const res = await s.post('attach'); assert.equal(res.status, 409); assert.equal(res.json().error, 'project-archived'); assert.equal(s.writes.length, 1); });