Files
stack/packages/webui/tests/decide-sso.test.mjs

269 lines
18 KiB
JavaScript

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/);
}
});