267 lines
16 KiB
JavaScript
267 lines
16 KiB
JavaScript
// Row 252 (#1753): the Console decision endpoint, driven with a fake request, response and bus
|
|
// transport. The real bus side is in packages/bus/tests/console-decide.test.mjs and the page side
|
|
// in the browser tests.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { BusReadError } from '../src/bus.mjs';
|
|
import { DECISION_REFUSALS, createConfirmations, decisionHTTP } from '../src/decision-http.mjs';
|
|
|
|
const ORIGIN = 'https://console.test';
|
|
const DIGEST = 'a'.repeat(64);
|
|
const PREVIEW = {
|
|
id: 'd1', business: 'demo', question: 'Enroll host?', digest: DIGEST,
|
|
approve: { key: 'yes', text: 'Yes, enroll it' }, deny: { key: 'no', text: 'No' },
|
|
grant: { method: 'host-enroll', host: 'h1' },
|
|
};
|
|
|
|
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({ answer = () => ({ ...PREVIEW }), resolve = (a) => ({ id: a.id, op: 'resolved', choice: a.choice, grant: 'started' }), now } = {}) {
|
|
const session = { name: 'jason', provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' };
|
|
const calls = [], lines = [];
|
|
const s = {
|
|
session, calls, lines, current: session,
|
|
confirmations: createConfirmations(now ? { now } : {}),
|
|
decide: {
|
|
origin: ORIGIN,
|
|
call: async (verb, args, ctx) => {
|
|
calls.push({ verb, args, ctx });
|
|
return verb === 'decision.preview.console' ? answer(args) : resolve(args);
|
|
},
|
|
},
|
|
};
|
|
s.run = async (path, req) => {
|
|
const res = response();
|
|
await decisionHTTP(req, res, { path, decide: s.decide, session: s.session, live: () => s.current, confirmations: s.confirmations, log: (l) => lines.push(l) });
|
|
return res;
|
|
};
|
|
s.confirm = (choice = 'yes', id = 'd1') => s.run(`/api/decisions/${id}/confirmation`, request({ body: { choice } }));
|
|
s.resolve = (body, id = 'd1') => s.run(`/api/decisions/${id}/resolve`, request({ body }));
|
|
return s;
|
|
}
|
|
const refused = (res, code) => {
|
|
assert.equal(res.status, DECISION_REFUSALS[code][0], `${code}: ${res.text}`);
|
|
assert.deepEqual(res.json(), { error: code, message: DECISION_REFUSALS[code][1] });
|
|
assert.equal(res.headers['cache-control'], 'no-store');
|
|
};
|
|
|
|
test('a confirmation then a resolve answers once with the held choice and digest, and a second resolve refuses', async () => {
|
|
const s = setup();
|
|
const c = await s.confirm('yes');
|
|
assert.equal(c.status, 200);
|
|
const body = c.json();
|
|
assert.match(body.confirmation, /^[a-f0-9]{64}$/);
|
|
assert.deepEqual({ ...body, confirmation: 'x' }, { id: 'd1', question: 'Enroll host?', action: 'approve', choice: 'yes', text: 'Yes, enroll it', grant: PREVIEW.grant, confirmation: 'x' });
|
|
assert.deepEqual(s.calls[0], { verb: 'decision.preview.console', args: { id: 'd1' }, ctx: { subject: { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' } } });
|
|
const r = await s.resolve({ choice: 'yes', confirmation: body.confirmation });
|
|
assert.equal(r.status, 200);
|
|
assert.deepEqual(r.json(), { id: 'd1', op: 'resolved', choice: 'yes', grant: 'started' });
|
|
assert.deepEqual(s.calls[1], { verb: 'decision.resolve.console', args: { id: 'd1', choice: 'yes', digest: DIGEST }, ctx: { subject: { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' }, business: 'demo' } });
|
|
// J12: the confirmation is spent, so a repeat never reaches the bus.
|
|
refused(await s.resolve({ choice: 'yes', confirmation: body.confirmation }), 'confirmation-required');
|
|
assert.equal(s.calls.length, 2);
|
|
assert.equal(s.lines.length, 3);
|
|
assert.match(s.lines[0], / decide user="jason" decision="d1" step=confirmation action=approve$/);
|
|
assert.match(s.lines[1], / step=resolve choice="yes" grant=started$/);
|
|
assert.doesNotMatch(s.lines.join('\n'), new RegExp(body.confirmation));
|
|
});
|
|
|
|
test('deny resolves without a grant field', async () => {
|
|
const s = setup({ resolve: (a) => ({ id: a.id, op: 'resolved', choice: a.choice }) });
|
|
const c = (await s.confirm('no')).json();
|
|
assert.equal(c.action, 'deny');
|
|
assert.equal(c.text, 'No');
|
|
const r = await s.resolve({ choice: 'no', confirmation: c.confirmation });
|
|
assert.deepEqual(r.json(), { id: 'd1', op: 'resolved', choice: 'no' });
|
|
});
|
|
|
|
const ORDINARY = {
|
|
id: 'd1', business: 'demo', question: 'How should we handle this request?', digest: DIGEST, kind: 'decision',
|
|
options: [{ key: 'new/autonomous', text: 'New project — autonomous' }, { key: 'new/guided', text: 'New project — guided' }, { key: 'existing:project', text: 'Add to existing project' }],
|
|
};
|
|
test('ordinary confirmation carries the selected third key/text and decision context, then resolves once in its business', async () => {
|
|
const s = setup({ answer: () => ORDINARY, resolve: a => ({ id: a.id, op: 'resolved', choice: a.choice }) });
|
|
const c = await s.confirm('existing:project');
|
|
assert.equal(c.status, 200, c.text);
|
|
assert.deepEqual({ ...c.json(), confirmation: 'x' }, { id: 'd1', kind: 'decision', question: ORDINARY.question, action: 'answer', choice: 'existing:project', text: 'Add to existing project', confirmation: 'x' });
|
|
const body = { choice: 'existing:project', confirmation: c.json().confirmation };
|
|
assert.deepEqual((await s.resolve(body)).json(), { id: 'd1', op: 'resolved', choice: 'existing:project' });
|
|
assert.equal(s.calls[1].ctx.business, 'demo');
|
|
assert.equal(s.calls[1].args.digest, DIGEST);
|
|
refused(await s.resolve(body), 'confirmation-required');
|
|
assert.equal(s.calls.length, 2);
|
|
});
|
|
|
|
test('ordinary options are validated authoritatively at confirmation, including two and nine choices', async () => {
|
|
for (const options of [ORDINARY.options.slice(0, 2), Array.from({ length: 9 }, (_, i) => ({ key: `key:${i}`, text: `Answer ${i}` }))]) {
|
|
const s = setup({ answer: () => ({ ...ORDINARY, options }) });
|
|
assert.equal((await s.confirm(options.at(-1).key)).status, 200);
|
|
refused(await s.confirm('not-listed'), 'decision-stale');
|
|
}
|
|
for (const options of [[], ORDINARY.options.slice(0, 1), Array(10).fill(ORDINARY.options[0]), [ORDINARY.options[0], ORDINARY.options[0]], [{ key: 1, text: 'x' }, ORDINARY.options[1]], [{ key: 'x' }, ORDINARY.options[1]]]) {
|
|
const s = setup({ answer: () => ({ ...ORDINARY, options }) });
|
|
refused(await s.confirm('new/autonomous'), 'confirmation-unavailable');
|
|
assert.equal(s.confirmations.size, 0);
|
|
}
|
|
});
|
|
|
|
test('ordinary stale/closed and uncertain outcomes spend their confirmation and never replay', async () => {
|
|
for (const code of ['decision-stale', 'decision-closed', 'outcome-unknown']) {
|
|
const s = setup({ answer: () => ORDINARY, resolve: () => { throw new BusReadError(code); } });
|
|
const c = await s.confirm('new/guided');
|
|
assert.equal(c.status, 200, c.text);
|
|
const body = { choice: 'new/guided', confirmation: c.json().confirmation };
|
|
refused(await s.resolve(body), code);
|
|
refused(await s.resolve(body), 'confirmation-required');
|
|
assert.equal(s.calls.length, 2);
|
|
}
|
|
});
|
|
|
|
test('ordinary confirmations stay bound to the issuing session, choice, decision and business', async () => {
|
|
const s = setup({ answer: () => ORDINARY, resolve: a => ({ id: a.id, op: 'resolved', choice: a.choice }) });
|
|
let c = (await s.confirm('new/guided')).json();
|
|
refused(await s.resolve({ choice: 'existing:project', confirmation: c.confirmation }), 'confirmation-required');
|
|
c = (await s.confirm('new/guided')).json();
|
|
refused(await s.resolve({ choice: 'new/guided', confirmation: c.confirmation }, 'd2'), 'confirmation-required');
|
|
c = (await s.confirm('new/guided')).json();
|
|
s.current = null;
|
|
refused(await s.resolve({ choice: 'new/guided', confirmation: c.confirmation }), 'sign-in-required');
|
|
s.current = s.session;
|
|
refused(await s.resolve({ choice: 'new/guided', confirmation: c.confirmation }), 'confirmation-required');
|
|
const other = setup({ answer: () => ({ ...ORDINARY, business: 'other' }) });
|
|
other.confirmations = s.confirmations;
|
|
c = (await s.confirm('new/guided')).json();
|
|
refused(await other.resolve({ choice: 'new/guided', confirmation: c.confirmation }), 'confirmation-required');
|
|
assert.equal(s.calls.filter(x => x.verb === 'decision.resolve.console').length, 0);
|
|
assert.equal(other.calls.length, 0);
|
|
});
|
|
|
|
test('the gates before the body: method, read-only, sign-in and same origin', async () => {
|
|
const s = setup();
|
|
refused(await s.run('/api/decisions/d1/confirmation', request({ method: 'GET' })), 'method-not-allowed');
|
|
const res = response();
|
|
await decisionHTTP(request({ body: { choice: 'yes' } }), res, { path: '/api/decisions/d1/confirmation', decide: null, session: s.session, live: () => s.session, confirmations: s.confirmations, log: () => {} });
|
|
refused(res, 'read-only');
|
|
const out = response();
|
|
await decisionHTTP(request({ body: { choice: 'yes' } }), out, { path: '/api/decisions/d1/confirmation', decide: s.decide, session: null, live: () => null, confirmations: s.confirmations, log: () => {} });
|
|
refused(out, 'sign-in-required');
|
|
for (const headers of [{ origin: ['https://evil.test'] }, { origin: null }, { origin: [ORIGIN, ORIGIN] }, { 'sec-fetch-site': ['same-site'] }, { 'sec-fetch-site': null }]) {
|
|
refused(await s.run('/api/decisions/d1/confirmation', request({ body: { choice: 'yes' }, headers })), 'cross-origin');
|
|
}
|
|
assert.equal(s.calls.length, 0);
|
|
});
|
|
|
|
test('a bad body, id or content type refuses before the bus', async () => {
|
|
const s = setup();
|
|
const path = '/api/decisions/d1/confirmation';
|
|
refused(await s.run(path, request({ body: { choice: 'yes' }, 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');
|
|
refused(await s.run(path, request({ body: { choice: 'yes', extra: 1 } })), 'invalid-request');
|
|
refused(await s.run(path, request({ body: { choice: 1 } })), 'invalid-request');
|
|
refused(await s.run(path, request({ raw: JSON.stringify({ choice: 'y'.repeat(5000) }) })), 'request-too-large');
|
|
refused(await s.run('/api/decisions/..%2Fx/confirmation', request({ body: { choice: 'yes' } })), 'decision-not-found');
|
|
refused(await s.run('/api/decisions/%E0%A4%A/confirmation', request({ body: { choice: 'yes' } })), 'decision-not-found');
|
|
refused(await s.resolve({ choice: 'yes' }), 'invalid-request');
|
|
refused(await s.resolve({ choice: 'yes', confirmation: 'a'.repeat(64), more: 1 }), 'invalid-request');
|
|
assert.equal(s.calls.length, 0);
|
|
});
|
|
|
|
test('a choice that is neither key is decision-stale, and a malformed preview is confirmation-unavailable', async () => {
|
|
refused(await setup().confirm('maybe'), 'decision-stale');
|
|
for (const bad of [{ ...PREVIEW, id: 'd2' }, { ...PREVIEW, digest: 'short' }, { ...PREVIEW, deny: { key: 'yes', text: 'No' } }, { ...PREVIEW, grant: null }, null]) {
|
|
const s = setup({ answer: () => bad });
|
|
refused(await s.confirm('yes'), 'confirmation-unavailable');
|
|
assert.equal(s.confirmations.size, 0);
|
|
}
|
|
});
|
|
|
|
test('bus refusals map by code, and an unknown or missing code is unavailable at preview and outcome-unknown at resolve', async () => {
|
|
const cases = [
|
|
['human-required', 'human-required'], ['agent-required', 'human-required'], ['access-revoked', 'human-required'],
|
|
['peercred-required', 'peercred-required'], ['decision-not-eligible', 'decision-not-eligible'],
|
|
['decision-closed', 'decision-closed'], ['decision-stale', 'decision-stale'], ['invalid-choice', 'decision-stale'],
|
|
['decision-not-found', 'decision-not-found'], ['outcome-unknown', 'confirmation-unavailable'], ['weird', 'confirmation-unavailable'],
|
|
];
|
|
for (const [bus, code] of cases) {
|
|
const s = setup({ answer: () => { throw new BusReadError(bus); } });
|
|
refused(await s.confirm('yes'), code);
|
|
}
|
|
refused(await setup({ answer: () => { throw new Error('boom'); } }).confirm('yes'), 'confirmation-unavailable');
|
|
for (const [bus, code] of [['decision-closed', 'decision-closed'], ['outcome-unknown', 'outcome-unknown'], ['weird', 'outcome-unknown']]) {
|
|
const s = setup({ resolve: () => { throw new BusReadError(bus); } });
|
|
const c = (await s.confirm('yes')).json();
|
|
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), code);
|
|
// Nothing retries, and the confirmation stays spent.
|
|
assert.equal(s.calls.length, 2);
|
|
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required');
|
|
}
|
|
for (const reply of [null, { id: 'd2', op: 'resolved', choice: 'yes' }, { id: 'd1', op: 'refused', choice: 'yes' }, { id: 'd1', op: 'resolved', choice: 'no' }]) {
|
|
const s = setup({ resolve: () => reply });
|
|
const c = (await s.confirm('yes')).json();
|
|
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'outcome-unknown');
|
|
}
|
|
});
|
|
|
|
test('a confirmation binds the session, decision and choice, and any mismatch spends it', async () => {
|
|
const s = setup();
|
|
let c = (await s.confirm('yes')).json();
|
|
refused(await s.resolve({ choice: 'no', confirmation: c.confirmation }), 'confirmation-required');
|
|
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required');
|
|
c = (await s.confirm('yes')).json();
|
|
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }, 'd2'), 'confirmation-required');
|
|
c = (await s.confirm('yes')).json();
|
|
const other = setup();
|
|
other.confirmations = s.confirmations;
|
|
other.session = { ...s.session };
|
|
other.current = other.session;
|
|
refused(await other.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required');
|
|
refused(await s.resolve({ choice: 'yes', confirmation: 'not-a-token' }), 'confirmation-required');
|
|
assert.equal(s.calls.filter((x) => x.verb === 'decision.resolve.console').length, 0);
|
|
assert.equal(other.calls.length, 0);
|
|
});
|
|
|
|
test('a session that ends between the steps refuses before the bus', async () => {
|
|
const s = setup();
|
|
s.current = null;
|
|
refused(await s.confirm('yes'), 'sign-in-required');
|
|
assert.equal(s.calls.length, 0);
|
|
s.current = s.session;
|
|
const c = (await s.confirm('yes')).json();
|
|
s.current = { ...s.session };
|
|
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'sign-in-required');
|
|
assert.equal(s.calls.length, 1);
|
|
// A sign-out during the preview also refuses, and no confirmation is issued.
|
|
const t = setup({ answer: () => { t.current = null; return { ...PREVIEW }; } });
|
|
refused(await t.confirm('yes'), 'sign-in-required');
|
|
assert.equal(t.confirmations.size, 0);
|
|
});
|
|
|
|
test('confirmations expire after the TTL, cap at the limit without dropping a live one, and are one use', () => {
|
|
let t = 0;
|
|
const store = createConfirmations({ ttlMs: 1000, max: 2, now: () => t });
|
|
const bind = { session: 's', business: 'demo', decision: 'd1', choice: 'yes', digest: DIGEST };
|
|
const a = store.issue(bind), b = store.issue(bind);
|
|
assert.equal(store.issue(bind), null);
|
|
assert.equal(store.size, 2);
|
|
assert.equal(store.consume(a, bind).digest, DIGEST);
|
|
assert.equal(store.consume(a, bind), null);
|
|
t = 1000;
|
|
assert.equal(store.consume(b, bind), null);
|
|
assert.equal(store.size, 0);
|
|
assert.match(store.issue(bind), /^[a-f0-9]{64}$/);
|
|
assert.equal(store.consume(undefined, bind), null);
|
|
assert.equal(store.consume('A'.repeat(64), bind), null);
|
|
});
|