157 lines
9.8 KiB
JavaScript
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'));
|
|
});
|