import { ownerAccounts } from './helpers/owner-accounts.mjs'; // Row 252 (#1753, design sections 4 and 5): the Console decision routes on the human listener, signed // in through the fake OP in helpers/fake-oidc.mjs on loopback. The bus transport is a recording stub, // so this covers the listener's gates and the session it passes; decision-http.test.mjs covers the // endpoint's own table and console-decide.test.mjs the bus. The public name is console.example.test, // sent only as a Host header to the test's own listener. sso.test.mjs pins its own log lines, so the // decide lines are checked here. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { request } from 'node:http'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Broker } from '../../bus/src/broker.mjs'; import { Store } from '../../bus/src/store.mjs'; import { views } from '../../bus/src/views.mjs'; import { browser } from './browser.mjs'; import { startServer } from '../src/serve.mjs'; import { createSso, SESSION_COOKIE } from '../src/sso.mjs'; import { DECISION_REFUSALS } from '../src/decision-http.mjs'; import { consoleCall } from '../src/bus.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 HOST = 'console.example.test', ORIGIN = `https://${HOST}`; const DIGEST = 'b'.repeat(64); const PREVIEW = { id: 'd1', business: 'demo', question: 'Enroll web1?', digest: DIGEST, approve: { key: 'yes', text: 'Yes' }, deny: { key: 'no', text: 'No' }, grant: { method: 'host-enroll', host: 'web1' } }; // node:http, not fetch: fetch can't set Host, Origin or Sec-Fetch-Site. 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 = request({ 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); req.end(body); }); } const writeHeaders = cookie => ({ ...(cookie ? { cookie } : {}), origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }); // The fake OP, createSso over it, and the human listener. `server` overrides startServer options. // The decision transport records each call and answers a preview or a resolve. `access`, when given, // is the access document: createSso gets the users hook and the listener a user transport, as // granted-user.test.mjs sets them up. async function setup(t, server = {}, access = null) { const op = await fakeOidc(); t.after(() => op.close()); const iss = op.issuer('a'); const config = { publicOrigin: ORIGIN, 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 calls = []; const decisionWrite = async (verb, args, ctx) => { calls.push({ verb, args, ctx }); return verb === 'decision.preview.console' ? { ...PREVIEW } : { id: args.id, op: 'resolved', choice: args.choice }; }; 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' }), decisionWrite, 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' }); assert.equal(start.status, 303); 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, iss, calls, lines, signIn, post }; } const refused = (r, code) => { assert.equal(r.status, DECISION_REFUSALS[code][0], `${code}: ${r.status} ${JSON.stringify(r.body)}`); assert.deepEqual(r.body, { error: code, message: DECISION_REFUSALS[code][1] }); }; test('a signed-in owner confirms and answers, with the session\'s provider, issuer and sub as the subject', async t => { const env = await setup(t); const session = await env.signIn(); const config = await call(env.port, { path: '/api/config', headers: { cookie: session } }); assert.equal(config.body.decide, true); assert.equal(config.body.sso, true); const c = await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' }); assert.equal(c.status, 200, JSON.stringify(c.body)); const r = await env.post(session, '/api/decisions/d1/resolve', { choice: 'yes', confirmation: c.body.confirmation }); assert.equal(r.status, 200, JSON.stringify(r.body)); assert.deepEqual(r.body, { id: 'd1', op: 'resolved', choice: 'yes' }); const subject = { provider: 'a', iss: env.iss.issuer, sub: 'user-a' }; assert.deepEqual(env.calls, [ { verb: 'decision.preview.console', args: { id: 'd1' }, ctx: { subject } }, { verb: 'decision.resolve.console', args: { id: 'd1', choice: 'yes', digest: DIGEST }, ctx: { subject, business: 'demo' } }, ]); assert.match(env.lines.at(-1), / decide user="person-a" decision="d1" step=resolve choice="yes"$/); assert.equal(env.lines.some(l => l.includes(c.body.confirmation) || l.includes(session.split('=')[1])), false); }); test('a signed-in browser selects the third ordinary option through HTTP and records exactly one audited resolution and raiser notification, with zero grants', { timeout: 120000 }, async t => { const root = mkdtempSync(join(tmpdir(), 'console-797-sso-')); const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); const business = { id: 'demo', human: 'jason', arbiters: { delivery: 'pm', technical: 'pm' }, roles: { chief: { authority: { withinRole: ['message.send'], crossRole: [] } }, pm: { authority: { withinRole: ['message.send'], crossRole: [] } } } }; let grants = 0; const broker = new Broker({ store, businesses: { demo: business }, hostGrant: { enroll: async () => { grants++; } } }); const chief = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' }); broker.request(chief, { verb: 'role.claim' }); const d = broker.request(chief, { verb: 'decision.raise', args: { action: 'prd.approve', question: 'Choose a product project', blocking: false, options: [{ key: 'new/autonomous', text: 'New project — autonomous' }, { key: 'new/guided', text: 'New project — guided' }, { key: 'existing:project', text: 'Add to existing project' }], recommendation: 'new/autonomous', approvalChoice: 'new/autonomous' } }); const reader = broker.bindReader({ business: 'demo' }); const bus = views({ call: async (verb, args = {}) => broker.request(reader, { verb, args }) }); const calls = []; const env = await setup(t, { bus, decisionWrite: async (verb, args, ctx) => { calls.push({ verb, args, ctx }); const cap = broker.bindConsole({ business: ctx.business ?? 'demo', human: 'jason', subject: ctx.subject }); try { return broker.request(cap, { verb, args }); } finally { broker.disconnect(cap); } } }); const session = await env.signIn(); const b = await browser(); t.after(() => b.close()); // Existing CDP harness pattern: fulfill a synthetic HTTPS origin from this scratch listener, // carrying the real fake-OP session just established above; no live Console or proxy is used. b.on('Fetch.requestPaused', ({ requestId, request: incoming }) => { const url = new URL(incoming.url); const req = request({ host: '127.0.0.1', port: env.port, path: url.pathname + url.search, method: incoming.method, headers: { ...incoming.headers, host: HOST, cookie: session, origin: ORIGIN, 'sec-fetch-site': 'same-origin' } }, res => { const parts = []; res.on('data', part => parts.push(part)); res.on('end', () => { const responseHeaders = Object.entries(res.headers).filter(([name]) => !['connection', 'keep-alive', 'transfer-encoding'].includes(name)) .flatMap(([name, value]) => (Array.isArray(value) ? value : [value]).map(value => ({ name, value: String(value) }))); b.call('Fetch.fulfillRequest', { requestId, responseCode: res.statusCode, responseHeaders, body: Buffer.concat(parts).toString('base64') }).catch(() => {}); }); }); req.on('error', () => b.call('Fetch.failRequest', { requestId, errorReason: 'Failed' }).catch(() => {})); req.end(incoming.postData); }); await b.call('Fetch.enable', { patterns: [{ urlPattern: ORIGIN + '/*', requestStage: 'Request' }] }); await b.navigate(`${ORIGIN}/#/inbox/${d.id}`); const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return;await new Promise(r=>setTimeout(r,50))}throw new Error('timed out')})()`); await wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Decision") && document.querySelector("#bus-view .s1-opts")'); assert.equal(await b.evaluate('document.querySelectorAll("#decide-section [data-decide]").length'), 3); await b.evaluate('document.querySelector("[data-decide=option-2]").click()'); await wait('document.querySelector("[data-decide=confirm]")'); assert.equal(store.get("SELECT count(*) AS n FROM decision_events WHERE decision=? AND op='resolved'", d.id).n, 0); await b.evaluate('document.querySelector("[data-decide=confirm]").click(); document.querySelector("[data-decide=confirm]")?.click()'); await wait('document.querySelector("#decide-status")?.textContent.startsWith("Recorded")'); assert.match(await b.evaluate('document.querySelector("#decide-status").textContent'), /existing:project.*Add to existing project/); assert.doesNotMatch(await b.evaluate('document.querySelector("#decide-status").textContent'), /grant|denial|Approved|Denied/); const closes = store.all('SELECT * FROM decision_events WHERE decision=?', d.id); assert.equal(closes.length, 1); assert.deepEqual([closes[0].op, closes[0].by, closes[0].choice, closes[0].via], ['resolved', 'jason', 'existing:project', 'console']); const inputs = store.all("SELECT * FROM events WHERE kind='human.input' AND subject=?", d.id); assert.equal(inputs.length, 1); const input = JSON.parse(inputs[0].body); assert.deepEqual(input.subject, { provider: 'a', iss: env.iss.issuer, sub: 'user-a' }); assert.equal(input.via, 'console'); assert.equal(input.decisionSeq, closes[0].seq); const notices = store.all("SELECT * FROM messages WHERE decision=? AND class='DECISION' AND from_role='human'", d.id); assert.equal(notices.length, 1); assert.equal(notices[0].to_role, 'chief'); assert.equal(notices[0].from_run, 'human-console'); assert.equal(calls.filter(c => c.verb === 'decision.resolve.console').length, 1); assert.equal(grants, 0); assert.equal(store.get("SELECT count(*) AS n FROM events WHERE actor_run='grant'").n, 0); }); test('the listener gates: no session, a cross-origin page, a loopback Host, a GET, and a sign-out between the steps', async t => { const env = await setup(t); refused(await env.post(null, '/api/decisions/d1/confirmation', { choice: 'yes' }), 'sign-in-required'); const session = await env.signIn(); refused(await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' }, { origin: 'https://evil.test' }), 'cross-origin'); refused(await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' }, { 'sec-fetch-site': 'cross-site' }), 'cross-origin'); const loop = await call(env.port, { method: 'POST', path: '/api/decisions/d1/confirmation', host: `127.0.0.1:${env.port}`, headers: { cookie: session, 'content-type': 'application/json' }, body: '{"choice":"yes"}' }); assert.equal(loop.status, 403); refused(await call(env.port, { path: '/api/decisions/d1/confirmation', headers: { cookie: session } }), 'method-not-allowed'); assert.equal(env.calls.length, 0); const c = await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' }); assert.equal(c.status, 200); const out = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, origin: ORIGIN, 'sec-fetch-site': 'same-origin' } }); assert.equal(out.status, 303); refused(await env.post(session, '/api/decisions/d1/resolve', { choice: 'yes', confirmation: c.body.confirmation }), 'sign-in-required'); assert.equal(env.calls.length, 1); // A new session can't use the old session's confirmation. const again = await env.signIn(); refused(await env.post(again, '/api/decisions/d1/resolve', { choice: 'yes', confirmation: c.body.confirmation }), 'confirmation-required'); assert.equal(env.calls.length, 1); }); test('J16: without a decision transport the routes are read-only and /api/config says no; startServer refuses decide without sso or send', async t => { // A decision transport without --send never starts (serve.mjs refuses it). The configs that start // without send have no transport either, so they are checked with the missing transport. await assert.rejects(setup(t, { send: null }), /need --sso and --send/); for (const server of [{ decisionWrite: null }, { decisionWrite: null, send: null }]) { const env = await setup(t, server); const session = await env.signIn(); const config = await call(env.port, { path: '/api/config', headers: { cookie: session } }); assert.equal(config.body.decide, false); assert.equal(config.body.sso, true); refused(await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' }), 'read-only'); assert.equal(env.calls.length, 0); } const write = async () => ({}); await assert.rejects(startServer({ port: 0, decisionWrite: write, send: async () => ({}) }), /need --sso and --send/); await assert.rejects(startServer({ port: 0, tester: true, testerSocket: {}, decisionWrite: write }), /need --sso and --send/); const plain = await startServer({ port: 0, board: 'http://127.0.0.1:9' }); t.after(() => close(plain)); const port = plain.address().port, host = `127.0.0.1:${port}`; const r = await call(port, { method: 'POST', path: '/api/decisions/d1/confirmation', host, headers: { origin: `http://${host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }, body: '{"choice":"yes"}' }); refused(r, 'read-only'); const config = await call(port, { path: '/api/config', host }); assert.equal(config.body.decide, false); assert.equal('sso' in config.body, false); }); test('J19: a granted user\'s session gets decide false and the user page config, and a decision POST stops at owner-only', 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 setup(t, {}, access); // The listener has a decision transport and --send, and the owner's session decides. const owner = await env.signIn(); assert.equal((await call(env.port, { path: '/api/config', headers: { cookie: owner } })).body.decide, true); // The granted user's config says send (its own transport) but never decide. const alice = await env.signIn('alice-sub'); const config = await call(env.port, { path: '/api/config', headers: { cookie: alice } }); assert.equal(config.status, 200); assert.equal(config.body.user, true); assert.equal(config.body.send, true); assert.equal(config.body.decide, false); // userRoute refuses the decision routes before decisionHTTP, so no session reaches the transport. for (const step of ['confirmation', 'resolve']) { const r = await env.post(alice, `/api/decisions/d1/${step}`, { choice: 'yes', confirmation: 'c'.repeat(64) }); assert.equal(r.status, 403); assert.equal(r.body.error, 'owner-only'); } assert.equal(env.calls.length, 0); }); test('N4: decision.resolve has no Console route, the --send caller names no decision verb, and consoleCall takes only the two console verbs', async t => { const env = await setup(t); const session = await env.signIn(); for (const verb of ['decision.resolve', 'decision.resolve.console', 'decision.preview.console']) { for (const method of ['GET', 'POST']) { const r = await call(env.port, { method, path: `/api/bus/${verb}`, headers: writeHeaders(session), ...(method === 'POST' ? { body: '{}' } : {}) }); // A GET finds no such bus read. A POST stops earlier, at the preview's read-only refusal for // every write that has no route of its own (row 74). if (method === 'GET') assert.equal(r.status, 404, `${method} ${verb}`); else assert.deepEqual([r.status, r.body.error], [403, 'read-only'], `${method} ${verb}`); } } assert.equal(env.calls.length, 0); const cli = readFileSync(new URL('../src/cli.mjs', import.meta.url), 'utf8'); assert.doesNotMatch(cli, /['"]decision\./); const c = consoleCall({ dataRoot: '/nonexistent', socket: '/nonexistent', business: 'demo', cli: '/nonexistent/console-cli.mjs', timeoutMs: 1000 }); for (const verb of ['decision.resolve', 'decision.seen', 'message.send', 'inbox']) { await assert.rejects(c(verb, { id: 'd1', choice: 'yes' }, { subject: { provider: 'a', iss: 'i', sub: 's' } }), /the Console does not call/); } });