531 lines
35 KiB
JavaScript
531 lines
35 KiB
JavaScript
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);
|
|
});
|