334 lines
20 KiB
JavaScript
334 lines
20 KiB
JavaScript
// C2-03b (#1791, design 7.2): the credential the library fetch runs with. The chief's grant store file
|
|
// once a chief has Gitea grant evidence, slot library-gitea before that, and a refusal otherwise.
|
|
// C2-21 replaces sage-4100's read fixture seed: T7/T9 raise the real chief library grant, the human
|
|
// resolves yes and the executor mints it. Revocation and fire keep using the same real verbs.
|
|
// The choice function's other checks (listed, a fire under way, held, two chiefs, another project)
|
|
// use fixtures: the evidence and the gitea_grants row go in by SQL, as gitea-grant.test.mjs does for
|
|
// decisions. Tokens are made up or the fake's, and only ever compared.
|
|
import test from 'node:test';
|
|
import { hireServiceFixture, fixtureConsole, approveFixtureFire } from './hire-fixtures.mjs';
|
|
import assert from 'node:assert/strict';
|
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { randomBytes } from 'node:crypto';
|
|
import { Store } from '../src/store.mjs';
|
|
import { Broker, GRANT_EVIDENCE, LIBRARY_FETCH_FIX, writeGrantEvent } from '../src/broker.mjs';
|
|
import { Credentials } from '../src/credentials.mjs';
|
|
import { giteaGrant, giteaKeys, giteaStart, giteaReference } from '../src/gitea-grant.mjs';
|
|
import { startBroker } from '../src/runtime.mjs';
|
|
import { gitEnv } from '../../seat/src/checkout.mjs';
|
|
import { mappingEntry, repo } from '../../library/tests/helpers.mjs';
|
|
import * as library from '../../library/src/index.mjs';
|
|
import { fakeGiteaApi } from './fake-gitea-api.mjs';
|
|
import { grantDecision } from './host-fixtures.mjs';
|
|
import { systemFor } from '../../organization/tests/helpers.mjs';
|
|
|
|
const AT = '2026-10-22T00:00:00.000Z';
|
|
const REV = 'b'.repeat(64);
|
|
const SLOT_TOKEN = 'fx286-slot-q7-r6-a9';
|
|
const role = { authority: { withinRole: [], crossRole: [] } };
|
|
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
|
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
|
const chiefAuthority = JSON.parse(readFileSync(fileURLToPath(new URL('../../../roles/chief-of-staff.json', import.meta.url)), 'utf8')).authority;
|
|
const SWITCH = { version: 1, halted: false, generation: '0b6e7f6e-4c1d-4a8e-9a51-6d2f3c4b5a69' };
|
|
const dayFrom = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
async function until(fn, ms = 10000) {
|
|
const end = Date.now() + ms;
|
|
while (!fn()) {
|
|
if (Date.now() > end) throw new Error('timed out');
|
|
await sleep(10);
|
|
}
|
|
}
|
|
|
|
const makeBusiness = ({ root, chief = true, instances = null } = {}) => ({
|
|
id: 'acme', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' },
|
|
// sage-4097 / mos-1584: the chief's declared read credential; no-chief negative fixture stays absent.
|
|
roles: { cto: { definition: 'cto', authority: { withinRole: ['decision.resolve.technical'], crossRole: [] } }, pm: { definition: 'pm', authority: { withinRole: ['decision.resolve.delivery'], crossRole: [] } }, ...(chief ? { chief: { definition: 'chief-of-staff', ...role, credentials: { gitea: giteaReference(join(root, 'secrets', 'agents'), 'acme', 'chief', '2099-01-01') } } } : {}), coder: { definition: 'coder', ...role } },
|
|
projects: { books: { ...mappingEntry(), user: 'books-bot' } },
|
|
...(instances ? { launch: { instances } } : {}),
|
|
});
|
|
|
|
// A Gitea grant for `agent` as row 236 leaves it: the decision, the user and grant rows, and
|
|
// grant.applied. `project` is the grant's target; the evidence check names no project.
|
|
function grant(store, agent, { user = agent + '-bot', project = 'books', row = true } = {}) {
|
|
const decision = grantDecision(store, { business: 'acme', grant: { method: 'gitea', agent, target: { projects: [project] }, rotateBy: '2099-01-01' } });
|
|
writeGrantEvent(store, AT, 'acme', { operation: 'grant.gitea', decision }, decision);
|
|
writeGrantEvent(store, AT, 'acme', { action: 'grant.applied', method: 'gitea', agent, grantDecision: decision, user, projects: [project] }, decision);
|
|
if (!row) return decision;
|
|
store.run('INSERT INTO gitea_users(business,agent,user,id,nonce,decision) VALUES(?,?,?,?,?,?)', 'acme', agent, user, 7, randomBytes(8).toString('hex'), decision);
|
|
store.run('INSERT INTO gitea_grants(business,agent,user,token_id,token_name,projects,rotate_by,decision) VALUES(?,?,?,?,?,?,?,?)',
|
|
'acme', agent, user, 11, 'mosaic-' + agent, JSON.stringify([project]), '2099-01-01', decision);
|
|
return decision;
|
|
}
|
|
|
|
function world(t, options) {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-library-credential-')), store = new Store(root);
|
|
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
|
const business = makeBusiness({ ...options, root });
|
|
const held = new Set();
|
|
const broker = new Broker({hires:hireServiceFixture, store, businesses: { acme: business }, giteaKeys: { held: (b, a) => held.has(b + '/' + a) } });
|
|
return { store, business, broker, held, choose: () => broker.libraryFetchCredential('acme') };
|
|
}
|
|
|
|
// The business for the real paths. `boss` is launch.by: instance.fire is the launch.by role's verb and
|
|
// refuses its own name (organization-instance-referenced), so the fired chief is the one listed
|
|
// chief-of-staff instance, fired by boss. The real read grant targets the library project books.
|
|
function verbDoc(url, root) {
|
|
return {
|
|
id: 'acme', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
|
roles: {
|
|
boss: { definition: 'pm', authority: chiefAuthority },
|
|
// sage-4097 / mos-1584: fixture projection carries the chief's own Gitea store reference.
|
|
chief: { definition: 'chief-of-staff', ...none, credentials: { gitea: giteaReference(join(root, 'secrets', 'agents'), 'acme', 'chief', '2099-01-01') }, identity: { name: 'Chief', email: '[email protected]', gitea: { url, user: 'acme-chief' } }, publish: ['books'] },
|
|
coder: { definition: 'coder', ...none },
|
|
},
|
|
definitions: {
|
|
'chief-of-staff': { roleVersion: 2, credentials: [{ service: 'gitea', scopes: ['read:repository', 'read:user'] }] },
|
|
pm: { roleVersion: 2, credentials: [] },
|
|
coder: { roleVersion: 2, credentials: [] },
|
|
},
|
|
// sage-4054 / mos-1272: runtime validates every code project's absolute checkout root.
|
|
projects: { app: { root: join(root, 'app-checkout'), remote: `${url}/acme/app` }, books: { ...mappingEntry(), remote: `${url}/acme/books`, user: 'books-bot' } },
|
|
launch: { by: 'boss', instances: ['chief', 'coder'] },
|
|
};
|
|
}
|
|
|
|
// A bare broker on the real grant paths, as gitea-grant.test.mjs and fire-revokes-grants.test.mjs run
|
|
// it. A second one on the same data root and store picks the grant up as runtime.mjs does: giteaStart,
|
|
// then Credentials with its references and held list. The admin slot sits outside storeRoot's config
|
|
// dir, so a runtime started on that dir finds no admin slot.
|
|
async function verbWorld(t, opts = {}) {
|
|
const dir = mkdtempSync(join(tmpdir(), 'bus-library-verbs-'));
|
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
let gitea = opts.gitea;
|
|
if (!gitea) {
|
|
gitea = await fakeGiteaApi(t, join(dir, 'tls'));
|
|
gitea.addUser('acme');
|
|
gitea.addRepo('acme', 'app');
|
|
gitea.addRepo('acme', 'books');
|
|
}
|
|
const adminDir = join(dir, 'admin');
|
|
mkdirSync(adminDir, { mode: 0o700 });
|
|
const dataRoot = opts.dataRoot ?? join(dir, 'data');
|
|
mkdirSync(dataRoot, { recursive: true, mode: 0o700 });
|
|
const storeRoot = opts.storeRoot ?? join(dir, 'secrets', 'agents');
|
|
const doc = opts.doc ?? verbDoc(gitea.url, dir);
|
|
const store = new Store(dataRoot);
|
|
const references = {};
|
|
const { held } = giteaStart(store, storeRoot, { acme: doc }, references);
|
|
const credentials = new Credentials({ references, storeRoot, dataRoot, held });
|
|
let open = true;
|
|
const close = () => {
|
|
if (!open) return;
|
|
open = false;
|
|
credentials.close();
|
|
store.close();
|
|
};
|
|
t.after(close);
|
|
const grantApi = giteaGrant({ configDir: adminDir, ca: gitea.ca, timeoutMs: 5000 });
|
|
grantApi.writeAdmin('acme', gitea.adminToken, { url: gitea.url, account: 'mosaic-grant', rotateBy: dayFrom(60), setAt: AT });
|
|
let broker;
|
|
const organization = (business, op, args) => {
|
|
if (op === 'instance.fire') {
|
|
doc.launch.instances = doc.launch.instances.filter((n) => n !== args.name);
|
|
broker.reloadBusiness('acme', doc);
|
|
return { revision: REV, transaction: 't-fire' };
|
|
}
|
|
return { revision: REV };
|
|
};
|
|
broker = new Broker({hires:hireServiceFixture,
|
|
store, businesses: { acme: structuredClone(doc) }, secretCheck: (value) => credentials.assertClean(value), organization,
|
|
processing: { read: () => SWITCH }, giteaGrant: grantApi, giteaKeys: giteaKeys({ credentials, storeRoot }),
|
|
});
|
|
const boss = broker.bindLaunch({ business: 'acme', role: 'boss', run: 'boss-run', harness: 'pi', address: 'boss' });
|
|
broker.request(boss, { verb: 'role.claim' });
|
|
// sage-4054 / mos-1272: grant raises require the chief definition, independent of launch.by's fire authority.
|
|
const chief = broker.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' });
|
|
broker.request(chief, { verb: 'role.claim' });
|
|
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
|
const events = () => store.all(`SELECT body FROM events WHERE business='acme' AND ${GRANT_EVIDENCE} ORDER BY seq`).map((e) => JSON.parse(e.body));
|
|
const row = () => store.get("SELECT 1 FROM gitea_grants WHERE business='acme' AND agent='chief'");
|
|
const mintChiefReadGrant = async () => {
|
|
const decision = await broker.request(chief, {
|
|
verb: 'decision.raise',
|
|
args: {
|
|
action: 'credential.mint', question: 'Grant chief library read?', options, recommendation: 'yes', blocking: false,
|
|
grant: { method: 'gitea', agent: 'chief', target: { projects: ['books'] }, rotateBy: dayFrom(30) },
|
|
},
|
|
});
|
|
broker.request(human, { verb: 'decision.resolve', args: { id: decision.id, choice: 'yes' } });
|
|
await until(() => events().some((e) => e.grantDecision === decision.id && ['grant.applied', 'grant.failed'].includes(e.action)));
|
|
await broker.giteaIdle();
|
|
const end = events().find((e) => e.grantDecision === decision.id && ['grant.applied', 'grant.failed'].includes(e.action));
|
|
assert.equal(end.action, 'grant.applied', JSON.stringify(end));
|
|
assert.deepEqual(end.repositories, [{ project: 'books', repository: `${gitea.url}/acme/books`, level: 'read' }]);
|
|
assert.deepEqual(gitea.tokens('acme-chief')[0].scopes, ['read:repository', 'read:user']);
|
|
assert.ok(row(), 'real grant wrote its gitea_grants row');
|
|
};
|
|
const revoke = async () => {
|
|
await broker.request(human, { verb: 'grants.revoke', args: { service: 'gitea', agent: 'chief', reason: 'test' } });
|
|
await broker.giteaIdle();
|
|
assert.equal(row(), undefined, 'grants.revoke deleted the row');
|
|
assert.ok(events().some((e) => e.action === 'grant.revoked' && e.agent === 'chief'));
|
|
};
|
|
const fire = async () => {
|
|
await approveFixtureFire(broker,fixtureConsole(broker,'acme'),'chief');
|
|
await until(() => !doc.launch.instances.includes('chief'));
|
|
await broker.grantsIdle();
|
|
assert.equal(row(), undefined, 'the fire\'s step G deleted the row (row 281)');
|
|
};
|
|
// sage-4054 / mos-1272: a second fixture broker on this store needs the previous runs released.
|
|
const release = () => {
|
|
broker.request(chief, { verb: 'role.release' });
|
|
broker.request(boss, { verb: 'role.release' });
|
|
};
|
|
return { gitea, doc, store, broker, storeRoot, row, mintChiefReadGrant, revoke, fire, close, release, choose: () => broker.libraryFetchCredential('acme') };
|
|
}
|
|
|
|
const REFUSED = { refused: 'library-fetch-no-grant', fix: LIBRARY_FETCH_FIX };
|
|
|
|
test('C2-03b T6: the slot before any chief evidence, then the listed chief\'s live grant', t => {
|
|
const w = world(t);
|
|
assert.deepEqual(w.choose(), { kind: 'slot' });
|
|
grant(w.store, 'coder');
|
|
assert.deepEqual(w.choose(), { kind: 'slot' }, 'a seat grant is not the chief\'s: the chief entry keeps the fallback');
|
|
grant(w.store, 'chief', { user: 'chief-bot' });
|
|
assert.deepEqual(w.choose(), { kind: 'chief', agent: 'chief', user: 'chief-bot' });
|
|
assert.deepEqual(new Broker({hires:hireServiceFixture, store: w.store, businesses: { acme: w.business } }).libraryFetchCredential('acme'), REFUSED,
|
|
'a bus with no grant store refuses rather than guess');
|
|
assert.throws(() => w.broker.libraryFetchCredential('other'), { code: 'unknown-business' });
|
|
assert.equal(LIBRARY_FETCH_FIX, 'add the chief of staff, or grant the library through C2-21');
|
|
});
|
|
|
|
test('C2-03b T7: a chief revoked through grants.revoke or fired through instance.fire refuses, and the fallback stays ended', { timeout: 60000 }, async t => {
|
|
const revoked = await verbWorld(t);
|
|
assert.deepEqual(revoked.choose(), { kind: 'slot' });
|
|
await revoked.mintChiefReadGrant();
|
|
assert.deepEqual(revoked.choose(), { kind: 'chief', agent: 'chief', user: 'acme-chief' });
|
|
await revoked.revoke();
|
|
assert.deepEqual(revoked.choose(), REFUSED, 'revoked');
|
|
|
|
const fired = await verbWorld(t);
|
|
await fired.mintChiefReadGrant();
|
|
assert.deepEqual(fired.choose(), { kind: 'chief', agent: 'chief', user: 'acme-chief' });
|
|
await fired.fire();
|
|
assert.equal(fired.doc.launch.instances.includes('chief'), false, 'delisted');
|
|
assert.ok(fired.doc.roles.chief, 'roles[chief] remains after a fire, so its evidence still ends the fallback');
|
|
assert.deepEqual(fired.choose(), REFUSED, 'fired');
|
|
});
|
|
|
|
test('C2-03b T7 choice checks: delisted, a fire under way and held each refuse; a rehire lists the chief again', t => {
|
|
const delisted = world(t, { instances: ['coder'] });
|
|
grant(delisted.store, 'chief');
|
|
assert.deepEqual(delisted.choose(), REFUSED, 'delisted with the row still there');
|
|
delisted.business.launch.instances.push('chief');
|
|
// sage-4054 / mos-1272: Broker clones input; publish the fixture's organization change.
|
|
delisted.broker.reloadBusiness('acme', delisted.business);
|
|
assert.deepEqual(delisted.choose(), { kind: 'chief', agent: 'chief', user: 'chief-bot' }, 'a rehire lists it again');
|
|
delisted.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES('f1',?,?,'action.allowed','chief','run','chief',?)",
|
|
AT, 'acme', JSON.stringify({ action: 'fire.stopped' }));
|
|
assert.deepEqual(delisted.choose(), REFUSED, 'a fire under way');
|
|
|
|
const held = world(t);
|
|
grant(held.store, 'chief');
|
|
held.held.add('acme/chief');
|
|
assert.deepEqual(held.choose(), REFUSED, 'held');
|
|
});
|
|
|
|
test('C2-03b T8: a chief grant on another project ends the fallback, and two listed chiefs refuse library-fetch-no-grant', t => {
|
|
const w = world(t);
|
|
grant(w.store, 'chief', { project: 'other-project', row: false });
|
|
assert.deepEqual(w.choose(), REFUSED);
|
|
const two = world(t);
|
|
grant(two.store, 'chief');
|
|
assert.deepEqual(two.choose(), { kind: 'chief', agent: 'chief', user: 'chief-bot' });
|
|
two.business.roles.second = { definition: 'chief-of-staff', ...role };
|
|
two.broker.reloadBusiness('acme', two.business); // sage-4054 / mos-1272: publish the fixture change
|
|
assert.deepEqual(two.choose(), REFUSED, 'two chief-of-staff entries, both listed: one chief only');
|
|
two.business.launch = { instances: ['coder'] };
|
|
two.broker.reloadBusiness('acme', two.business); // sage-4054 / mos-1272
|
|
assert.deepEqual(two.choose(), REFUSED, 'neither listed');
|
|
});
|
|
|
|
test('C2-03b departure (sage-2928): with no chief entry, any Gitea grant evidence refuses', t => {
|
|
const w = world(t, { chief: false });
|
|
assert.deepEqual(w.choose(), { kind: 'slot' });
|
|
grant(w.store, 'coder');
|
|
assert.deepEqual(w.choose(), REFUSED);
|
|
});
|
|
|
|
// T9 and the runtime wiring. The timer and library.sync both take the broker's choice: the chief's
|
|
// store file, the slot, or a refusal that runs no git even with the slot at 0000. The grant and its
|
|
// revoke run through the real paths on the runtime's own data root and store.
|
|
test('C2-03b T9 runtime: the chief\'s store file and user, then a real revoke that runs no git with the slot at 0000', { timeout: 60000 }, async t => {
|
|
const f = repo(t), root = f.root, dataRoot = join(root, 'data');
|
|
mkdirSync(dataRoot, { mode: 0o700 });
|
|
const storeRoot = join(root, 'secrets', 'agents');
|
|
// sage-4100: row285 ships the chief's read declaration; use the real roles without a scratch copy.
|
|
const rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url));
|
|
const slot = join(root, 'secrets', 'acme', library.SLOT);
|
|
mkdirSync(join(root, 'secrets', 'acme'), { recursive: true, mode: 0o700 });
|
|
writeFileSync(slot, SLOT_TOKEN + '\n', { mode: 0o600 });
|
|
const granted = await verbWorld(t, { dataRoot, storeRoot });
|
|
await granted.mintChiefReadGrant();
|
|
granted.release();
|
|
granted.close();
|
|
const gitea = granted.gitea;
|
|
const chiefFile = join(storeRoot, 'acme', 'chief', 'gitea');
|
|
assert.ok(existsSync(chiefFile), 'the grant wrote the chief\'s store file');
|
|
const tokens = () => [SLOT_TOKEN, ...gitea.issued];
|
|
const calls = [], lines = [], write = process.stderr.write;
|
|
const command = (kind, url, opts) => { calls.push({ kind, opts }); return { argv: ['--version'], env: gitEnv() }; };
|
|
process.stderr.write = function (chunk, ...rest) {
|
|
if (String(chunk).startsWith('library: ')) { lines.push(String(chunk)); return true; }
|
|
return write.call(this, chunk, ...rest);
|
|
};
|
|
t.after(() => { process.stderr.write = write; });
|
|
const start = (intervalMs = 50) => startBroker({ dataRoot, businesses: { acme: verbDoc(gitea.url, root) }, organization: { dir: root, rolesDir, system: systemFor(root) },
|
|
librarySync: { command, intervalMs } });
|
|
const untilAsync = async (fn) => { const end = Date.now() + 10000; while (!fn() && Date.now() < end) await sleep(10); };
|
|
|
|
let runtime = await start();
|
|
t.after(async () => { await runtime.close(); }); // sage-4054 / mos-1272: close even on assertion failure
|
|
// sage-4054 / mos-1272: command construction precedes publication; wait for the completed sync log.
|
|
await untilAsync(() => lines.length >= 1);
|
|
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
|
await runtime.broker.request(human, { verb: 'library.sync', args: { project: 'books' } }).catch(e => e);
|
|
// webby-0151 R3: library.status names the kind and user of the last fetch, never the file or value.
|
|
const shown = await runtime.broker.request(human, { verb: 'library.status', args: { project: 'books' } });
|
|
assert.deepEqual(shown.credential, { kind: 'chief', user: 'acme-chief' });
|
|
assert.equal(JSON.stringify(shown).includes(chiefFile) || tokens().some(tok => JSON.stringify(shown).includes(tok)), false);
|
|
await runtime.close();
|
|
assert.ok(calls.length >= 2, 'the timer and the verb');
|
|
assert.ok(calls.every(c => c.kind === 'fetch' && c.opts.slot === chiefFile && c.opts.user === 'acme-chief'), 'the chief\'s file and user, never the slot');
|
|
|
|
// Revoked through grants.revoke: the next start refuses before git, and the log names the fix.
|
|
const revoking = await verbWorld(t, { dataRoot, storeRoot, gitea });
|
|
await revoking.revoke();
|
|
revoking.release();
|
|
revoking.close();
|
|
chmodSync(slot, 0o000);
|
|
calls.length = 0; lines.length = 0;
|
|
// mos-1594: TIMEOUT_MAX keeps later timer ticks from racing the revoked verb sync.
|
|
runtime = await start(2147483647);
|
|
await untilAsync(() => lines.length >= 1);
|
|
const human2 = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
|
await assert.rejects(runtime.broker.request(human2, { verb: 'library.sync', args: { project: 'books' } }), { code: 'library-fetch-no-grant' });
|
|
const after = await runtime.broker.request(human2, { verb: 'library.status', args: { project: 'books' } });
|
|
assert.equal(after.lastError.code, 'library-fetch-no-grant');
|
|
assert.equal(after.credential, null);
|
|
await runtime.close();
|
|
assert.equal(calls.length, 0, 'no git with a refusal');
|
|
assert.equal(statSync(slot).mode & 0o777, 0, 'the slot was never fixed up');
|
|
assert.deepEqual(JSON.parse(lines[0].slice('library: '.length)),
|
|
{ event: 'library-sync-held', business: 'acme', project: 'books', code: 'library-fetch-no-grant', fix: LIBRARY_FETCH_FIX });
|
|
for (const line of lines) assert.equal(tokens().some(tok => line.includes(tok)), false);
|
|
const mirror = library.mirrorPath({ business: verbDoc(gitea.url, root), project: 'books', dataRoot });
|
|
const state = await library.readSyncState(mirror, { branch: 'main' });
|
|
assert.equal(state.lastError.code, 'library-fetch-no-grant');
|
|
assert.equal(state.credential, null);
|
|
});
|