Files
stack/packages/bus/tests/copy-launch.test.mjs
jason.woltje 90245765f2 feat(bus): copy revoke CB4, lifecycle events fence and revoke bound copies (#1873)
Every lifecycle event that ends a copy's authority (unbind, emergency
revoke, slot removal or expiry, role change, fire, collision, shadowing,
archive, delete and recreate) now closes admissions, drains in-flight
uses, stops runs with a confirmed ack, removes the slot and revokes the
Gitea token through the org admin slot. The binding moves through a
durable revoking state, so a restart resumes the same op and never
resurrects authority; an uncertain outcome stays fenced.
2026-11-12 16:08:07 -06:00

570 lines
32 KiB
JavaScript

// #1873 CB3 (design section 5 steps 7 and 8, section 8 row CB3): a bound agent copy launches only
// when the bus's boot proved its binding live at the service, every launch carries a launch
// authority the bus checks again at prepare, final admission and bindLaunch, and each credential
// call is checked against the session's authority before and after the request. The fake Gitea is
// fake-gitea-api.mjs on 127.0.0.1; the runtime and launcher halves are runtime tests below and
// packages/cli/tests/agent-copies.test.mjs.
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker, BusError } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { Credentials } from '../src/credentials.mjs';
import { REFUSALS } from '../src/refusals.mjs';
import { copyGrant } from '../src/copy-grant.mjs';
import { copyLaunchGate } from '../src/copy-launch.mjs';
import { send } from '../src/gitea-grant.mjs';
import { fakeGiteaApi } from './fake-gitea-api.mjs';
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { checkRootAgents } from '../../organization/src/agent-levels.mjs';
import { loadWithCopies, materializeCopies, promoteCopies } from '../../organization/src/agent-copies.mjs';
import { activateOrg, reserveOrg } from '../../organization/src/org-registry.mjs';
import { copyBotLogin } from '../../organization/src/copy-bindings.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' };
const READ = { gitea: { scopes: ['read:repository', 'read:user'], projects: ['stack'] } };
const WRITE = { gitea: { scopes: ['read:user', 'write:repository'], projects: ['stack'] } };
const ahead = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
const CODES = ['restart-required', 'credential-revoked', 'credential-revoking', 'credential-unavailable', 'credential-expired',
'copy-authority-changed', 'copy-request-refused', 'copy-redirect-refused', 'organization-changed', 'organization-unreadable'];
// demo with the project stack on the fake Gitea and the root copy rev (research, which needs gitea
// alone; a binding may be narrower than the role). The
// owner binds rev with `services` and, unless `issue` is false, issues one live token for it (CB2).
// `gate` is the CB3 gate on the boot snapshot; `boot()` runs its boot check and returns the broker's
// business with the copies it proved promoted, as runtime.mjs does.
async function fixture(t, { services = READ, perm = 'read', issue = true, definition = 'research', budgets = {} } = {}) {
let clock = Date.now();
const root = mkdtempSync(join(tmpdir(), 'cb3-')), rolesDir = rolesCopy(root);
t.after(() => rmSync(root, { recursive: true, force: true }));
const api = await fakeGiteaApi(t, root);
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } };
doc.projects.stack.remote = `${api.url}/owner/stack`;
const file = writeJson(join(root, 'businesses/demo.json'), doc);
const dataRoot = join(root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
const ctx = { configDir: root, dataRoot, instance: 'test' };
await reserveOrg(ctx, 'demo'); await activateOrg(ctx, 'demo');
const snapshot = materializeCopies({ orgId: 'demo', doc, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { rev: { definition } } }, { rolesDir }) }, rolesDir });
const writer = organizationWriter({ dir: root, rolesDir }), store = new Store(dataRoot);
t.after(() => store.close());
const service = hireDecisions({ writer, rolesDir, dataRoot, configDir: root, assertClean: () => {}, copies: (id) => snapshot.org === id ? snapshot : null });
const storeRoot = join(root, 'secrets', 'agents');
mkdirSync(storeRoot, { recursive: true, mode: 0o700 }); chmodSync(join(root, 'secrets'), 0o700);
const credentials = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
// Every request the gate sends, with what the test lets through; `hold` parks one until released.
const sent = [];
const transport = async (req) => { sent.push(`${req.method} ${req.path.split('?')[0]}`); return send(req); };
const gate = copyLaunchGate({ credentials, writer, configDir: root, rolesDir, snapshot, transport, ca: api.ca, timeoutMs: 3000, now: () => new Date(clock), ...budgets });
const advance = (days) => { clock += days * 86400000; };
let promoted = snapshot;
const business = () => {
const d = loadWithCopies('demo', { dir: root, rolesDir, snapshot: promoted });
return { ...d, roles: Object.fromEntries(Object.entries(d.roles).map(([n, e]) => [n, { ...d.definitions?.[e.definition], ...e }])) };
};
const issueGrant = (...a) => copyGrant({ credentials, writer, copies: snapshot, organizations: () => ['demo'], ca: api.ca, timeoutMs: 3000 })(...a);
const b = new Broker({ store, businesses: { demo: business() }, copyLaunch: gate,
hires: { ...service, apply: async (...args) => { const r = await service.apply(...args); b.reloadBusiness('demo', business()); return r; } },
credentialVerbs: () => { throw new BusError('credential-unknown'); }, copyGrant: (...a) => issueGrant(...a) });
const owner = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const call = (cap, verb, args = {}) => Promise.resolve().then(() => b.request(cap, { verb, args }));
const rec = () => JSON.parse(readFileSync(file, 'utf8')).copyBindings?.rev;
const repo = api.addRepo('owner', 'stack');
api.addRepo('owner', 'other');
await call(owner, 'hire.start.console', { op: 'change', level: 'root', name: 'rev', reason: 'Bind the copy', binding: { services } });
const r = rec();
const login = copyBotLogin(r.orgIncarnation, r.copyId, 'gitea');
const bot = api.addUser(login).user;
repo.collaborators.push(login); repo.permissions[login] = perm;
const scopes = services.gitea.scopes;
const reissue = (scopesNow = scopes) => call(human, 'credentials.issue', { agent: 'rev', service: 'gitea', date: ahead(30), token: api.addToken(login, `t${api.issued.length}`, scopesNow).sha1 });
if (issue) await reissue();
const boot = async () => {
await gate.boot('demo');
promoted = promoteCopies(snapshot, gate.bound());
b.reloadBusiness('demo', business());
};
const slot = join(storeRoot, 'demo', 'rev', 'gitea');
// A run of rev, bound with the authority the launcher's prepare returned.
// rev is a thread instance, so each run takes a thread of its own in the stack project.
let project = null;
const threadFor = async (run) => {
project ??= (await call(human, 'project.create', { name: 'stack work', repo: 'stack' })).id;
return (await call(human, 'thread.create', { project, name: run, instance: 'rev' })).id;
};
const launch = async (run = 'rev-run') => {
const thread = await threadFor(run);
const revision = writer.read('demo').revision;
const { authority } = await gate.prepare('demo', 'rev', revision);
return { authority, thread, cap: b.bindLaunch({ business: 'demo', role: 'rev', run, harness: 'pi', thread, copyAuthority: authority, revision }) };
};
const use = (cap, request) => Promise.resolve().then(() => gate.call(b.copySession(cap), request));
const GET = { method: 'GET', path: '/api/v1/repos/owner/stack' };
// The organization file edited in place, as an owner's editor would.
const edit = (fn) => {
const d = JSON.parse(readFileSync(file, 'utf8'));
fn(d);
writeFileSync(file, JSON.stringify(d, null, 2) + '\n');
};
// The next request matching `match` waits at the fake server until release() lets it through.
const hold = (match) => {
let release, arrived;
const go = new Promise((r) => { release = r; });
const seen = new Promise((r) => { arrived = r; });
const unrule = api.rule((e, body) => match(e, body), async () => { unrule(); arrived(); await go; return { pass: true }; });
return { release: () => release(), arrived: seen };
};
// Gate requests to the copy's repositories (the proof's /version, /token and /user routes aside).
const repoCalls = () => sent.filter((x) => x.includes('/api/v1/repos/')).length;
return { root, rolesDir, edit, hold, advance, repoCalls, file, api, b, gate, threadFor, writer, credentials, owner, human, call, rec, bot, login, repo, reissue, boot, slot, launch, use, sent, GET, storeRoot, snapshot, business };
}
const refuses = (code) => (e) => { assert.ok(e instanceof BusError, String(e?.stack ?? e)); assert.equal(e.code, code); return true; };
test('CB3 every new refusal code is in the refusal table with a rail and a next step naming no path', () => {
for (const code of CODES) {
const entry = REFUSALS[code];
assert.ok(entry, code);
assert.match(entry.rail, /^R\d+[/:]/, code);
assert.ok(entry.nextStep.length > 20, code);
assert.ok(!/(^|\s)(\/|~\/)\S/.test(entry.nextStep), `${code} names a path`);
}
});
test('CB3 a copy bound before boot is proved live at boot and launches; prepare, admission and bindLaunch carry one authority', async (t) => {
const f = await fixture(t);
assert.equal(f.rec().state, 'bound');
await f.boot();
assert.deepEqual(f.gate.bound(), { rev: ['gitea'] });
const role = f.b.taskRoles('demo').rev;
assert.deepEqual([role.copyBound, role.credentials], [['gitea'], {}]);
const { authority, cap } = await f.launch();
assert.match(authority, /^[0-9a-f]{64}$/);
assert.equal(f.gate.authority('demo', 'rev').authority, authority);
// The live proof at prepare went to the bound origin as the copy's account.
assert.ok(f.sent.includes('GET /api/v1/token'));
const r = await f.use(cap, f.GET);
assert.equal(r.status, 200);
assert.equal(r.json.full_name, 'owner/stack');
assert.ok(!JSON.stringify(r).includes(f.api.issued.at(-1)));
// bindLaunch refuses a bound copy without the authority, or with another one.
const r2 = await f.threadFor('r2'), r3 = await f.threadFor('r3');
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'rev', run: 'r2', harness: 'pi', thread: r2 }), refuses('copy-authority-changed'));
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'rev', run: 'r3', harness: 'pi', thread: r3, copyAuthority: 'f'.repeat(64) }), refuses('copy-authority-changed'));
// A non-copy never carries one.
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'chief', run: 'c1', harness: 'pi', copyAuthority: authority }), refuses('invalid-request'));
});
test('CB3 a binding approved after boot answers restart-required at every launch step and sends nothing', async (t) => {
const f = await fixture(t, { issue: false });
assert.notEqual(f.rec().state, 'bound');
await f.boot();
assert.deepEqual([f.gate.bound(), f.sent], [{}, []]);
// The owner issues the grant now: the record is bound and granted, but the boot didn't prove it.
await f.reissue();
assert.equal(f.rec().state, 'bound');
assert.equal(f.b.taskRoles('demo').rev, undefined);
assert.throws(() => f.gate.authority('demo', 'rev'), refuses('restart-required'));
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('restart-required'));
assert.throws(() => f.gate.admit('demo', 'rev', 'f'.repeat(64)), refuses('restart-required'));
// The verb says so too, before its own unbound refusal.
await assert.rejects(f.call(f.human, 'instance.launch', { instance: 'rev' }), refuses('restart-required'));
assert.deepEqual(f.sent, []);
});
test('CB3 a partial binding (a role needing gitea and vikunja, bound for gitea alone) never launches and its boot sends nothing', async (t) => {
const f = await fixture(t, { definition: 'reviewer' });
assert.equal(f.rec().state, 'bound');
await f.boot();
assert.deepEqual([f.gate.bound(), f.sent], [{}, []]);
assert.throws(() => f.gate.authority('demo', 'rev'), refuses('credential-scope-unverified'));
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('credential-scope-unverified'));
await assert.rejects(f.call(f.human, 'instance.launch', { instance: 'rev' }), refuses('credential-scope-unverified'));
assert.deepEqual(f.sent, []);
});
test('CB3 a redirect from the bound origin is refused and never followed', async (t) => {
const f = await fixture(t);
await f.boot();
const { cap } = await f.launch();
f.api.rule((e) => e.path === '/api/v1/repos/owner/stack', { status: 302 });
await assert.rejects(f.use(cap, f.GET), refuses('copy-redirect-refused'));
assert.equal(f.api.requests.filter((e) => e.path.startsWith('/api/v1/repos/')).length, 1);
});
test('CB3 a call reaches only the admitted repositories, by the methods the binding allows, with no body on a read', async (t) => {
const f = await fixture(t);
await f.boot();
const { cap } = await f.launch();
for (const request of [
{ method: 'GET', path: '/api/v1/repos/owner/other' },
{ method: 'GET', path: '/api/v1/repos/owner/stack/../other' },
{ method: 'GET', path: '/api/v1/repos/owner/stack/%2e%2e/other' },
{ method: 'GET', path: '/api/v1/user' },
{ method: 'GET', path: 'https://evil.test/api/v1/repos/owner/stack' },
{ method: 'GET', path: '//evil.test/api/v1/repos/owner/stack' },
{ method: 'GET', path: '/api/v1/repos/owner/stack', body: { x: 1 } },
{ method: 'POST', path: '/api/v1/repos/owner/stack/issues', body: { title: 'x' } },
{ method: 'TRACE', path: '/api/v1/repos/owner/stack' },
{ service: 'vikunja', method: 'GET', path: '/api/v1/repos/owner/stack' },
]) await assert.rejects(f.use(cap, request), refuses('copy-request-refused'), JSON.stringify(request));
assert.equal(f.repoCalls(), 0);
// The session, never the arguments, names who the call is for.
await assert.rejects(f.gate.call({ business: 'demo', role: 'rev', copyAuthority: 'f'.repeat(64) }, f.GET), refuses('copy-authority-changed'));
assert.throws(() => f.b.copySession(f.human), refuses('copy-request-refused'));
assert.equal(f.repoCalls(), 0);
});
test('CB3 a write binding reaches its repository by POST as the copy\'s account (a READ binding is refused above)', async (t) => {
const f = await fixture(t, { services: WRITE, perm: 'write' });
await f.boot();
const { cap } = await f.launch();
f.api.rule((e) => e.method === 'POST' && e.path === '/api/v1/repos/owner/stack/issues', { status: 201, json: { number: 1 } });
const r = await f.use(cap, { method: 'POST', path: '/api/v1/repos/owner/stack/issues', body: { title: 'x' } });
assert.deepEqual(r, { status: 201, json: { number: 1 } });
const seen = f.api.requests.filter((e) => e.path === '/api/v1/repos/owner/stack/issues');
assert.deepEqual(seen.map((e) => [e.method, e.as]), [['POST', f.login]]);
});
test('CB3 a re-issue, a replaced slot or rewritten token bytes deny the next call with no request and no cached token', async (t) => {
for (const [name, change, code] of [
['re-issue', (f) => f.reissue(), 'restart-required'],
['slot replaced', (f) => { writeFileSync(f.slot + '.n', 'x'.repeat(40), { mode: 0o600 }); renameSync(f.slot + '.n', f.slot); }, 'credential-unavailable'],
['bytes rewritten', (f) => writeFileSync(f.slot, readFileSync(f.slot, 'utf8').replace(/[0-9a-f]{40}/, '0'.repeat(40))), 'credential-unavailable'],
['slot removed', (f) => unlinkSync(f.slot), 'credential-unavailable'],
['mode widened', (f) => chmodSync(f.slot, 0o644), 'credential-unavailable'],
]) {
await t.test(name, async (t) => {
const f = await fixture(t);
await f.boot();
const { cap } = await f.launch();
assert.equal((await f.use(cap, f.GET)).status, 200);
const before = f.sent.length;
await change(f);
// The bus's session lookup refuses the run itself, before the gate's own check.
assert.throws(() => f.b.copySession(cap), refuses(code));
await assert.rejects(f.use(cap, f.GET), refuses(code));
// No new launch either.
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses(code));
assert.deepEqual(f.sent.slice(before), []);
});
}
});
test('CB3 a revoke while a call is in flight denies that call\'s answer and the next call', async (t) => {
const f = await fixture(t);
await f.boot();
const { cap } = await f.launch();
const h = f.hold((e) => e.path === '/api/v1/repos/owner/stack');
const pending = f.use(cap, f.GET);
await h.arrived;
await f.reissue();
h.release();
await assert.rejects(pending, refuses('restart-required'));
const before = f.sent.length;
await assert.rejects(f.use(cap, f.GET), refuses('restart-required'));
assert.equal(f.sent.length, before);
});
test('CB3 a grant change in flight fences the copy: nothing is admitted or sent until it ends, and an unknown outcome keeps the fence', async (t) => {
const f = await fixture(t);
await f.boot();
const { cap } = await f.launch();
const r2 = await f.threadFor('r2');
const authority = f.gate.authority('demo', 'rev').authority;
// The owner's re-issue holds at its live proof; the copy is fenced meanwhile.
const h = f.hold((e) => e.path === '/api/v1/version');
const issuing = f.reissue();
await h.arrived;
const before = f.sent.length;
assert.equal(f.gate.revoking('demo', 'rev'), true);
assert.throws(() => f.gate.authority('demo', 'rev'), refuses('credential-revoking'));
assert.throws(() => f.gate.admit('demo', 'rev', authority), refuses('credential-revoking'));
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('credential-revoking'));
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'rev', run: 'r2', harness: 'pi', thread: r2, copyAuthority: authority }), refuses('credential-revoking'));
await assert.rejects(f.use(cap, f.GET), refuses('credential-revoking'));
assert.equal(f.sent.length, before);
h.release();
await issuing;
assert.equal(f.gate.revoking('demo', 'rev'), false);
assert.throws(() => f.gate.authority('demo', 'rev'), refuses('restart-required'));
// A definite refusal lifts the fence; an unknown outcome, a system error or a codeless throw keeps it.
await assert.rejects(f.gate.fence('demo', 'rev', () => { throw new BusError('credential-date'); }), refuses('credential-date'));
assert.equal(f.gate.revoking('demo', 'rev'), false);
for (const thrown of [new BusError('outcome-unknown'), Object.assign(new Error('disk'), { code: 'ENOSPC' }), new Error('bug')]) {
const g = await fixture(t);
await g.boot();
await assert.rejects(g.gate.fence('demo', 'rev', () => { throw thrown; }));
assert.equal(g.gate.revoking('demo', 'rev'), true, String(thrown.code));
await assert.rejects(g.gate.prepare('demo', 'rev'), refuses('credential-revoking'));
assert.deepEqual(g.sent.filter((x) => !x.startsWith('GET /api/v1/version') && !x.startsWith('GET /api/v1/token') && !x.startsWith('GET /api/v1/user')), []);
}
});
// Each change lands while prepare's live proof is at the server (between the authority before and
// after), then again between prepare and bindLaunch, then between bindLaunch and the first use. None
// may end in a request to the copy's repositories.
const RACES = [
['role definition changed', (f) => writeFileSync(join(f.rolesDir, 'research.md'), readFileSync(join(f.rolesDir, 'research.md'), 'utf8') + '\nChanged.\n')],
['binding generation moved', (f) => f.edit((d) => { d.copyBindings.rev.generation += 1; })],
['binding unbound', (f) => f.edit((d) => { d.copyBindings.rev.state = 'unbound'; })],
['grant scopes narrowed', (f) => f.edit((d) => { d.copyBindings.rev.services.gitea.scopes = ['read:user']; })],
['token bytes rewritten', (f) => writeFileSync(f.slot, readFileSync(f.slot, 'utf8').replace(/[0-9a-f]{40}/, '0'.repeat(40)))],
['project remote moved', (f) => f.edit((d) => { d.projects.stack.remote = 'https://elsewhere.test/owner/stack'; })],
['project removed', (f) => f.edit((d) => { delete d.projects.stack; })],
['project excludes the copy', (f) => f.edit((d) => { d.projects.stack.agents = { ...d.projects.stack.agents, rev: { exclude: true } }; })],
];
test('CB3 a change during prepare\'s live proof refuses the launch with no repository request', async (t) => {
for (const [name, change] of RACES) {
await t.test(name, async (t) => {
const f = await fixture(t);
await f.boot();
const h = f.hold((e) => e.path === '/api/v1/token');
const preparing = f.gate.prepare('demo', 'rev');
await h.arrived;
await change(f);
h.release();
await assert.rejects(preparing, (e) => e instanceof BusError && /^(copy-|credential-|restart-)/.test(e.code));
assert.equal(f.repoCalls(), 0);
});
}
});
test('CB3 a change between prepare and bindLaunch, or between bindLaunch and use, refuses with no repository request', async (t) => {
for (const [name, change] of RACES) {
await t.test(`${name} before bind`, async (t) => {
const f = await fixture(t);
await f.boot();
const thread = await f.threadFor('r1');
const { authority } = await f.gate.prepare('demo', 'rev');
await change(f);
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'rev', run: 'r1', harness: 'pi', thread, copyAuthority: authority }),
(e) => e instanceof BusError && /^(copy-|credential-|restart-)/.test(e.code));
assert.equal(f.repoCalls(), 0);
});
await t.test(`${name} before use`, async (t) => {
const f = await fixture(t);
await f.boot();
const { cap } = await f.launch();
await change(f);
await assert.rejects(f.use(cap, f.GET), (e) => e instanceof BusError && /^(copy-|credential-|restart-|run-)/.test(e.code));
assert.equal(f.repoCalls(), 0);
});
}
});
// The organization's registry entry rewritten in place (mode kept), as a lifecycle change would.
const registryEdit = (f, fn) => {
const file = join(f.root, 'org-runtime.json');
const d = JSON.parse(readFileSync(file, 'utf8'));
fn(d.orgs.demo, d);
writeFileSync(file, JSON.stringify(d, null, 2) + '\n');
};
test('CB3 an organization no longer active admits and sends nothing, with the binding and incarnation unchanged', async (t) => {
// #1873 CB4 (design section 6): an archived organization answers org-archived, any other state that
// is not active org-unavailable (CB3 answered credential-revoked for both).
for (const [state, code] of [['archived', 'org-archived'], ['deleting', 'org-unavailable'], ['reserved', 'org-unavailable']]) {
await t.test(`${state} after launch`, async (t) => {
const f = await fixture(t);
await f.boot();
const { cap, authority } = await f.launch();
registryEdit(f, (e) => { e.state = state; });
const before = f.sent.length;
assert.throws(() => f.gate.authority('demo', 'rev'), refuses(code));
assert.throws(() => f.gate.admit('demo', 'rev', authority), refuses(code));
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses(code));
await assert.rejects(f.use(cap, f.GET), refuses(code));
assert.equal(f.sent.length, before);
assert.equal(f.repoCalls(), 0);
});
}
await t.test('archived while prepare\'s proof is at the server', async (t) => {
const f = await fixture(t);
await f.boot();
const h = f.hold((e) => e.path === '/api/v1/token');
const preparing = f.gate.prepare('demo', 'rev');
await h.arrived;
registryEdit(f, (e) => { e.state = 'archived'; });
h.release();
await assert.rejects(preparing, refuses('org-archived'));
assert.equal(f.repoCalls(), 0);
});
await t.test('archived before boot', async (t) => {
const f = await fixture(t);
registryEdit(f, (e) => { e.state = 'archived'; });
await f.boot();
assert.deepEqual([f.gate.bound(), f.sent], [{}, []]);
});
await t.test('registry unsafe after launch', async (t) => {
const f = await fixture(t);
await f.boot();
const { cap } = await f.launch();
chmodSync(join(f.root, 'org-runtime.json'), 0o644);
const before = f.sent.length;
await assert.rejects(f.use(cap, f.GET), refuses('credential-unavailable'));
assert.equal(f.sent.length, before);
});
});
test('CB3 an organization recreated under the same id (a new incarnation) admits and sends nothing for the old copy', async (t) => {
const f = await fixture(t);
await f.boot();
const { cap, authority } = await f.launch();
registryEdit(f, (e) => { e.incarnation = 'f'.repeat(32); });
const before = f.sent.length;
assert.throws(() => f.gate.admit('demo', 'rev', authority), refuses('org-unavailable'));
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('org-unavailable'));
await assert.rejects(f.use(cap, f.GET), refuses('org-unavailable'));
assert.equal(f.sent.length, before);
});
// The project points at another repository on the same server: origin, binding and slot are as
// boot proved them, only the repositories the launch authority covers move.
const repointed = (f) => f.edit((d) => { d.projects.stack.remote = d.projects.stack.remote.replace('/owner/stack', '/owner/other'); });
test('CB3 a change while a call is in flight denies that call\'s answer, including one that moves only the launch authority', async (t) => {
for (const [name, change, code] of [['repository repointed', repointed, 'credential-revoked'],
...RACES.filter(([n]) => ['role definition changed', 'project excludes the copy'].includes(n)).map(([n, c]) => [n, c, null])]) {
await t.test(name, async (t) => {
const f = await fixture(t);
await f.boot();
const { cap } = await f.launch();
const h = f.hold((e) => e.path === '/api/v1/repos/owner/stack');
const pending = f.use(cap, f.GET);
await h.arrived;
await change(f);
h.release();
await assert.rejects(pending, code ? refuses(code) : (e) => e instanceof BusError && /^(copy-|credential-|restart-)/.test(e.code));
});
}
});
test('CB3 a running copy session whose launch authority moved answers no agent verb', async (t) => {
const f = await fixture(t);
await f.boot();
const { cap } = await f.launch();
// Past the copy check to the holder check: the session holds no claim yet.
await assert.rejects(f.call(cap, 'inbox'), refuses('not-holder'));
repointed(f);
assert.doesNotThrow(() => f.gate.authority('demo', 'rev'));
await assert.rejects(f.call(cap, 'inbox'), refuses('copy-authority-changed'));
});
test('CB3 a binding with no repository scope sends no repository request, read or write', async (t) => {
const f = await fixture(t, { services: { gitea: { scopes: ['read:user'], projects: ['stack'] } } });
await f.boot();
assert.deepEqual(f.gate.bound(), { rev: ['gitea'] });
const { cap } = await f.launch();
for (const method of ['GET', 'HEAD', 'POST'])
await assert.rejects(f.use(cap, { method, path: '/api/v1/repos/owner/stack' }), refuses('copy-request-refused'), method);
assert.equal(f.repoCalls(), 0);
});
test('CB3 the organization file revision the launch resolved holds through prepare, final admission and bindLaunch', async (t) => {
// Same document, other bytes: the launch authority is unchanged, only the revision moves.
const rewrite = (f) => writeFileSync(f.file, JSON.stringify(JSON.parse(readFileSync(f.file, 'utf8'))) + '\n');
await t.test('changed before prepare', async (t) => {
const f = await fixture(t);
await f.boot();
const revision = f.writer.read('demo').revision;
rewrite(f);
const before = f.sent.length;
await assert.rejects(f.gate.prepare('demo', 'rev', revision), refuses('organization-changed'));
assert.equal(f.sent.length, before);
});
await t.test('changed during prepare\'s proof', async (t) => {
const f = await fixture(t);
await f.boot();
const revision = f.writer.read('demo').revision;
const h = f.hold((e) => e.path === '/api/v1/token');
const preparing = f.gate.prepare('demo', 'rev', revision);
await h.arrived;
rewrite(f);
h.release();
await assert.rejects(preparing, refuses('organization-changed'));
});
await t.test('changed after final admission, before bindLaunch', async (t) => {
const f = await fixture(t);
await f.boot();
const thread = await f.threadFor('r1');
const revision = f.writer.read('demo').revision;
const { authority } = await f.gate.prepare('demo', 'rev', revision);
f.gate.admit('demo', 'rev', authority, revision);
rewrite(f);
assert.equal(f.gate.authority('demo', 'rev').authority, authority);
assert.throws(() => f.gate.admit('demo', 'rev', authority, revision), refuses('organization-changed'));
const bind = (extra) => f.b.bindLaunch({ business: 'demo', role: 'rev', run: 'r1', harness: 'pi', thread, copyAuthority: authority, ...extra });
assert.throws(() => bind({ revision }), refuses('organization-changed'));
assert.throws(() => bind({}), refuses('organization-changed'));
assert.equal(f.repoCalls(), 0);
// The same authority on the file as it stands now binds: the refusal above was the revision's.
assert.ok(bind({ revision: f.writer.read('demo').revision }));
});
await t.test('a non-copy never carries a revision', async (t) => {
const f = await fixture(t);
await f.boot();
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'chief', run: 'c1', harness: 'pi', revision: f.writer.read('demo').revision }), refuses('invalid-request'));
});
});
test('CB3 a copy past its rotation date refuses credential-expired at prepare and at the next call', async (t) => {
const f = await fixture(t);
await f.boot();
const { cap } = await f.launch();
f.advance(31);
const before = f.sent.length;
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('credential-expired'));
await assert.rejects(f.use(cap, f.GET), refuses('credential-expired'));
assert.equal(f.sent.length, before);
});
test('CB3 a boot that cannot verify proves nothing: the service unreachable, the token refused, the slot missing', async (t) => {
for (const [name, setup, code] of [
// The verifier answers an unreachable service as unverified (copy-grant.mjs verifyGiteaToken).
['service down', (f) => f.api.rule(() => true, { drop: true }), 'credential-scope-unverified'],
['token revoked at the service', (f) => f.api.rule((e) => e.path === '/api/v1/token', { status: 401, json: { message: 'no' } }), 'credential-scope-unverified'],
['slot missing', (f) => unlinkSync(f.slot), 'credential-unavailable'],
]) {
await t.test(name, async (t) => {
const f = await fixture(t);
setup(f);
await f.boot();
assert.deepEqual(f.gate.bound(), {});
assert.throws(() => f.gate.authority('demo', 'rev'), refuses(code));
await assert.rejects(f.call(f.human, 'instance.launch', { instance: 'rev' }), refuses(code));
assert.equal(f.repoCalls(), 0);
});
}
});
// The launcher's host stops on a reply later than 10 s, so a slow service must not hold prepare past
// its budget: the proof gets no answer, the launch is refused, and nothing more is sent.
test('CB3: a service that does not answer within the budget fails prepare and boot closed, inside the budget', { timeout: 60000 }, async (t) => {
await t.test('prepare', async (t) => {
const f = await fixture(t, { budgets: { prepareMs: 400 } });
await f.boot();
const unrule = f.api.rule((e) => e.path === '/api/v1/token', { hang: true });
t.after(unrule);
const at = Date.now();
await assert.rejects(f.gate.prepare('demo', 'rev'), (e) => e.code === 'credential-scope-unverified');
assert.ok(Date.now() - at < 2500, `prepare took ${Date.now() - at} ms`);
assert.equal(f.repoCalls(), 0);
});
await t.test('boot', async (t) => {
const f = await fixture(t, { budgets: { bootMs: 400 } });
const unrule = f.api.rule((e) => e.path === '/api/v1/token', { hang: true });
t.after(unrule);
const at = Date.now();
await f.boot();
assert.ok(Date.now() - at < 2500, `boot took ${Date.now() - at} ms`);
assert.deepEqual(f.gate.bound(), {});
await assert.rejects(f.gate.prepare('demo', 'rev'), (e) => e.code === 'credential-scope-unverified');
});
});