import { ownerAccounts } from './helpers/owner-accounts.mjs'; // R6-N1 (#1807, row 294 N1): the user-session option swap in serve.mjs, driven through HTTP. // granted-user.test.mjs G4 stops every owner route at the userRoute gate, so it never reaches the // swap behind it. Here serve.mjs loads with that gate stubbed open (a load hook keyed on one query // string; the product file is unchanged), and every owner route a user session might try must reach // no owner option. The owner transport may carry only the plugins read mos-1394 allowed. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { request } from 'node:http'; import { registerHooks } from 'node:module'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Store } from '../../bus/src/store.mjs'; import { Broker } from '../../bus/src/broker.mjs'; import { createSso, SESSION_COOKIE } from '../src/sso.mjs'; import { consoleAccess } from '../src/access.mjs'; import { userOptionsFor } from '../src/console-bus.mjs'; import { BusReadError } from '../src/bus.mjs'; import { close } from './fixture.mjs'; import { fakeOidc } from './helpers/fake-oidc.mjs'; const SERVE = new URL('../src/serve.mjs', import.meta.url).href, OPEN = `${SERVE}?gate=open`; const GATE = 'export function userRoute(path, method) {'; registerHooks({ load(url, context, next) { const r = next(url, context); if (url !== OPEN) return r; const source = String(r.source); // A renamed gate fails here rather than leaving the test on the closed gate. if (source.split(GATE).length !== 2) throw new Error('serve.mjs: the userRoute gate is not there exactly once'); return { ...r, source: source.replace(GATE, `${GATE} return true;`) }; }, }); const { startServer, userRoute } = await import(OPEN); const HOST = 'console.example.test', ORIGIN = `https://${HOST}`; function call(port, { method = 'GET', path = '/', headers = {}, body } = {}) { 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: HOST, ...length, ...headers } }, res => { let text = ''; res.on('data', c => (text += c)); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: text })); }); req.on('error', reject); req.end(body); }); } const setCookies = r => [r.headers['set-cookie'] ?? []].flat(); const writeHeaders = cookie => ({ cookie, origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }); // As granted-user.test.mjs setup: the fake OP, a broker for `demo`, createSso with the access hook, // counting owner stubs, and consoleBus over alice's transport for her session. Only startServer // differs: it is the gate-open copy. async function setup(t) { const op = await fakeOidc(); t.after(() => op.close()); const iss = op.issuer('a'); const root = mkdtempSync(join(tmpdir(), 'webui-option-swap-')); const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); const doc = { 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 load = () => doc; const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: {}, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } }, assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } }, }, definitions: { 'chief-of-staff': {}, assistant: { messaging: JSON.parse(readFileSync(new URL('../../../roles/assistant.json', import.meta.url), 'utf8')).messaging } }, }, }, access: load, }); const owner = [], user = [], opened = []; const userTransport = name => async (verb, args = {}) => { user.push(verb); try { return broker.request(broker.bindUser({ business: 'demo', user: name }), { verb, args }); } catch (e) { throw new BusReadError(e.code ?? 'invalid-response'); } }; const ownerCall = async (verb, args) => { owner.push(['transport', verb, args]); return { plugins: { channel: ['webui'] } }; }; const userOptions = userOptionsFor({ owner: ownerCall, send: true, transport: name => { opened.push(name); return userTransport(name); } }); const lines = []; const config = { publicOrigin: ORIGIN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'jason' }] }] }; const sso = await createSso(config, { accounts: ownerAccounts(config), secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), users: consoleAccess({ load, business: () => 'demo' }) }); t.after(() => sso.close()); // Every owner option is a stub that records its own name. const stub = name => async () => { owner.push(['option', name]); return name === 'send' ? { id: 'msg-0001' } : [{ verb: name }]; }; const options = { bus: Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions', 'names', 'instances', 'routes', 'thread.claims'].map(v => [v, stub(`bus.${v}`)])), send: stub('send'), settingsWrite: stub('settingsWrite'), projectFiles: stub('projectFiles'), projectFile: stub('projectFile'), projectSave: stub('projectSave'), nameWrite: stub('nameWrite'), assistantStop: stub('assistantStop'), instancesWrite: stub('instancesWrite'), routesWrite: stub('routesWrite'), agentWrite: stub('agentWrite'), processing: { read: stub('processing.read'), halt: stub('processing.halt'), resume: stub('processing.resume') }, reads: Object.fromEntries(['settings', 'business', 'busStatus', 'queue', 'row'].map(v => [v, stub(`reads.${v}`)])), chat: Object.fromEntries(['projects', 'threads', 'messages', 'send', 'open', 'createProject', 'createThread'].map(v => [v, stub(`chat.${v}`)])), }; const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, log: l => lines.push(l), ...options, userOptions }); t.after(() => close(web)); return { op, iss, owner, user, opened, lines, port: web.address().port }; } async function signIn(env, sub) { const start = await call(env.port, { path: '/auth/login' }); assert.equal(start.status, 303); const cookie = setCookies(start)[0].split(';')[0]; const location = start.headers.location; const g = env.op.grant(env.iss, location, { sub }); const q = new URLSearchParams({ code: g.code, state: new URL(location).searchParams.get('state') }); const r = await call(env.port, { path: `/auth/callback?${q}`, headers: { cookie } }); const session = setCookies(r).find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0'))?.split(';')[0]; assert.ok(session, `${sub}: no session cookie (${r.status})`); return session; } // granted-user.test.mjs G4's owner routes, plus the chat routes a user session reaches anyway. const ROUTES = [ ['GET', '/api/bus/inbox'], ['GET', '/api/bus/tasks'], ['GET', '/api/bus/names'], ['GET', '/api/bus/thread.claims'], ['POST', '/api/bus/message', { to: 'coder', body: 'hi' }], ['GET', '/api/settings'], ['POST', '/api/settings', { revision: 'x' }], ['POST', '/api/name', { role: 'assistant', name: 'x' }], ['POST', '/api/assistant/stop', {}], ['POST', '/api/instances', { instance: 'x' }], ['POST', '/api/routes', { routes: [] }], ['POST', '/api/agent/launch', { instance: 'x' }], ['GET', '/api/agent/request?id=x'], ['GET', '/api/processing'], ['POST', '/api/processing/halt', {}], ['GET', '/api/project-files?project=p'], ['GET', '/api/project-file?project=p&path=a'], ['POST', '/api/project-file', { project: 'p' }], ['GET', '/api/board'], ['GET', '/api/conversations'], ['POST', '/api/seen', {}], ['POST', '/api/reply', {}], ['GET', '/api/queue'], ['GET', '/api/queue/1'], ['GET', '/api/business'], ['GET', '/api/bus-status'], ['GET', '/api/task-fields'], ['GET', '/api/chat/projects'], ['GET', '/api/chat/instances'], ['GET', '/app.js'], ['GET', '/bus.js'], ['GET', '/agents.js'], ['GET', '/editor.js'], ['GET', '/shared/app.css'], ['GET', '/index.html'], ]; const go = (env, session, [method, path, value]) => call(env.port, { method, path, headers: value === undefined ? { cookie: session } : writeHeaders(session), body: value === undefined ? undefined : JSON.stringify(value) }); test('R6-N1 with the userRoute gate open, a user session reaches no owner option over HTTP', async t => { // The stub took: the gate-open copy lets an owner route through. assert.equal(userRoute('/api/bus/message', 'POST'), true); const env = await setup(t); // Positive control: the owner's session reaches the owner's send on this route. const ownerSession = await signIn(env, 'owner-sub'); const sent = await go(env, ownerSession, ['POST', '/api/bus/message', { to: 'coder', body: 'hi' }]); assert.deepEqual(env.owner, [['option', 'send']], `the owner's send did not run: ${sent.status} ${sent.body}`); env.owner.length = 0; const session = await signIn(env, 'alice-sub'); for (const route of ROUTES) { const r = await go(env, session, route); assert.doesNotMatch(r.body, /owner-only/, `${route[0]} ${route[1]}: the gate is still closed`); // Only the plugins read with {}, as granted-user.test.mjs G9 pins for the chat. for (const entry of env.owner) assert.deepEqual(entry, ['transport', 'plugins', {}], `${route[0]} ${route[1]}: ${JSON.stringify(entry)}`); } assert.ok(env.user.includes('message.send'), 'the message route never reached the user transport'); assert.ok(env.opened.length > 0 && env.opened.every(name => name === 'alice')); });