95 lines
8.8 KiB
JavaScript
95 lines
8.8 KiB
JavaScript
// M3: real accounts store, real OIDC code flow, isolated port-zero providers.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { request } from 'node:http';
|
|
import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { loadAccounts, withAccounts } from '../../organization/src/accounts.mjs';
|
|
import { createSso, SESSION_COOKIE } from '../src/sso.mjs';
|
|
import { startServer } from '../src/serve.mjs';
|
|
import { consoleAccess } from '../src/access.mjs';
|
|
import { fakeOidc } from './helpers/fake-oidc.mjs';
|
|
import { close } from './fixture.mjs';
|
|
const HOST = 'accounts.example.test', ORIGIN = `https://${HOST}`;
|
|
function call(port, path, { cookie, method = 'GET', body } = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const req = request({ host: '127.0.0.1', port, path, method, headers: { host: HOST, ...(cookie ? { cookie } : {}), ...(body ? { origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } : {}) } }, res => {
|
|
let text = ''; res.on('data', c => text += c); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text }));
|
|
}); req.on('error', reject); req.end(body);
|
|
});
|
|
}
|
|
async function setup(t, { legacy = false } = {}) {
|
|
const dir = mkdtempSync(join(tmpdir(), 'as-')); chmodSync(dir, 0o700); t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
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: 'owner', name: 'Owner' }, { sub: 'drift', name: 'Drift' }] }] };
|
|
const ctx = { configDir: dir, providers: config.providers }, file = join(dir, 'accounts.json');
|
|
const row = (id, sub, instanceRole) => ({ id, subjects: [{ provider: 'a', sub }], name: sub, instanceRole, state: 'active', authGen: 1, created: '2026-11-10T00:00:00Z' });
|
|
await withAccounts(ctx, { init: true, revision: 0, verb: 'init' }, d => { d.users.push(row('u-000000000001', 'owner', 'operator'), row('u-000000000002', 'pat', 'user'), row('u-000000000003', 'op', 'operator')); });
|
|
let access = { users: [{ id: 'legacy', subjects: [{ provider: 'a', sub: 'pat' }] }], grants: legacy ? [{ user: 'legacy', business: 'demo', projects: ['*'], instances: ['*'], level: 'collaborator' }] : [] };
|
|
const lines = [], sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), accounts: { load: () => loadAccounts(file, ctx) }, users: consoleAccess({ load: () => access, business: () => 'demo' }) });
|
|
t.after(() => sso.close()); let restarts = 0;
|
|
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, bus: { inbox: async () => [] }, send: async () => ({}), restart: () => restarts++, trail: () => {}, userOptions: () => ({ bus: { inbox: async () => [] }, chat: { projects: async () => [] } }), log: () => {} }); t.after(() => close(web)); const port = web.address().port;
|
|
async function login(sub = 'pat') {
|
|
const begin = await call(port, '/auth/login'); const loc = begin.headers.location; const grant = op.grant(iss, loc, { sub });
|
|
const r = await call(port, `/auth/callback?${new URLSearchParams({ code: grant.code, state: new URL(loc).searchParams.get('state') })}`, { cookie: begin.headers['set-cookie'][0].split(';')[0] });
|
|
const cookie = r.headers['set-cookie']?.find(c => c.startsWith(SESSION_COOKIE + '=') && !c.includes('Max-Age=0'))?.split(';')[0]; return { ...r, cookie };
|
|
}
|
|
const read = () => loadAccounts(file, ctx);
|
|
const change = (fn, verb = 'user.update') => withAccounts(ctx, { revision: read().revision, verb }, fn);
|
|
return { login, sso, lines, file, port, read, change, revoke: () => { access.grants = []; }, restarts: () => restarts };
|
|
}
|
|
test('account sign-in records separate authority; unknown and drifted owner subjects refuse', async t => {
|
|
const s = await setup(t); const p = await s.login(); assert.equal(p.status, 303);
|
|
const session = s.sso.session({ headers: { cookie: p.cookie } }); assert.equal(session.account, 'u-000000000002'); assert.equal(session.authGen, 1); assert.equal(session.instanceRole, 'user'); assert.equal(session.rootOwner, false); assert.equal(session.user, undefined);
|
|
for (const sub of ['unknown', 'drift']) { const r = await s.login(sub); assert.equal(r.status, 403); assert.equal(r.cookie, undefined); assert.match(s.lines.at(-1), /subject-unknown/); }
|
|
const owner = await s.login('owner'); assert.equal(s.sso.session({ headers: { cookie: owner.cookie } }).rootOwner, true);
|
|
});
|
|
test('disabled, removed, signout, changed subject and role generations invalidate without resurrection', async t => {
|
|
for (const mutation of ['disable', 'disable-enable', 'role-roundtrip', 'signout', 'subject', 'remove', 'missing', 'invalid']) await t.test(mutation, async t => {
|
|
const s = await setup(t), r = await s.login(); assert.ok(r.cookie, 'account sign-in required before revocation');
|
|
if (mutation.startsWith('disable')) { await s.change(d => d.users[1].state = 'disabled'); if (mutation.endsWith('enable')) await s.change(d => d.users[1].state = 'active'); }
|
|
if (mutation === 'role-roundtrip') { await s.change(d => d.users[1].instanceRole = 'operator'); await s.change(d => d.users[1].instanceRole = 'user'); }
|
|
if (mutation === 'signout') await s.change(d => d.users[1].authGen++, 'user.signout');
|
|
if (mutation === 'subject') await s.change(d => d.users[1].subjects[0].sub = 'changed');
|
|
if (mutation === 'remove') await s.change(d => d.users.splice(1, 1), 'user.remove');
|
|
if (mutation === 'missing') rmSync(s.file); if (mutation === 'invalid') writeFileSync(s.file, '{}');
|
|
assert.equal(s.sso.session({ headers: { cookie: r.cookie } }), null);
|
|
assert.equal((await call(s.port, '/api/bus/inbox', { cookie: r.cookie })).status, 401);
|
|
});
|
|
});
|
|
test('the ninth account session evicts only that account oldest; global caps are 512/128', async t => {
|
|
const s = await setup(t), other = await s.login('owner'), cookies = [];
|
|
for (let i = 0; i < 9; i++) cookies.push((await s.login()).cookie);
|
|
assert.equal(s.sso.session({ headers: { cookie: cookies[0] } }), null);
|
|
for (const cookie of [...cookies.slice(1), other.cookie]) assert.ok(s.sso.session({ headers: { cookie } }));
|
|
for (let i = 0; i < 129; i++) await call(s.port, '/auth/login'); assert.equal(s.sso.logins.size, 128);
|
|
});
|
|
test('root routes refuse account-only sessions and restart checks operator independently of root ownership', async t => {
|
|
const s = await setup(t), pat = await s.login(), op = await s.login('op');
|
|
for (const path of ['/api/bus/inbox', '/api/settings', '/api/chat/projects', '/board']) assert.equal((await call(s.port, path, { cookie: pat.cookie })).status, 404, path);
|
|
assert.equal((await call(s.port, '/api/console/restart', { cookie: pat.cookie, method: 'POST', body: '{}' })).status, 403);
|
|
assert.equal(s.restarts(), 0);
|
|
assert.equal((await call(s.port, '/api/console/restart', { cookie: op.cookie, method: 'POST', body: '{}' })).status, 202);
|
|
assert.equal(s.restarts(), 1);
|
|
});
|
|
test('legacy root grant and account authority revoke independently and never return on an old cookie', async t => {
|
|
const s = await setup(t, { legacy: true }), r = await s.login();
|
|
let session = s.sso.session({ headers: { cookie: r.cookie } }); assert.equal(session.user, 'legacy'); assert.equal(session.account, 'u-000000000002');
|
|
assert.equal((await call(s.port, '/api/chat/projects', { cookie: r.cookie })).status, 200);
|
|
await s.change(d => d.users[1].state = 'disabled');
|
|
session = s.sso.session({ headers: { cookie: r.cookie } }); assert.ok(session); assert.equal(session.user, 'legacy'); assert.equal(session.account, undefined); assert.equal(session.instanceRole, undefined);
|
|
assert.equal((await call(s.port, '/api/chat/projects', { cookie: r.cookie })).status, 200);
|
|
await s.change(d => d.users[1].state = 'active'); assert.equal(s.sso.session({ headers: { cookie: r.cookie } }).account, undefined);
|
|
s.revoke(); assert.equal(s.sso.session({ headers: { cookie: r.cookie } }), null);
|
|
assert.equal((await call(s.port, '/api/chat/projects', { cookie: r.cookie })).status, 401);
|
|
const fresh = await s.login(); assert.ok(fresh.cookie); assert.equal(s.sso.session({ headers: { cookie: fresh.cookie } }).user, undefined);
|
|
assert.equal((await call(s.port, '/api/chat/projects', { cookie: fresh.cookie })).status, 404);
|
|
});
|
|
|
|
test('accounts mode refuses any start that could reach the dev LAN same-origin fake', async () => {
|
|
const { createDevOwner } = await import('../src/dev-owner.mjs');
|
|
await assert.rejects(startServer({ port: 0, sso: { ...createDevOwner(), mode: 'accounts' }, devAllowHosts: ['accounts.test'] }), /accounts mode refuses the dev LAN/);
|
|
await assert.rejects(startServer({ port: 0, sso: { ...createDevOwner(), mode: 'accounts' } }), /accounts mode refuses the dev LAN/);
|
|
});
|