- The copy launch gate reads the organization registry entry on every check for a verified copy. A state other than active, or a new incarnation, refuses credential-revoked; an unreadable registry refuses credential-unavailable. - Repository reads need read or write repository scope, like writes need write scope. - The admitted organization revision travels from the launcher through bindLaunch. prepare checks it before and after the live proof, and admit checks it after the launch authority; a change refuses organization-changed. - Tests for the completion authority recheck, the broker session authority compare and an incarnation change; the copyRefusal comment now names where restart-required is answered.
568 lines
32 KiB
JavaScript
568 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) => {
|
|
for (const state of ['archived', 'deleting', 'reserved']) {
|
|
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('credential-revoked'));
|
|
assert.throws(() => f.gate.admit('demo', 'rev', authority), refuses('credential-revoked'));
|
|
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('credential-revoked'));
|
|
await assert.rejects(f.use(cap, f.GET), refuses('credential-revoked'));
|
|
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('credential-revoked'));
|
|
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('credential-revoked'));
|
|
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('credential-revoked'));
|
|
await assert.rejects(f.use(cap, f.GET), refuses('credential-revoked'));
|
|
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');
|
|
});
|
|
});
|