Files
stack/packages/webui/tests/user-option-swap.test.mjs

157 lines
9.8 KiB
JavaScript

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