import { ownerAccounts } from './helpers/owner-accounts.mjs'; // FED R6 (#1800, federation design 3.1 D3-D5, 3.4 and 3.7): a granted user signs in to the // Console. Every provider is the fake OP in helpers/fake-oidc.mjs on loopback; no test reaches a // real provider, the live Console or a real config, slot or access file. The broker is an // in-process Broker with an access function (bus/tests/user-principal.test.mjs), reached through a // per-session user transport that binds the user on every call, as user-cli.mjs does. The real // user-cli spawn is checked on its own in G8 against a recording socket. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { request, createServer as createHttpServer } from 'node:http'; import { createServer as createNetServer } from 'node:net'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Store } from '../../bus/src/store.mjs'; import { Broker } from '../../bus/src/broker.mjs'; import { HUMAN_CLI } from '../../cli/src/transport.mjs'; import { startServer, userRoute } from '../src/serve.mjs'; import { createSso, SESSION_COOKIE } from '../src/sso.mjs'; import { consoleAccess } from '../src/access.mjs'; import { consoleBus, userOptionsFor } from '../src/console-bus.mjs'; import { BusReadError, USER_CLI, userCall } from '../src/bus.mjs'; import { close, fixture } from './fixture.mjs'; import { browser } from './browser.mjs'; import { fakeOidc } from './helpers/fake-oidc.mjs'; const HOST = 'console.example.test', ORIGIN = `https://${HOST}`; const PUBLIC = new URL('../src/public/', import.meta.url); const NOTICE = "The business owner sees your thread's agent's decisions, inbox entries, claims, tasks and project files, but not your messages."; const OWNER_MARKER = 'owner-plugins-marker-7f3a'; const FEDERATION = { self: 'hub', peers: [{ id: 'factory', origin: 'https://factory.example.test', label: 'Software factory' }] }; 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 setCookies = r => [r.headers['set-cookie'] ?? []].flat(); const sessionOf = r => setCookies(r).find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0'))?.split(';')[0] ?? null; const writeHeaders = cookie => ({ cookie, origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }); const grant = (over = {}) => ({ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over }); const ALICE = { id: 'alice', home: 'local', subjects: [{ provider: 'a', sub: 'alice-sub' }] }; // The fake OP, an in-process broker for business `demo`, createSso with the access hook, and the // human listener. The owner's options are counting stubs; each user session's options are // consoleBus over that user's transport. `counts` records every owner stub and user transport call. async function setup(t, { users = [ALICE], grants = [grant()] } = {}) { const op = await fakeOidc(); t.after(() => op.close()); const iss = op.issuer('a'); const root = mkdtempSync(join(tmpdir(), 'webui-granted-user-')); const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); const access = { doc: { accessVersion: 1, revision: 1, users, grants }, fail: false }; const load = () => { if (access.fail) throw new Error('access file refused'); return access.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 human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); const counts = { owner: 0, user: 0 }; // As user-cli.mjs: a fresh bind per call, and the broker's refusal code as the transport's. // `userVerbs` and `ownerVerbs` record what each transport carried for a user session. const userVerbs = [], ownerVerbs = []; const userTransport = user => async (verb, args = {}) => { counts.user++; userVerbs.push(verb); try { return broker.request(broker.bindUser({ business: 'demo', user }), { verb, args }); } catch (e) { throw new BusReadError(e.code ?? 'invalid-response'); } }; // The owner's transport as cli.mjs passes it (ownerCall). Its answer carries a marker that must // never reach a user's response. const ownerCall = async (verb, args) => { ownerVerbs.push([verb, args]); return { plugins: { channel: ['webui'] }, marker: OWNER_MARKER }; }; const opened = []; const userOptions = userOptionsFor({ owner: ownerCall, send: true, transport: user => { opened.push(user); return userTransport(user); } }); const lines = []; const log = line => lines.push(line); 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, users: consoleAccess({ load, business: () => 'demo' }) }); t.after(() => sso.close()); const stub = name => async () => { counts.owner++; return name === 'send' ? { id: 'msg-0001' } : [{ verb: name }]; }; const ownerBus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions', 'names', 'instances', 'routes', 'thread.claims'].map(v => [v, stub(v)])); const owner = { bus: ownerBus, send: stub('send'), settingsWrite: stub('settings'), projectFiles: stub('files'), projectFile: stub('file'), projectSave: stub('save'), nameWrite: stub('name'), assistantStop: stub('stop'), instancesWrite: stub('instances'), routesWrite: stub('routes'), agentWrite: stub('agent'), processing: { read: stub('processing'), halt: stub('halt'), resume: stub('resume') }, reads: Object.fromEntries(['settings', 'business', 'busStatus', 'queue', 'row'].map(v => [v, stub(v)])), chat: Object.fromEntries(['projects', 'threads', 'messages', 'send', 'open', 'createProject', 'createThread'].map(v => [v, stub(v)])), }; const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, log, ...owner, userOptions, federation: FEDERATION }); t.after(() => close(web)); const p1 = broker.request(human, { verb: 'project.create', args: { name: 'shared' } }).id; const p2 = broker.request(human, { verb: 'project.create', args: { name: 'private' } }).id; return { op, iss, broker, human, store, access, counts, opened, lines, port: web.address().port, p1, p2, userTransport, ownerCall, userVerbs, ownerVerbs }; } // A whole sign-in with `sub`: start, a code at the OP, the callback. 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 } }); return { r, session: sessionOf(r) }; } const events = env => env.store.get('SELECT count(*) n FROM events').n; // Every table's row count but events', for a "nothing else written" pin. const tables = env => Object.fromEntries(env.store.all("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('events', 'sqlite_sequence') ORDER BY name") .map(({ name }) => [name, env.store.get(`SELECT count(*) n FROM "${name}"`).n])); test('R6 G1 a granted user signs in, gets the user page and reaches the broker as itself', async t => { const env = await setup(t); env.access.doc.grants = [grant({ projects: [env.p1] })]; const { r, session } = await signIn(env, 'alice-sub'); assert.equal(r.status, 303, JSON.stringify(r.body)); assert.ok(session, 'a session cookie was set'); const config = await call(env.port, { path: '/api/config', headers: { cookie: session } }); assert.equal(config.status, 200); assert.equal(config.body.user, true); assert.equal(config.body.edit, false); assert.deepEqual(config.body.federation, FEDERATION); const page = await call(env.port, { path: '/', headers: { cookie: session } }); assert.equal(page.status, 200); assert.match(page.body, /