1464 lines
77 KiB
JavaScript
1464 lines
77 KiB
JavaScript
// Row 236 (#1742, #436b r5 section 9): the Gitea grant executor, its restart, revoke, sweep and admin
|
|
// verbs, against the fake Gitea in fake-gitea-api.mjs on 127.0.0.1. Every token is a fake the fake made;
|
|
// no test reaches git.mosaicstack.dev or any provider, and no test creates a real account or token
|
|
// (Q188). Decisions a test needs outside the human's yes go in by SQL as fixtures. A token value is only
|
|
// ever compared, never printed: the checks use assert.ok on a boolean.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { Broker, GRANT_EVIDENCE, writeGrantEvent } from '../src/broker.mjs';
|
|
import { Credentials } from '../src/credentials.mjs';
|
|
import {
|
|
SEAT_SCOPES, adminSha256, definitionSha256, giteaGrant, giteaKeys, giteaStart, identitySha256, openGiteaGrants, tokenName, tokenUnknown, versionAtLeast,
|
|
} from '../src/gitea-grant.mjs';
|
|
import { Store } from '../src/store.mjs';
|
|
import { fakeGiteaApi } from './fake-gitea-api.mjs';
|
|
import { grantDecision } from './host-fixtures.mjs';
|
|
|
|
const AT = '2026-10-21T00:00:00.000Z';
|
|
const USER = 'demo-coder';
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
const dayFrom = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
|
|
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
|
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
|
const DEFINITIONS = {
|
|
'chief-of-staff': { roleVersion: 2, credentials: [] },
|
|
pm: { roleVersion: 2, credentials: [] },
|
|
coder: { roleVersion: 2, credentials: [{ service: 'gitea' }] },
|
|
research: { roleVersion: 2, credentials: [{ service: 'gitea' }] },
|
|
};
|
|
async function until(fn, ms = 10000) {
|
|
const end = Date.now() + ms;
|
|
while (!fn()) {
|
|
if (Date.now() > end) throw new Error('timed out');
|
|
await sleep(20);
|
|
}
|
|
}
|
|
const rejects = (p, code) => assert.rejects(p, (e) => (assert.equal(e.code, code), true));
|
|
|
|
function makeBusiness(url) {
|
|
return {
|
|
id: 'demo',
|
|
human: 'jason',
|
|
arbiters: { technical: 'pm', delivery: 'pm' },
|
|
roles: {
|
|
chief: { definition: 'chief-of-staff', ...none },
|
|
pm: { definition: 'pm', ...none },
|
|
coder: {
|
|
definition: 'coder', ...none,
|
|
identity: { name: 'Coder', email: '[email protected]', gitea: { url, user: USER } },
|
|
publish: ['app', 'lib'],
|
|
},
|
|
},
|
|
definitions: structuredClone(DEFINITIONS),
|
|
projects: Object.fromEntries(['app', 'lib', 'old'].map((p) => [p, { remote: `${url}/acme/${p}` }])),
|
|
vars: { 'bus.tester': true },
|
|
};
|
|
}
|
|
|
|
async function setup(t, opts = {}) {
|
|
const dir = mkdtempSync(join(tmpdir(), 'g236-'));
|
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
const gitea = await fakeGiteaApi(t, join(dir, 'tls'), opts.fake);
|
|
gitea.addUser('acme');
|
|
for (const r of ['app', 'lib', 'old']) gitea.addRepo('acme', r);
|
|
const configDir = join(dir, 'cfg');
|
|
mkdirSync(configDir, { mode: 0o700 });
|
|
const storeRoot = join(configDir, 'secrets', 'agents');
|
|
mkdirSync(join(dir, 'data'), { mode: 0o700 });
|
|
const store = new Store(join(dir, 'data'));
|
|
const credentials = new Credentials({ references: {}, storeRoot, dataRoot: join(dir, 'data') });
|
|
t.after(() => {
|
|
credentials.close();
|
|
store.close();
|
|
});
|
|
const business = makeBusiness(gitea.url);
|
|
opts.business?.(business);
|
|
const grantApi = giteaGrant({ configDir, ca: gitea.ca, timeoutMs: opts.timeoutMs ?? 5000 });
|
|
const meta = { url: gitea.url, account: 'mosaic-grant', rotateBy: opts.adminRotateBy ?? dayFrom(60), setAt: AT };
|
|
if (opts.slot !== false) grantApi.writeAdmin('demo', gitea.adminToken, meta);
|
|
const keys = giteaKeys({ credentials, storeRoot });
|
|
const calls = [];
|
|
const broker = new Broker({
|
|
store,
|
|
businesses: { demo: business },
|
|
secretCheck: (value) => credentials.assertClean(value),
|
|
giteaGrant: grantApi,
|
|
giteaKeys: keys,
|
|
processing: opts.processing,
|
|
credentialVerbs: (id, verb, args, actor) => {
|
|
calls.push([verb, actor]);
|
|
if (verb === 'credentials.revoke' || verb === 'credentials.delete') {
|
|
try {
|
|
credentials.revoke('demo/' + args.agent, 'gitea');
|
|
} catch {}
|
|
}
|
|
return { ok: true };
|
|
},
|
|
});
|
|
const caps = {};
|
|
for (const role of ['chief', 'pm']) {
|
|
caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
|
|
broker.request(caps[role], { verb: 'role.claim' });
|
|
}
|
|
caps.human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
caps.tester = broker.bindTester({ business: 'demo' });
|
|
const call = (who, verb, args = {}) => broker.request(caps[who], { verb, args });
|
|
const raise = (grant = {}) =>
|
|
call('chief', 'decision.raise', {
|
|
action: 'credential.mint', question: 'Grant coder Gitea?', options, recommendation: 'yes', blocking: false,
|
|
grant: { method: 'gitea', agent: 'coder', target: { projects: ['app'] }, rotateBy: dayFrom(30), ...grant },
|
|
});
|
|
const grantEvents = () =>
|
|
store
|
|
.all(`SELECT body FROM events WHERE business='demo' AND ${GRANT_EVIDENCE} ORDER BY seq`)
|
|
.map((e) => JSON.parse(e.body));
|
|
const ended = (decision) => grantEvents().findLast((e) => e.grantDecision === decision && ['grant.applied', 'grant.failed'].includes(e.action));
|
|
const approve = async (grant) => {
|
|
const d = await raise(grant);
|
|
call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
|
await until(() => ended(d.id));
|
|
await broker.giteaIdle();
|
|
return { id: d.id, end: ended(d.id) };
|
|
};
|
|
const fixtureGrant = (over = {}, close) => {
|
|
const grant = {
|
|
method: 'gitea', agent: 'coder', target: { projects: ['app'] }, rotateBy: dayFrom(30),
|
|
definitionSha256: definitionSha256(business.definitions.coder),
|
|
identitySha256: identitySha256(business.roles.coder, business.projects),
|
|
adminSha256: adminSha256(grantApi.adminMeta('demo') ?? meta),
|
|
...over,
|
|
};
|
|
return grantDecision(store, { grant, ...(close !== undefined ? { close } : {}) });
|
|
};
|
|
const tokenFile = join(storeRoot, 'demo', 'coder', 'gitea');
|
|
const fileHolds = (sha1) => existsSync(tokenFile) && readFileSync(tokenFile, 'utf8') === sha1 + '\n';
|
|
const record = () => store.get("SELECT * FROM gitea_grants WHERE business='demo' AND agent='coder'");
|
|
const userRecord = () => store.get("SELECT * FROM gitea_users WHERE business='demo' AND agent='coder'");
|
|
const state = () => credentials.status().find((e) => e.instance === 'demo/coder' && e.service === 'gitea')?.state;
|
|
const messages = (decision) =>
|
|
store.all("SELECT to_role, body FROM messages WHERE business=? AND decision=? AND from_role='_bus' AND instr(body, ?) ORDER BY rowid", 'demo', decision, `(decision ${decision})`);
|
|
const userTokens = () => gitea.tokens(USER) ?? [];
|
|
// The newest token the fake made for the seat user: the one a grant minted last.
|
|
const seatToken = (id) => gitea.users.get(USER)?.tokens.find((x) => x.id === id) ?? null;
|
|
// No token the fake made shows up in an event, a message, a decision or a refusal.
|
|
const noLeak = () => {
|
|
const text = [
|
|
...store.all("SELECT body FROM events WHERE business='demo'").map((r) => r.body),
|
|
...store.all("SELECT body FROM messages WHERE business='demo'").map((r) => r.body),
|
|
...store.all('SELECT question FROM decisions').map((r) => r.question),
|
|
JSON.stringify(gitea.requests),
|
|
].join('\n');
|
|
assert.ok(gitea.issued.every((tok) => !text.includes(tok)), 'a token value reached the store or the request log');
|
|
};
|
|
return {
|
|
dir, gitea, configDir, storeRoot, store, credentials, business, grantApi, meta, keys, broker, caps, calls,
|
|
call, raise, approve, grantEvents, ended, fixtureGrant, tokenFile, fileHolds, record, userRecord, state, messages, userTokens, seatToken, noLeak,
|
|
};
|
|
}
|
|
|
|
// A decision the human approved and a killed bus child spent, with its grant.step events up to `steps`.
|
|
function interrupted(f, steps, over = {}) {
|
|
const id = f.fixtureGrant(over);
|
|
writeGrantEvent(f.store, AT, 'demo', { operation: 'grant.gitea', decision: id }, id);
|
|
for (const [step, extra = {}] of steps)
|
|
writeGrantEvent(f.store, AT, 'demo', { action: 'grant.step', method: 'gitea', grantDecision: id, agent: 'coder', user: USER, step, ...extra }, 'coder');
|
|
return id;
|
|
}
|
|
|
|
// An open rotation: a live first grant, then a spent fixture decision whose commit step names the live
|
|
// token as previous and `next` (a fake token minted here, never by a grant) as the new one.
|
|
async function openRotation(f) {
|
|
const first = await f.approve();
|
|
assert.equal(first.end.action, 'grant.applied');
|
|
const live = f.record();
|
|
const next = f.gitea.addToken(USER, tokenName('demo', 'coder', 'rot'), SEAT_SCOPES);
|
|
const previous = { tokenId: live.token_id, tokenName: live.token_name, projects: JSON.parse(live.projects), rotateBy: live.rotate_by, decision: live.decision };
|
|
const id = interrupted(f, [
|
|
['mint', { tokenName: next.name }],
|
|
['minted', { tokenId: next.id }],
|
|
['commit', { previousTokenId: live.token_id, tokenId: next.id, tokenName: next.name, previous }],
|
|
]);
|
|
return { first, live, next, id };
|
|
}
|
|
|
|
// A restart as runtime.mjs does it, on the same store: giteaStart, Credentials with its references and
|
|
// held list, a new broker, reconcileGrants before the socket opens, then reconcileGitea.
|
|
async function restart(t, f, { business = f.business, timeoutMs = 5000 } = {}) {
|
|
const references = {};
|
|
const { held } = giteaStart(f.store, f.storeRoot, { demo: business }, references);
|
|
const credentials = new Credentials({ references, storeRoot: f.storeRoot, dataRoot: join(f.dir, 'data'), held });
|
|
t.after(() => credentials.close());
|
|
const grantApi = giteaGrant({ configDir: f.configDir, ca: f.gitea.ca, timeoutMs });
|
|
const broker = new Broker({
|
|
store: f.store,
|
|
businesses: { demo: business },
|
|
secretCheck: (value) => credentials.assertClean(value),
|
|
giteaGrant: grantApi,
|
|
giteaKeys: giteaKeys({ credentials, storeRoot: f.storeRoot }),
|
|
});
|
|
const before = f.gitea.requests.length;
|
|
const now = broker.reconcileGrants();
|
|
const later = await broker.reconcileGitea();
|
|
await broker.giteaIdle();
|
|
const state = credentials.status().find((e) => e.instance === 'demo/coder' && e.service === 'gitea')?.state;
|
|
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
return { credentials, broker, held, now, later, state, human, requests: f.gitea.requests.slice(before) };
|
|
}
|
|
|
|
test('versionAtLeast compares numbers and refuses a prerelease of 1.27.0', () => {
|
|
for (const v of ['1.27.0', '1.27.1', 'v1.30.0', '1.27.0+build', '2.0.0']) assert.equal(versionAtLeast(v), true, v);
|
|
for (const v of ['1.26.4', '1.27.0-rc0', '1.9.0', '1.27', 'x', null, '1.27.0 ']) assert.equal(versionAtLeast(v), false, String(v));
|
|
});
|
|
|
|
test('X-G1: a raise that fails a check refuses and stores nothing', async (t) => {
|
|
const f = await setup(t);
|
|
const decisions = () => f.store.get('SELECT count(*) AS n FROM decisions').n;
|
|
const n = decisions();
|
|
const bad = [
|
|
[{ extra: 1 }, 'invalid-request'],
|
|
[{ rotateBy: '2020-01-01' }, 'invalid-request'],
|
|
[{ rotateBy: '2026-02-30' }, 'invalid-request'],
|
|
[{ target: { projects: [] } }, 'invalid-request'],
|
|
[{ target: { projects: ['app', 'app'] } }, 'invalid-request'],
|
|
[{ target: { projects: ['app'], repo: 'x' } }, 'invalid-request'],
|
|
[{ agent: 'pm' }, 'gitea-not-declared'],
|
|
[{ agent: 'nobody' }, 'gitea-not-declared'],
|
|
[{ target: { projects: ['old'] } }, 'remote-not-granted'],
|
|
];
|
|
for (const [over, code] of bad) await rejects(f.raise(over), code);
|
|
// Only the chief raises a grant.
|
|
await rejects(
|
|
f.call('pm', 'decision.raise', {
|
|
action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false,
|
|
grant: { method: 'gitea', agent: 'coder', target: { projects: ['app'] }, rotateBy: dayFrom(30) },
|
|
}),
|
|
'grant-chief-only',
|
|
);
|
|
assert.equal(decisions(), n);
|
|
assert.equal(f.gitea.requests.length, 0);
|
|
});
|
|
|
|
test('X-G1: a creative role, a role without identity and a missing or due slot refuse at raise', async (t) => {
|
|
const creative = await setup(t, { business: (b) => (b.roles.coder.definition = 'research') });
|
|
await rejects(creative.raise(), 'gitea-not-declared');
|
|
const bare = await setup(t, { business: (b) => delete b.roles.coder.identity });
|
|
await rejects(bare.raise(), 'gitea-not-declared');
|
|
const noSlot = await setup(t, { slot: false });
|
|
await rejects(noSlot.raise(), 'grant-admin-missing');
|
|
const due = await setup(t, { adminRotateBy: '2026-01-01' });
|
|
await rejects(due.raise(), 'grant-admin-rotation-due');
|
|
const elsewhere = await setup(t, { business: (b) => (b.projects.app.remote = 'https://example.test/acme/app') });
|
|
await rejects(elsewhere.raise(), 'remote-host-mismatch');
|
|
for (const f of [creative, bare, noSlot, due, elsewhere]) assert.equal(f.gitea.requests.length, 0);
|
|
});
|
|
|
|
// Row 285 (#1790, sage-2921): the chief's definition declares gitea for a read grant only, and this path
|
|
// mints SEAT_SCOPES, so a chief with identity and publish still refuses at raise and stores nothing.
|
|
test('X-G1: the chief of staff refuses at raise, whatever its definition, identity and publish declare', async (t) => {
|
|
const f = await setup(t, {
|
|
business: (b) => {
|
|
b.definitions['chief-of-staff'].credentials = [{ service: 'gitea' }];
|
|
b.roles.chief.identity = { name: 'Chief', email: '[email protected]', gitea: { url: b.roles.coder.identity.gitea.url, user: 'demo-chief' } };
|
|
b.roles.chief.publish = ['app'];
|
|
},
|
|
});
|
|
const n = f.store.get('SELECT count(*) AS n FROM decisions').n;
|
|
await rejects(f.raise({ agent: 'chief' }), 'gitea-not-declared');
|
|
assert.equal(f.store.get('SELECT count(*) AS n FROM decisions').n, n);
|
|
assert.equal(f.gitea.requests.length, 0);
|
|
});
|
|
|
|
test('X-G2: admin set refuses each failed check and writes nothing, then writes the slot once', async (t) => {
|
|
const f = await setup(t, { slot: false });
|
|
const set = (token, over = {}) =>
|
|
f.call('human', 'grants.admin.set', { service: 'gitea', url: f.gitea.url, account: 'mosaic-grant', rotateBy: dayFrom(60), token, ...over });
|
|
const slot = join(f.configDir, 'secrets', 'grants', 'demo', 'gitea');
|
|
for (const v of ['1.26.4', '1.27.0-rc0', '1.9.0']) {
|
|
f.gitea.state.version = v;
|
|
await rejects(set(f.gitea.adminToken), 'gitea-too-old');
|
|
}
|
|
f.gitea.state.version = '1.27.1';
|
|
const plain = f.gitea.addUser('plain', { scopes: ['write:admin', 'write:repository', 'write:user'] }).token.sha1;
|
|
await rejects(set(plain, { account: 'plain' }), 'grant-admin-account');
|
|
await rejects(set(f.gitea.adminToken, { account: 'someone-else' }), 'grant-admin-account');
|
|
const narrow = f.gitea.addToken('mosaic-grant', 'narrow', ['write:admin', 'write:repository']).sha1;
|
|
await rejects(set(narrow), 'grant-admin-scope');
|
|
const wide = f.gitea.addToken('mosaic-grant', 'wide', ['write:admin', 'write:repository', 'write:user', 'write:organization']).sha1;
|
|
await rejects(set(wide), 'grant-admin-scope');
|
|
const reach = f.gitea.addRepo('acme', 'shared', { collaborators: ['mosaic-grant'] });
|
|
await rejects(set(f.gitea.adminToken), 'grant-admin-not-dedicated');
|
|
f.gitea.repos.splice(f.gitea.repos.indexOf(reach), 1);
|
|
await rejects(set(f.gitea.adminToken, { rotateBy: '2020-01-01' }), 'invalid-request');
|
|
// The url is an instance origin over https (row 199's parser; a trailing slash is the same origin).
|
|
await rejects(set(f.gitea.adminToken, { url: f.gitea.url + '/gitea' }), 'invalid-request');
|
|
await rejects(set(f.gitea.adminToken, { url: f.gitea.url.replace('https:', 'http:') }), 'invalid-request');
|
|
await rejects(f.call('chief', 'grants.admin.set', { service: 'gitea', url: f.gitea.url, account: 'mosaic-grant', rotateBy: dayFrom(60), token: f.gitea.adminToken }), 'human-required');
|
|
await rejects(f.call('tester', 'grants.admin.set', { service: 'gitea', url: f.gitea.url, account: 'mosaic-grant', rotateBy: dayFrom(60), token: f.gitea.adminToken }), 'human-required');
|
|
assert.equal(existsSync(slot), false);
|
|
const meta = await set(f.gitea.adminToken);
|
|
assert.deepEqual(Object.keys(meta).sort(), ['account', 'rotateBy', 'setAt', 'url']);
|
|
assert.equal(statSync(join(slot, 'admin')).mode & 0o777, 0o600);
|
|
assert.equal(statSync(slot).mode & 0o777, 0o700);
|
|
assert.ok(readFileSync(join(slot, 'admin'), 'utf8') === f.gitea.adminToken + '\n');
|
|
await rejects(set(f.gitea.adminToken), 'grant-admin-exists');
|
|
const admin = f.grantEvents().filter((e) => e.action === 'grant.admin');
|
|
assert.deepEqual(admin.map((e) => e.verb), ['set']);
|
|
f.noLeak();
|
|
});
|
|
|
|
test('X-G3 to X-G8: an approve makes the user, mints the token, forks, checks reach and writes the file, record and grant.applied', async (t) => {
|
|
const f = await setup(t);
|
|
const d = await f.raise();
|
|
const block = f.store.get('SELECT question FROM decisions WHERE id=?', d.id).question;
|
|
assert.match(block, new RegExp(`user ${USER}, email [email protected]`));
|
|
assert.match(block, /Scopes: read:user, write:repository\./);
|
|
assert.match(block, /Kind: new Gitea user\./);
|
|
assert.match(block, new RegExp(`It may also reach this user's existing forks of:\\n${f.gitea.url}/acme/lib`));
|
|
f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
|
await until(() => f.ended(d.id));
|
|
await f.broker.giteaIdle();
|
|
const end = f.ended(d.id);
|
|
assert.equal(end.action, 'grant.applied', JSON.stringify(end));
|
|
const u = f.gitea.users.get(USER);
|
|
const userStep = f.grantEvents().find((e) => e.grantDecision === d.id && e.step === 'user');
|
|
assert.match(userStep.nonce, /^[0-9a-f]{16}$/);
|
|
assert.equal(u.full_name, `Coder [mosaic ${userStep.nonce}]`);
|
|
assert.equal(u.email, '[email protected]');
|
|
assert.equal(u.is_admin, false);
|
|
assert.deepEqual({ ...f.userRecord() }, { business: 'demo', agent: 'coder', user: USER, id: u.id, nonce: userStep.nonce, decision: d.id });
|
|
assert.equal(u.tokens.length, 1);
|
|
const tok = u.tokens[0];
|
|
assert.deepEqual(tok.scopes, [...SEAT_SCOPES]);
|
|
assert.equal(tok.name, tokenName('demo', 'coder', d.id));
|
|
assert.deepEqual(end, {
|
|
action: 'grant.applied', method: 'gitea', grantDecision: d.id, agent: 'coder', user: USER, userId: u.id, tokenId: tok.id,
|
|
forks: [`${f.gitea.url}/${USER}/app`],
|
|
});
|
|
const fork = f.gitea.repos.find((r) => r.owner === USER && r.name === 'app');
|
|
assert.equal(fork.parent, 'acme/app');
|
|
assert.equal(statSync(f.tokenFile).mode & 0o777, 0o600);
|
|
assert.ok(f.fileHolds(tok.sha1), 'the store file holds the minted token');
|
|
assert.deepEqual({ ...f.record() }, {
|
|
business: 'demo', agent: 'coder', user: USER, token_id: tok.id, token_name: tok.name, projects: '["app"]', rotate_by: dayFrom(30), decision: d.id,
|
|
});
|
|
assert.equal(f.state(), 'valid');
|
|
const mine = f.grantEvents().filter((e) => e.grantDecision === d.id);
|
|
assert.deepEqual(mine.map((e) => e.action + (e.step ? ' ' + e.step : '')), ['grant.step user', 'grant.step mint', 'grant.step minted', 'grant.step commit', 'grant.applied']);
|
|
assert.deepEqual(mine.find((e) => e.step === 'commit'), {
|
|
action: 'grant.step', method: 'gitea', grantDecision: d.id, agent: 'coder', user: USER, step: 'commit', previousTokenId: null, tokenId: tok.id, tokenName: tok.name, previous: null,
|
|
});
|
|
// X-G8: each call has its result, in order, and the ops are the section's.
|
|
const log = f.grantEvents().filter((e) => e.action === 'grant.call' || e.action === 'grant.result');
|
|
const calls = log.filter((e) => e.action === 'grant.call');
|
|
for (const c of calls) assert.equal(log.filter((e) => e.action === 'grant.result' && e.callId === c.callId).length, 1, c.op);
|
|
assert.deepEqual(calls.map((c) => c.op), [
|
|
'version', 'self', 'token-scopes', 'repos-check', 'user-get', 'user-create', 'token-create', 'fork-get', 'fork', 'seat-self', 'seat-repos',
|
|
]);
|
|
assert.ok(calls.every((c) => c.reason === d.id && c.method === 'gitea'));
|
|
// Section 7: the consume record is the only grant body with a `decision` key.
|
|
assert.deepEqual(f.grantEvents().filter((e) => 'decision' in e).map((e) => e.operation), ['grant.gitea']);
|
|
assert.deepEqual(f.messages(d.id).map((m) => m.to_role), ['human', 'chief']);
|
|
// The token routes went with Basic auth, the rest with the token header; nothing as an unknown user.
|
|
for (const r of f.gitea.requests) {
|
|
if (r.path.includes('/tokens')) assert.equal(r.auth, 'basic', r.path);
|
|
else if (r.path !== '/api/v1/version') assert.equal(r.auth, 'token', r.path);
|
|
}
|
|
f.noLeak();
|
|
});
|
|
|
|
test('X-G4: a user this stack did not make, an admin, a disabled user and a remade user each refuse', async (t) => {
|
|
const foreign = await setup(t);
|
|
foreign.gitea.addUser(USER);
|
|
let r = await foreign.approve();
|
|
assert.equal(r.end.code, 'gitea-user-foreign');
|
|
assert.equal(foreign.userRecord(), undefined);
|
|
assert.equal(foreign.gitea.calls().filter((c) => c.startsWith('POST')).length, 0);
|
|
|
|
const f = await setup(t);
|
|
assert.equal((await f.approve()).end.action, 'grant.applied');
|
|
const u = f.gitea.users.get(USER);
|
|
u.is_admin = true;
|
|
r = await f.approve();
|
|
assert.equal(r.end.code, 'gitea-user-admin');
|
|
u.is_admin = false;
|
|
u.prohibit_login = true;
|
|
r = await f.approve();
|
|
assert.equal(r.end.code, 'gitea-user-disabled');
|
|
u.prohibit_login = false;
|
|
const tokenCalls = () => f.gitea.calls().filter((c) => c.includes('/tokens')).length;
|
|
const before = tokenCalls();
|
|
f.gitea.removeUser(USER);
|
|
f.gitea.addUser(USER);
|
|
r = await f.approve();
|
|
assert.equal(r.end.code, 'gitea-user-foreign');
|
|
// A case variant of the name, remade: Gitea matches the login without case, the id differs.
|
|
f.gitea.removeUser(USER);
|
|
f.gitea.addUser(USER.toUpperCase());
|
|
r = await f.approve();
|
|
assert.equal(r.end.code, 'gitea-user-foreign');
|
|
// None of these minted a token or made a token call, and the live grant stands.
|
|
assert.equal(tokenCalls(), before);
|
|
assert.equal(f.state(), 'valid');
|
|
assert.equal(f.grantEvents().filter((e) => e.step === 'mint').length, 1);
|
|
f.noLeak();
|
|
});
|
|
|
|
test('X-G6 and X-G8: the user and mint steps are written before their calls, and each result after its call', async (t) => {
|
|
const f = await setup(t);
|
|
const seen = {};
|
|
// What the store holds while the call is at Gitea: the step, and the call with no result yet.
|
|
const at = (op, step) => () => {
|
|
const events = f.grantEvents();
|
|
const call = events.findLast((e) => e.action === 'grant.call' && e.op === op);
|
|
seen[op] = {
|
|
step: events.some((e) => e.action === 'grant.step' && e.step === step),
|
|
call: Boolean(call),
|
|
result: events.some((e) => e.action === 'grant.result' && e.callId === call?.callId),
|
|
};
|
|
return { pass: true };
|
|
};
|
|
f.gitea.rule((e) => e.method === 'POST' && e.path === '/api/v1/admin/users', at('user-create', 'user'));
|
|
f.gitea.rule((e) => e.method === 'POST' && e.path === `/api/v1/users/${USER}/tokens`, at('token-create', 'mint'));
|
|
const r = await f.approve();
|
|
assert.equal(r.end.action, 'grant.applied', JSON.stringify(r.end));
|
|
assert.deepEqual(seen, {
|
|
'user-create': { step: true, call: true, result: false },
|
|
'token-create': { step: true, call: true, result: false },
|
|
});
|
|
// Every result carries the HTTP status Gitea gave (user-get and fork-get answer 404 on a first grant).
|
|
const results = f.grantEvents().filter((e) => e.action === 'grant.result');
|
|
assert.ok(results.length > 0);
|
|
for (const e of results) assert.ok(Number.isInteger(e.status) && e.status >= 100 && e.status < 600, JSON.stringify(e));
|
|
assert.ok(results.some((e) => e.status === 404));
|
|
});
|
|
|
|
test('X-G4: a fork by the seat user of another repository than the project refuses fork-foreign', async (t) => {
|
|
const f = await setup(t);
|
|
f.gitea.addRepo('other', 'app');
|
|
f.gitea.addRepo(USER, 'app', { parent: 'other/app' });
|
|
const r = await f.approve();
|
|
assert.equal(r.end.code, 'fork-foreign', JSON.stringify(r.end));
|
|
assert.equal(f.gitea.calls().filter((c) => c.endsWith('/forks')).length, 0);
|
|
assert.deepEqual(f.userTokens(), []);
|
|
assert.equal(existsSync(f.tokenFile), false);
|
|
assert.equal(f.record(), undefined);
|
|
});
|
|
|
|
test('X-G5: a failure after the mint deletes the token, unloads nothing it did not load and leaves no file', async (t) => {
|
|
const f = await setup(t);
|
|
const off = f.gitea.rule((e) => e.method === 'POST' && e.path.endsWith('/forks'), { status: 500, json: {} });
|
|
const r = await f.approve();
|
|
off();
|
|
assert.equal(r.end.code, 'gitea-failed');
|
|
assert.equal(r.end.serviceLeft, undefined);
|
|
assert.equal(r.end.user, USER);
|
|
assert.deepEqual(f.userTokens(), []);
|
|
assert.equal(existsSync(f.tokenFile), false);
|
|
assert.equal(f.record(), undefined);
|
|
assert.equal(f.state(), undefined);
|
|
// The user stays recorded; the next grant reuses it.
|
|
assert.ok(f.userRecord());
|
|
const again = await f.approve();
|
|
assert.equal(again.end.action, 'grant.applied');
|
|
assert.equal(f.gitea.calls().filter((c) => c === 'POST /api/v1/admin/users').length, 1);
|
|
});
|
|
|
|
test('X-G5: a token delete that fails is left at Gitea, shown by status and taken by sweep', async (t) => {
|
|
const f = await setup(t);
|
|
const offFork = f.gitea.rule((e) => e.method === 'POST' && e.path.endsWith('/forks'), { status: 500, json: {} });
|
|
const offDelete = f.gitea.rule((e) => e.method === 'DELETE', { status: 500, json: {} });
|
|
const r = await f.approve();
|
|
offFork();
|
|
offDelete();
|
|
assert.equal(r.end.code, 'gitea-failed');
|
|
const id = f.userTokens()[0].id;
|
|
assert.deepEqual(r.end.serviceLeft, [id]);
|
|
assert.match(f.messages(r.id)[0].body, new RegExp(`Left at Gitea: ${id}\\. Run: mosaic grants sweep gitea`));
|
|
const status = await f.call('human', 'grants.status', { service: 'gitea' });
|
|
assert.deepEqual(status.serviceLeft, [{ agent: 'coder', user: USER, id }]);
|
|
const swept = await f.call('tester', 'grants.sweep', { service: 'gitea' });
|
|
assert.deepEqual(swept.left, []);
|
|
assert.deepEqual(f.userTokens(), []);
|
|
assert.deepEqual((await f.call('human', 'grants.status', { service: 'gitea' })).serviceLeft, []);
|
|
await rejects(f.call('chief', 'grants.sweep', { service: 'gitea' }), 'human-required');
|
|
});
|
|
|
|
test('X-G5: a mint that fails deletes by name, and a refusal at step 0 leaves the decision unspent', async (t) => {
|
|
const f = await setup(t);
|
|
const off = f.gitea.rule((e) => e.method === 'POST' && e.path.endsWith('/tokens'), { status: 500, json: {} });
|
|
const r = await f.approve();
|
|
off();
|
|
assert.equal(r.end.code, 'gitea-failed');
|
|
assert.ok(f.gitea.calls().includes(`DELETE /api/v1/users/${USER}/tokens/${tokenName('demo', 'coder', r.id)}`));
|
|
// Step 0: the admin token's scope changed after set.
|
|
f.gitea.admin.tokens[0].scopes = ['write:admin'];
|
|
const s0 = await f.approve();
|
|
assert.equal(s0.end.code, 'grant-admin-scope');
|
|
assert.equal(f.grantEvents().some((e) => e.operation === 'grant.gitea' && e.decision === s0.id), false);
|
|
f.gitea.admin.tokens[0].scopes = ['write:admin', 'write:repository', 'write:user'];
|
|
// The human runs it again once the slot is right.
|
|
const run = await f.call('human', 'grants.run', { decision: s0.id });
|
|
assert.deepEqual(run, { decision: s0.id, agent: 'coder', started: true });
|
|
await f.broker.giteaIdle();
|
|
assert.equal(f.grantEvents().filter((e) => e.grantDecision === s0.id).at(-1).action, 'grant.applied');
|
|
});
|
|
|
|
test('X-G5: an agent held for an unknown token is refused at step 0 with no Gitea call', async (t) => {
|
|
const f = await setup(t);
|
|
const held = f.fixtureGrant();
|
|
writeGrantEvent(f.store, AT, 'demo', { action: 'grant.failed', method: 'gitea', grantDecision: held, agent: 'coder', code: 'grant-token-unknown', tokenId: 7 }, 'coder');
|
|
const r = await f.approve();
|
|
assert.equal(r.end.code, 'grant-token-unknown');
|
|
assert.equal(f.gitea.requests.length, 0);
|
|
await rejects(f.call('human', 'grants.run', { decision: r.id }), 'grant-token-unknown');
|
|
});
|
|
|
|
test('X-G5 and X-G9 (step 7): a rotation whose old-token delete fails stays applied with the old id left', async (t) => {
|
|
const f = await setup(t);
|
|
const first = await f.approve();
|
|
const oldId = f.record().token_id;
|
|
const off = f.gitea.rule((e) => e.method === 'DELETE' && e.path.endsWith(`/tokens/${oldId}`), { status: 500, json: {} });
|
|
const second = await f.approve();
|
|
off();
|
|
assert.equal(second.end.action, 'grant.applied');
|
|
assert.deepEqual(second.end.serviceLeft, [oldId]);
|
|
assert.equal(f.record().decision, second.id);
|
|
assert.notEqual(f.record().token_id, oldId);
|
|
assert.match(f.messages(second.id)[0].body, new RegExp(`The previous token ${oldId} is still live`));
|
|
const swept = await f.call('human', 'grants.sweep', { service: 'gitea' });
|
|
assert.deepEqual(swept.left, []);
|
|
assert.deepEqual(f.userTokens().map((x) => x.id), [f.record().token_id]);
|
|
assert.notEqual(first.id, second.id);
|
|
});
|
|
|
|
test('X-G10: in a rotation the store file holds the new token before the old one is deleted', async (t) => {
|
|
const f = await setup(t);
|
|
await f.approve();
|
|
const oldId = f.record().token_id;
|
|
let seen = null;
|
|
f.gitea.rule(
|
|
(e) => e.method === 'DELETE' && e.path.endsWith(`/tokens/${oldId}`),
|
|
() => {
|
|
const newest = f.gitea.users.get(USER).tokens.at(-1);
|
|
seen = newest.id !== oldId && f.fileHolds(newest.sha1);
|
|
return { pass: true };
|
|
},
|
|
);
|
|
const r = await f.approve();
|
|
assert.equal(r.end.action, 'grant.applied');
|
|
assert.equal(seen, true);
|
|
const commit = f.grantEvents().find((e) => e.grantDecision === r.id && e.step === 'commit');
|
|
assert.equal(commit.previousTokenId, oldId);
|
|
assert.equal(commit.previous.tokenId, oldId);
|
|
assert.deepEqual(f.userTokens().map((x) => x.id), [r.end.tokenId]);
|
|
f.noLeak();
|
|
});
|
|
|
|
test('X-G6: a rotation cut before the rename settles back at once, and the old token stays live', async (t) => {
|
|
const f = await setup(t);
|
|
await f.approve();
|
|
const live = f.record();
|
|
// The store's tmp path is a directory, so the issue fails before its rename.
|
|
mkdirSync(f.tokenFile + '.tmp');
|
|
const r = await f.approve();
|
|
rmSync(f.tokenFile + '.tmp', { recursive: true });
|
|
assert.equal(r.end.code, 'interrupted');
|
|
assert.equal(r.end.settled, 'back');
|
|
assert.deepEqual({ ...f.record() }, { ...live });
|
|
assert.deepEqual(f.userTokens().map((x) => x.id), [live.token_id]);
|
|
assert.ok(f.fileHolds(f.seatToken(live.token_id).sha1));
|
|
assert.equal(f.state(), 'valid');
|
|
});
|
|
|
|
test('X-G6: a rotation whose step 8 write fails is settle-pending, and a restart settles it forward', async (t) => {
|
|
const f = await setup(t);
|
|
await f.approve();
|
|
const oldId = f.record().token_id;
|
|
f.store.run("CREATE TEMP TRIGGER g236_fail BEFORE UPDATE ON gitea_grants BEGIN SELECT RAISE(ABORT, 'test fault'); END");
|
|
const d = await f.raise();
|
|
f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
|
await until(() => f.grantEvents().some((e) => e.grantDecision === d.id && e.step === 'commit'));
|
|
await f.broker.giteaIdle();
|
|
f.store.run('DROP TRIGGER g236_fail');
|
|
assert.equal(f.ended(d.id), undefined);
|
|
const status = await f.call('human', 'grants.status', { service: 'gitea' });
|
|
assert.deepEqual(status.settlePending, [{ decision: d.id, agent: 'coder' }]);
|
|
const newId = f.grantEvents().find((e) => e.grantDecision === d.id && e.step === 'commit').tokenId;
|
|
const r = await restart(t, f);
|
|
assert.deepEqual(r.held, []);
|
|
const end = f.ended(d.id);
|
|
assert.equal(end.action, 'grant.applied');
|
|
assert.equal(end.settled, 'forward');
|
|
assert.equal(end.serviceLeft, undefined);
|
|
assert.equal(f.record().token_id, newId);
|
|
assert.equal(f.record().decision, d.id);
|
|
assert.equal(r.state, 'valid');
|
|
assert.deepEqual(f.userTokens().map((x) => x.id), [newId]);
|
|
assert.notEqual(newId, oldId);
|
|
});
|
|
|
|
test('X-G6: a restart settles an open rotation back when the file still holds the old token', async (t) => {
|
|
const f = await setup(t);
|
|
const { live, next, id } = await openRotation(f);
|
|
const r = await restart(t, f);
|
|
const end = f.ended(id);
|
|
assert.equal(end.code, 'interrupted');
|
|
assert.equal(end.settled, 'back');
|
|
assert.equal(f.record().token_id, live.token_id);
|
|
assert.equal(f.record().decision, live.decision);
|
|
assert.deepEqual(f.userTokens().map((x) => x.id), [live.token_id]);
|
|
assert.equal(f.seatToken(next.id), null);
|
|
assert.equal(r.state, 'valid');
|
|
});
|
|
|
|
test('X-G6: a restart settles an open rotation forward when the file holds the new token', async (t) => {
|
|
const f = await setup(t);
|
|
const { live, next, id } = await openRotation(f);
|
|
writeFileSync(f.tokenFile, next.sha1 + '\n');
|
|
const r = await restart(t, f);
|
|
const end = f.ended(id);
|
|
assert.equal(end.action, 'grant.applied');
|
|
assert.equal(end.settled, 'forward');
|
|
assert.equal(f.record().token_id, next.id);
|
|
assert.equal(f.record().decision, id);
|
|
assert.deepEqual(f.userTokens().map((x) => x.id), [next.id]);
|
|
assert.equal(r.state, 'valid');
|
|
assert.notEqual(live.token_id, next.id);
|
|
});
|
|
|
|
test('X-G6: a 401 on the file token loses the grant: both tokens go, the record goes and memory unloads', async (t) => {
|
|
const f = await setup(t);
|
|
const { live, id } = await openRotation(f);
|
|
const u = f.gitea.users.get(USER);
|
|
u.tokens.splice(u.tokens.findIndex((x) => x.id === live.token_id), 1);
|
|
const r = await restart(t, f);
|
|
const end = f.ended(id);
|
|
assert.equal(end.code, 'grant-lost');
|
|
assert.equal(end.serviceLeft, undefined);
|
|
assert.equal(f.record(), undefined);
|
|
assert.deepEqual(f.userTokens(), []);
|
|
assert.equal(existsSync(f.tokenFile), false);
|
|
assert.equal(r.state, undefined);
|
|
assert.deepEqual(f.messages(id).map((m) => m.to_role), ['human', 'chief']);
|
|
});
|
|
|
|
test('X-G6: a 403, 404, 500, a hang and a dropped socket each leave the rotation settle-pending with nothing deleted', async (t) => {
|
|
for (const answer of [{ status: 403, json: {} }, { status: 404, json: {} }, { status: 500, json: {} }, { hang: true }, { drop: true }]) {
|
|
const f = await setup(t);
|
|
const { live, next, id } = await openRotation(f);
|
|
f.gitea.rule((e) => e.method === 'GET' && e.path === '/api/v1/token' && e.as === USER, answer);
|
|
const r = await restart(t, f, { timeoutMs: 400 });
|
|
assert.equal(f.ended(id), undefined, JSON.stringify(answer));
|
|
assert.deepEqual(f.userTokens().map((x) => x.id).sort(), [live.token_id, next.id].sort());
|
|
assert.equal(r.requests.filter((q) => q.method === 'DELETE').length, 0);
|
|
assert.equal(f.record().token_id, live.token_id);
|
|
assert.deepEqual(openGiteaGrants(f.store, 'demo').map((o) => o.decision), [id]);
|
|
}
|
|
});
|
|
|
|
test('X-G6: a file token no grant minted is held; the next restart says so again; a revoke clears it and takes both ids', async (t) => {
|
|
const f = await setup(t);
|
|
const { live, next, id } = await openRotation(f);
|
|
const stray = f.gitea.addToken(USER, 'stray', SEAT_SCOPES);
|
|
writeFileSync(f.tokenFile, stray.sha1 + '\n');
|
|
const r1 = await restart(t, f);
|
|
const end = f.ended(id);
|
|
assert.equal(end.code, 'grant-token-unknown');
|
|
assert.equal(end.tokenId, stray.id);
|
|
assert.equal(r1.state, 'held');
|
|
assert.ok(existsSync(f.tokenFile));
|
|
assert.deepEqual([...tokenUnknown(f.store, 'demo').keys()], ['coder']);
|
|
const before = f.messages(id).length;
|
|
const r2 = await restart(t, f);
|
|
assert.deepEqual(r2.held, ['demo/coder:gitea']);
|
|
assert.equal(r2.state, 'held');
|
|
assert.equal(r2.requests.length, 0);
|
|
assert.equal(f.messages(id).length, before + 2);
|
|
assert.match(f.messages(id).at(-1).body, /still holds a token no grant minted/);
|
|
// The revoke deletes the live token and both ids of the last commit step; the stray one stays for Jason.
|
|
const out = await r2.broker.request(r2.human, { verb: 'grants.revoke', args: { service: 'gitea', agent: 'coder', reason: 'test' } });
|
|
assert.deepEqual(out.serviceLeft, []);
|
|
const revoked = f.grantEvents().filter((e) => e.action === 'grant.revoked').at(-1);
|
|
assert.deepEqual(revoked.tokens.sort(), [live.token_id, next.id].sort());
|
|
assert.deepEqual(f.userTokens().map((x) => x.id), [stray.id]);
|
|
assert.deepEqual([...tokenUnknown(f.store, 'demo').keys()], []);
|
|
const r3 = await restart(t, f);
|
|
assert.deepEqual(r3.held, []);
|
|
});
|
|
|
|
test('N9: a revoke of an open rotation, then a restart: no settle, no Gitea call and no message', async (t) => {
|
|
const f = await setup(t);
|
|
const { live, next, id } = await openRotation(f);
|
|
const out = await f.call('human', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'test' });
|
|
assert.deepEqual(out.serviceLeft, []);
|
|
assert.deepEqual(f.grantEvents().find((e) => e.action === 'grant.revoked').tokens.sort(), [live.token_id, next.id].sort());
|
|
assert.deepEqual(f.userTokens(), []);
|
|
const status = await f.call('human', 'grants.status', { service: 'gitea' });
|
|
assert.deepEqual(status.settlePending, []);
|
|
const before = f.messages(id).length;
|
|
const r = await restart(t, f);
|
|
assert.deepEqual(r.requests, []);
|
|
const end = f.ended(id);
|
|
assert.equal(end.code, 'revoked');
|
|
assert.equal(end.settled, undefined);
|
|
assert.equal(f.messages(id).length, before);
|
|
assert.equal(f.record(), undefined);
|
|
assert.equal(r.state, undefined);
|
|
});
|
|
|
|
test('N9: a 404 on a delete by id counts as done', async (t) => {
|
|
const f = await setup(t);
|
|
const { next } = await openRotation(f);
|
|
const u = f.gitea.users.get(USER);
|
|
u.tokens.splice(u.tokens.findIndex((x) => x.id === next.id), 1);
|
|
const out = await f.call('human', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'test' });
|
|
assert.deepEqual(out.serviceLeft, []);
|
|
assert.ok(f.gitea.calls().includes(`DELETE /api/v1/users/${USER}/tokens/${next.id}`));
|
|
});
|
|
|
|
test('X-G6: a restart undoes each step a killed child left, and spends an approved unspent decision', async (t) => {
|
|
const f = await setup(t);
|
|
// user: a user made with the nonce is adopted; one without it is an orphan; none is interrupted.
|
|
const nonce = 'aaaaaaaaaaaaaaaa';
|
|
f.gitea.addUser(USER, { full_name: `Coder [mosaic ${nonce}]` });
|
|
const u1 = interrupted(f, [['user', { nonce }]]);
|
|
let r = await restart(t, f);
|
|
assert.equal(f.ended(u1).code, 'interrupted');
|
|
assert.equal(f.userRecord().id, f.gitea.users.get(USER).id);
|
|
assert.equal(f.userRecord().nonce, nonce);
|
|
|
|
const g = await setup(t);
|
|
g.gitea.addUser(USER, { full_name: 'Someone' });
|
|
const u2 = interrupted(g, [['user', { nonce }]]);
|
|
await restart(t, g);
|
|
assert.equal(g.ended(u2).code, 'gitea-user-orphan');
|
|
assert.equal(g.userRecord(), undefined);
|
|
|
|
const h = await setup(t);
|
|
const u3 = interrupted(h, [['user', { nonce }]]);
|
|
await restart(t, h);
|
|
assert.equal(h.ended(u3).code, 'interrupted');
|
|
assert.equal(h.userRecord(), undefined);
|
|
|
|
// mint by name, minted by id, a first grant's commit by id with its file unlinked.
|
|
const k = await setup(t);
|
|
k.gitea.addUser(USER);
|
|
const named = k.gitea.addToken(USER, tokenName('demo', 'coder', 'x1'), SEAT_SCOPES);
|
|
const m1 = interrupted(k, [['mint', { tokenName: named.name }]]);
|
|
// X-G8: a call the child wrote before it was killed, with no result, is listed in the end.
|
|
writeGrantEvent(k.store, AT, 'demo', { action: 'grant.call', callId: 'c-open', reason: m1, method: 'gitea', op: 'token-create' }, 'coder');
|
|
r = await restart(t, k);
|
|
assert.equal(k.ended(m1).code, 'interrupted');
|
|
assert.deepEqual(k.ended(m1).unclosedCalls, [{ callId: 'c-open', op: 'token-create' }]);
|
|
assert.match(k.messages(m1)[0].body, /Calls with no result: token-create\./);
|
|
assert.deepEqual(k.userTokens(), []);
|
|
|
|
// One executor per agent (grant-busy), so a killed child leaves one open grant per agent: its own store.
|
|
const m = await setup(t);
|
|
m.gitea.addUser(USER);
|
|
const byId = m.gitea.addToken(USER, 'x2', SEAT_SCOPES);
|
|
const m2 = interrupted(m, [['mint', { tokenName: 'x2' }], ['minted', { tokenId: byId.id }]]);
|
|
await restart(t, m);
|
|
assert.equal(m.ended(m2).code, 'interrupted');
|
|
assert.equal(m.ended(m2).serviceLeft, undefined);
|
|
assert.ok(m.gitea.calls().includes(`DELETE /api/v1/users/${USER}/tokens/${byId.id}`));
|
|
assert.deepEqual(m.userTokens(), []);
|
|
|
|
const c = await setup(t);
|
|
c.gitea.addUser(USER);
|
|
const tok = c.gitea.addToken(USER, 'x3', SEAT_SCOPES);
|
|
const c1 = interrupted(c, [['commit', { previousTokenId: null, tokenId: tok.id, tokenName: 'x3', previous: null }]]);
|
|
mkdirSync(join(c.storeRoot, 'demo', 'coder'), { recursive: true, mode: 0o700 });
|
|
writeFileSync(c.tokenFile, tok.sha1 + '\n', { mode: 0o600 });
|
|
r = await restart(t, c);
|
|
assert.deepEqual(r.held, ['demo/coder:gitea']);
|
|
assert.equal(c.ended(c1).code, 'interrupted');
|
|
assert.equal(existsSync(c.tokenFile), false);
|
|
assert.deepEqual(c.userTokens(), []);
|
|
assert.equal(r.state, undefined);
|
|
|
|
// An approved decision no child spent is spent at start and ends interrupted, with no Gitea call.
|
|
const o = await setup(t);
|
|
const open = o.fixtureGrant();
|
|
const status = await o.call('human', 'grants.status', { service: 'gitea' });
|
|
assert.deepEqual(status.open, [{ decision: open, agent: 'coder', spentAtNextStart: true }]);
|
|
r = await restart(t, o);
|
|
assert.deepEqual(r.now, [open]);
|
|
assert.equal(o.ended(open).code, 'interrupted');
|
|
assert.ok(o.grantEvents().some((e) => e.operation === 'grant.gitea' && e.decision === open));
|
|
assert.deepEqual(r.requests, []);
|
|
});
|
|
|
|
test('X-G6: a restart with no admin slot leaves the user an orphan and the token left', async (t) => {
|
|
const f = await setup(t);
|
|
const id = interrupted(f, [['mint', { tokenName: 'x1' }], ['minted', { tokenId: 41 }]]);
|
|
rmSync(join(f.configDir, 'secrets', 'grants'), { recursive: true });
|
|
await restart(t, f);
|
|
assert.deepEqual(f.ended(id).serviceLeft, [41]);
|
|
const u = interrupted(f, [['user', { nonce: 'bbbbbbbbbbbbbbbb' }]]);
|
|
await restart(t, f);
|
|
assert.equal(f.ended(u).code, 'gitea-user-orphan');
|
|
});
|
|
|
|
test('X-G7: a change to the definition, identity, publish remotes or admin slot after the raise ends decision-mismatch', async (t) => {
|
|
// The broker holds its own copy of the business, so each change is a binding the raise saw before it
|
|
// (as row 234's G9 does): the decision's hash differs from what step 1 hashes now.
|
|
const f = await setup(t);
|
|
const b = f.business;
|
|
const meta = f.grantApi.adminMeta('demo');
|
|
for (const over of [
|
|
{ definitionSha256: definitionSha256({ ...b.definitions.coder, roleVersion: 3 }) },
|
|
{ identitySha256: identitySha256({ ...b.roles.coder, identity: { ...b.roles.coder.identity, email: '[email protected]' } }, b.projects) },
|
|
{ identitySha256: identitySha256(b.roles.coder, { ...b.projects, lib: { remote: `${f.gitea.url}/acme/lib2` } }) },
|
|
{ adminSha256: adminSha256({ ...meta, setAt: '2026-01-01T00:00:00.000Z' }) },
|
|
]) {
|
|
const id = f.fixtureGrant(over);
|
|
await f.call('human', 'grants.run', { decision: id });
|
|
await f.broker.giteaIdle();
|
|
assert.equal(f.ended(id).code, 'decision-mismatch', JSON.stringify(over));
|
|
// The spend rolled back with the recheck.
|
|
assert.equal(f.grantEvents().some((e) => e.operation === 'grant.gitea' && e.decision === id), false);
|
|
}
|
|
assert.equal(f.gitea.calls().some((c) => c.includes('/admin/users') || c.includes('/tokens')), false);
|
|
});
|
|
|
|
test('X-G7 and X-G9: an admin rotate voids open decisions, and rotate --finish deletes what was left', async (t) => {
|
|
const f = await setup(t);
|
|
const open = f.fixtureGrant();
|
|
const offDelete = f.gitea.rule((e) => e.method === 'DELETE' && e.path.endsWith('/tokens/mosaic-grant-bootstrap'), { status: 500, json: {} });
|
|
const out = await f.call('human', 'grants.admin.rotate', { service: 'gitea', rotateBy: dayFrom(90) });
|
|
offDelete();
|
|
assert.deepEqual(out.voided, [open]);
|
|
assert.equal(out.adminTokenLeft, 'mosaic-grant-bootstrap');
|
|
assert.match(out.tokenName, /^mosaic-grant-\d{8}T\d{6}Z$/);
|
|
assert.equal(f.grantApi.adminMeta('demo').rotateBy, dayFrom(90));
|
|
const successor = f.gitea.admin.tokens.find((x) => x.name === out.tokenName);
|
|
assert.ok(readFileSync(join(f.configDir, 'secrets', 'grants', 'demo', 'gitea', 'admin'), 'utf8') === successor.sha1 + '\n');
|
|
assert.equal((await f.call('human', 'grants.status', { service: 'gitea' })).adminTokenLeft, 'mosaic-grant-bootstrap');
|
|
await f.call('human', 'grants.run', { decision: open });
|
|
await f.broker.giteaIdle();
|
|
assert.equal(f.ended(open).code, 'decision-mismatch');
|
|
const done = await f.call('human', 'grants.admin.rotate', { service: 'gitea', finish: true });
|
|
assert.deepEqual(done, { deleted: 'mosaic-grant-bootstrap' });
|
|
assert.deepEqual(f.gitea.admin.tokens.map((x) => x.name), [out.tokenName]);
|
|
await rejects(f.call('human', 'grants.admin.rotate', { service: 'gitea', finish: true }), 'grant-admin-nothing-left');
|
|
assert.deepEqual(f.grantEvents().filter((e) => e.action === 'grant.admin').map((e) => e.verb), ['rotate', 'rotate-finish']);
|
|
f.noLeak();
|
|
});
|
|
|
|
test('X-G9: a rotate whose name is taken refuses and writes nothing; remove takes the slot away', async (t) => {
|
|
const f = await setup(t);
|
|
const slotFile = join(f.configDir, 'secrets', 'grants', 'demo', 'gitea', 'admin');
|
|
const before = readFileSync(slotFile);
|
|
const now = Date.now();
|
|
for (let s = -1; s < 6; s++) {
|
|
const stamp = new Date(now + s * 1000).toISOString().slice(0, 19).replace(/[-:]/g, '') + 'Z';
|
|
f.gitea.addToken('mosaic-grant', `mosaic-grant-${stamp}`, ['write:admin', 'write:repository', 'write:user']);
|
|
}
|
|
await rejects(f.call('human', 'grants.admin.rotate', { service: 'gitea', rotateBy: dayFrom(90) }), 'grant-admin-name-taken');
|
|
assert.ok(readFileSync(slotFile).equals(before));
|
|
await rejects(f.call('human', 'grants.admin.rotate', { service: 'gitea', rotateBy: '2020-01-01' }), 'invalid-request');
|
|
await rejects(f.call('human', 'grants.admin.rotate', { service: 'gitea', rotateBy: dayFrom(90), finish: true }), 'invalid-request');
|
|
await rejects(f.call('chief', 'grants.admin.remove', { service: 'gitea' }), 'human-required');
|
|
assert.deepEqual(await f.call('human', 'grants.admin.remove', { service: 'gitea' }), { removed: true });
|
|
assert.equal(existsSync(join(f.configDir, 'secrets', 'grants', 'demo', 'gitea')), false);
|
|
await rejects(f.call('human', 'grants.admin.remove', { service: 'gitea' }), 'grant-admin-missing');
|
|
await rejects(f.raise(), 'grant-admin-missing');
|
|
});
|
|
|
|
test('X-G9: a slot past its rotate-by date refuses a run at step 0, leaving the decision unspent', async (t) => {
|
|
const f = await setup(t);
|
|
const open = f.fixtureGrant();
|
|
f.grantApi.writeAdmin('demo', f.gitea.adminToken, { ...f.meta, rotateBy: '2026-01-01' });
|
|
const status = await f.call('human', 'grants.status', { service: 'gitea' });
|
|
assert.equal(status.admin.rotationDue, true);
|
|
await f.call('human', 'grants.run', { decision: open });
|
|
await f.broker.giteaIdle();
|
|
assert.equal(f.ended(open).code, 'grant-admin-rotation-due');
|
|
assert.equal(f.gitea.requests.length, 0);
|
|
const before = await f.call('human', 'grants.status', { service: 'gitea' });
|
|
assert.deepEqual(before.open, [{ decision: open, agent: 'coder', spentAtNextStart: true }]);
|
|
// X-G11: a restart spends it as interrupted, and the human's run then refuses decision-consumed.
|
|
const r = await restart(t, f);
|
|
assert.deepEqual(r.now, [open]);
|
|
assert.equal(f.ended(open).code, 'interrupted');
|
|
await rejects(r.broker.request(r.human, { verb: 'grants.run', args: { decision: open } }), 'decision-consumed');
|
|
});
|
|
|
|
test('X-G11: run refuses every caller but the human, an unapproved, a spent and a busy decision', async (t) => {
|
|
const f = await setup(t);
|
|
const open = f.fixtureGrant();
|
|
await rejects(f.call('chief', 'grants.run', { decision: open }), 'human-required');
|
|
await rejects(f.call('tester', 'grants.run', { decision: open }), 'human-required');
|
|
const unapproved = f.fixtureGrant({}, null);
|
|
await rejects(f.call('human', 'grants.run', { decision: unapproved }), 'not-authorized');
|
|
const tester = f.fixtureGrant({}, { op: 'resolved', by: 'tester', choice: 'yes', via: 'broker' });
|
|
await rejects(f.call('human', 'grants.run', { decision: tester }), 'not-authorized');
|
|
const signal = f.keys.claim('demo', 'coder');
|
|
await rejects(f.call('human', 'grants.run', { decision: open }), 'grant-busy');
|
|
f.keys.release('demo', 'coder', signal);
|
|
await f.call('human', 'grants.run', { decision: open });
|
|
await f.broker.giteaIdle();
|
|
assert.equal(f.ended(open).action, 'grant.applied');
|
|
await rejects(f.call('human', 'grants.run', { decision: open }), 'decision-consumed');
|
|
await rejects(f.call('human', 'grants.run', { decision: open, extra: 1 }), 'invalid-request');
|
|
// status: the human, the tester and the chief; no other role.
|
|
for (const who of ['human', 'tester', 'chief']) assert.ok(await f.call(who, 'grants.status', { service: 'gitea' }));
|
|
await rejects(f.call('pm', 'grants.status', { service: 'gitea' }), 'grant-chief-only');
|
|
assert.deepEqual(f.grantEvents().filter((e) => 'decision' in e).map((e) => e.operation), ['grant.gitea']);
|
|
});
|
|
|
|
test('kill switch (plan :16): while halted the chief still reads grants.status, and an agent grants.revoke refuses', async (t) => {
|
|
const control = { halted: false, version: 1, generation: randomUUID() };
|
|
const f = await setup(t, { processing: { read: () => ({ ...control }) } });
|
|
await f.approve();
|
|
control.halted = true;
|
|
const status = await f.call('human', 'grants.status', { service: 'gitea' });
|
|
assert.deepEqual(await f.call('chief', 'grants.status', { service: 'gitea' }), status);
|
|
// The gate throws before dispatch, as in processing.test.mjs, so the call is wrapped.
|
|
await rejects(async () => f.call('pm', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'r' }), 'processing-halted');
|
|
assert.equal(f.record().agent, 'coder');
|
|
assert.equal(f.state(), 'valid');
|
|
});
|
|
|
|
test('X-G11: a revoke while the executor runs aborts it and the grant ends revoked', async (t) => {
|
|
const f = await setup(t);
|
|
let release;
|
|
const held = new Promise((r) => (release = r));
|
|
f.gitea.rule((e) => e.method === 'POST' && e.path.endsWith('/forks'), async () => {
|
|
await held;
|
|
return { pass: true };
|
|
});
|
|
const d = await f.raise();
|
|
f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
|
await until(() => f.gitea.calls().some((c) => c.endsWith('/forks')));
|
|
const revoke = f.call('human', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'stop' });
|
|
release();
|
|
await revoke;
|
|
await until(() => f.ended(d.id));
|
|
await f.broker.giteaIdle();
|
|
assert.equal(f.ended(d.id).code, 'revoked');
|
|
assert.equal(f.record(), undefined);
|
|
assert.deepEqual(f.userTokens(), []);
|
|
});
|
|
|
|
test('X-G11: a revoke unloads memory and removes the file before its Gitea delete', async (t) => {
|
|
const f = await setup(t);
|
|
const live = (await f.approve()).end;
|
|
assert.equal(live.action, 'grant.applied');
|
|
assert.ok(existsSync(f.tokenFile));
|
|
let release;
|
|
const held = new Promise((r) => (release = r));
|
|
const seen = {};
|
|
f.gitea.rule((e) => e.method === 'DELETE' && e.path === `/api/v1/users/${USER}/tokens/${live.tokenId}`, async () => {
|
|
seen.file = existsSync(f.tokenFile);
|
|
seen.state = f.state();
|
|
await held;
|
|
return { pass: true };
|
|
});
|
|
const out = f.call('human', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'stop' });
|
|
await until(() => 'file' in seen);
|
|
release();
|
|
assert.deepEqual((await out).serviceLeft, []);
|
|
assert.equal(seen.file, false);
|
|
assert.notEqual(seen.state, 'valid');
|
|
assert.deepEqual(f.userTokens(), []);
|
|
});
|
|
|
|
test('X-G11: a revoke during a rotation step 7 ends it revoked, and step 8 writes no record back', async (t) => {
|
|
const f = await setup(t);
|
|
await f.approve();
|
|
const oldId = f.record().token_id;
|
|
let release;
|
|
const held = new Promise((r) => (release = r));
|
|
f.gitea.rule((e) => e.method === 'DELETE' && e.path === `/api/v1/users/${USER}/tokens/${oldId}`, async () => {
|
|
await held;
|
|
return { pass: true };
|
|
});
|
|
const d = await f.raise();
|
|
f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
|
await until(() => f.gitea.calls().includes(`DELETE /api/v1/users/${USER}/tokens/${oldId}`));
|
|
const revoke = f.call('human', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'stop' });
|
|
release();
|
|
await revoke;
|
|
await until(() => f.ended(d.id));
|
|
await f.broker.giteaIdle();
|
|
assert.equal(f.ended(d.id).code, 'revoked');
|
|
assert.equal(f.grantEvents().some((e) => e.action === 'grant.applied' && e.grantDecision === d.id), false);
|
|
assert.equal(f.record(), undefined);
|
|
assert.deepEqual(f.userTokens(), []);
|
|
});
|
|
|
|
test('X-G11: a revoke while a sweep settles a rotation, forward or back, ends it revoked with no record', async (t) => {
|
|
for (const forward of [true, false]) {
|
|
const f = await setup(t);
|
|
const { live, next, id } = await openRotation(f);
|
|
if (forward) writeFileSync(f.tokenFile, next.sha1 + '\n');
|
|
// Forward deletes the old token, back the new one: hold that delete.
|
|
const target = forward ? live.token_id : next.id;
|
|
let release;
|
|
const held = new Promise((r) => (release = r));
|
|
f.gitea.rule((e) => e.method === 'DELETE' && e.path === `/api/v1/users/${USER}/tokens/${target}`, async () => {
|
|
await held;
|
|
return { pass: true };
|
|
});
|
|
const sweep = f.call('human', 'grants.sweep', { service: 'gitea' });
|
|
await until(() => f.gitea.calls().includes(`DELETE /api/v1/users/${USER}/tokens/${target}`));
|
|
const revoke = f.call('human', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'stop' });
|
|
release();
|
|
await revoke;
|
|
const swept = await sweep;
|
|
await f.broker.giteaIdle();
|
|
assert.deepEqual(swept.settled, [{ decision: id, outcome: 'revoked' }], forward ? 'forward' : 'back');
|
|
assert.equal(f.ended(id).code, 'revoked');
|
|
assert.equal(f.grantEvents().some((e) => e.grantDecision === id && e.settled), false);
|
|
assert.equal(f.record(), undefined);
|
|
assert.deepEqual(f.userTokens(), []);
|
|
}
|
|
});
|
|
|
|
// A sweep settles the open rotation while the seat whoami is held with `file` in the store file; a revoke
|
|
// lands during the hold. Checks the rotation ended revoked with no record; returns the fixture and its decision.
|
|
async function revokeDuringWhoami(t, file) {
|
|
const f = await setup(t);
|
|
const { id } = await openRotation(f);
|
|
writeFileSync(f.tokenFile, file(f) + '\n');
|
|
let release;
|
|
const held = new Promise((r) => (release = r));
|
|
const seat = (e) => e.method === 'GET' && e.path === '/api/v1/token' && e.as !== 'mosaic-grant';
|
|
f.gitea.rule(seat, async () => {
|
|
await held;
|
|
return { pass: true };
|
|
});
|
|
const sweep = f.call('human', 'grants.sweep', { service: 'gitea' });
|
|
await until(() => f.gitea.requests.some(seat));
|
|
const revoke = f.call('human', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'stop' });
|
|
release();
|
|
await revoke;
|
|
const swept = await sweep;
|
|
await f.broker.giteaIdle();
|
|
assert.deepEqual(swept.settled, [{ decision: id, outcome: 'revoked' }]);
|
|
assert.equal(f.ended(id).code, 'revoked');
|
|
assert.equal(f.record(), undefined);
|
|
return { f, id };
|
|
}
|
|
|
|
test('X-G11: a revoke during a settle whoami that finds a foreign token ends it revoked, not held', async (t) => {
|
|
const { f, id } = await revokeDuringWhoami(t, (g) => g.gitea.addToken(USER, 'foreign', SEAT_SCOPES).sha1);
|
|
assert.equal(f.grantEvents().some((e) => e.grantDecision === id && e.code === 'grant-token-unknown'), false);
|
|
assert.deepEqual([...tokenUnknown(f.store, 'demo').keys()], []);
|
|
});
|
|
|
|
test('X-G11: a revoke during a settle whoami that gets a 401 ends it revoked, not lost', async (t) => {
|
|
const { f, id } = await revokeDuringWhoami(t, () => 'f'.repeat(40));
|
|
assert.equal(f.grantEvents().some((e) => e.grantDecision === id && e.code === 'grant-lost'), false);
|
|
});
|
|
|
|
test('X-G12: a token that reaches wider than its forks fails with the repository named', async (t) => {
|
|
const collab = await setup(t);
|
|
collab.gitea.addRepo('other', 'secret', { collaborators: [USER] });
|
|
let r = await collab.approve();
|
|
assert.equal(r.end.code, 'gitea-reach-wider');
|
|
assert.equal(r.end.repository, 'other/secret');
|
|
assert.match(collab.messages(r.id)[0].body, /It reaches other\/secret\./);
|
|
assert.deepEqual(collab.userTokens(), []);
|
|
assert.equal(existsSync(collab.tokenFile), false);
|
|
|
|
// A fork of a project that left publish.
|
|
const f = await setup(t);
|
|
assert.equal((await f.approve()).end.action, 'grant.applied');
|
|
f.gitea.addRepo(USER, 'old', { parent: 'acme/old' });
|
|
r = await f.approve();
|
|
assert.equal(r.end.code, 'gitea-reach-wider');
|
|
assert.equal(r.end.repository, `${USER}/old`);
|
|
assert.equal(f.state(), 'valid');
|
|
|
|
// An old fork of a publish project outside target.projects passes.
|
|
const ok = await setup(t);
|
|
assert.equal((await ok.approve()).end.action, 'grant.applied');
|
|
ok.gitea.addRepo(USER, 'lib', { parent: 'acme/lib' });
|
|
r = await ok.approve();
|
|
assert.equal(r.end.action, 'grant.applied');
|
|
assert.deepEqual(ok.userTokens().map((x) => x.id), [r.end.tokenId]);
|
|
});
|
|
|
|
test('X-G12: reach is paged by X-Total-Count and a count that does not add up refuses', async (t) => {
|
|
const f = await setup(t);
|
|
assert.equal((await f.approve()).end.action, 'grant.applied');
|
|
for (let i = 0; i < 51; i++) f.gitea.addRepo(USER, `app${i}`, { parent: 'acme/app' });
|
|
const before = f.gitea.requests.length;
|
|
let r = await f.approve();
|
|
assert.equal(r.end.action, 'grant.applied');
|
|
const pages = f.gitea.requests.slice(before).filter((q) => q.path.startsWith('/api/v1/user/repos') && q.as === USER).map((q) => q.path);
|
|
assert.deepEqual(pages, ['/api/v1/user/repos?limit=50&page=1', '/api/v1/user/repos?limit=50&page=2']);
|
|
const off = f.gitea.rule((e) => e.path.startsWith('/api/v1/user/repos') && e.as === USER, { status: 200, json: [], total: 5 });
|
|
r = await f.approve();
|
|
off();
|
|
assert.equal(r.end.code, 'gitea-reach-unknown');
|
|
const noTotal = f.gitea.rule((e) => e.path.startsWith('/api/v1/user/repos') && e.as === USER, { status: 200, json: [] });
|
|
r = await f.approve();
|
|
noTotal();
|
|
assert.equal(r.end.code, 'gitea-reach-unknown');
|
|
});
|
|
|
|
test('X-G13: an agent cannot record a grant event, and the trigger refuses one from any writer but the grant writer', async (t) => {
|
|
const f = await setup(t);
|
|
for (const body of [
|
|
{ action: 'grant.applied', method: 'gitea', agent: 'coder' },
|
|
{ action: 'grant.revoked', method: 'gitea', agent: 'coder' },
|
|
{ operation: 'grant.gitea', decision: randomUUID() },
|
|
])
|
|
assert.throws(() => f.broker.recordEvent(f.caps.chief, { kind: 'action.allowed', body }), (e) => (assert.equal(e.code, 'reserved-event'), true));
|
|
assert.throws(() =>
|
|
f.store.run(
|
|
"INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'action.allowed',NULL,NULL,'coder',?)",
|
|
randomUUID(), AT, 'demo', JSON.stringify({ action: 'grant.step', method: 'gitea' }),
|
|
),
|
|
/only the grant writer/);
|
|
// The grant writer takes grant.* bodies only.
|
|
assert.throws(() => writeGrantEvent(f.store, AT, 'demo', { action: 'host.sudo', host: 'web1' }, 'web1'), /grant event shape/);
|
|
});
|
|
|
|
test('X-G14: credentials delete fires the user: tokens go, login is prohibited, and grant.revoke.closed records it', async (t) => {
|
|
const f = await setup(t);
|
|
await f.approve();
|
|
f.gitea.addToken(USER, 'extra', SEAT_SCOPES);
|
|
const out = f.call('human', 'credentials.delete', { agent: 'coder', reason: 'fired' });
|
|
assert.match(out.giteaRevoke, /^[0-9a-f-]{36}$/);
|
|
await until(() => f.grantEvents().some((e) => e.action === 'grant.revoke.closed'));
|
|
await f.broker.giteaIdle();
|
|
const closed = f.grantEvents().find((e) => e.action === 'grant.revoke.closed');
|
|
assert.deepEqual(closed.serviceLeft, []);
|
|
assert.equal(closed.revoke, out.giteaRevoke);
|
|
assert.deepEqual(f.userTokens(), []);
|
|
assert.equal(f.gitea.users.get(USER).prohibit_login, true);
|
|
assert.equal(f.record(), undefined);
|
|
const revoked = f.grantEvents().find((e) => e.action === 'grant.revoked');
|
|
assert.equal(revoked.fire, true);
|
|
assert.equal(revoked.by, 'jason');
|
|
// A plain revoke through the credentials verb takes the grant but leaves the user.
|
|
const g = await setup(t);
|
|
await g.approve();
|
|
g.call('human', 'credentials.revoke', { agent: 'coder', service: 'gitea', reason: 'r' });
|
|
await until(() => g.grantEvents().some((e) => e.action === 'grant.revoke.closed'));
|
|
await g.broker.giteaIdle();
|
|
assert.equal(g.gitea.users.get(USER).prohibit_login, false);
|
|
assert.deepEqual(g.userTokens(), []);
|
|
f.noLeak();
|
|
});
|
|
|
|
test('X-G14: a fire whose token delete or login PATCH fails lands it in serviceLeft', async (t) => {
|
|
const f = await setup(t);
|
|
await f.approve();
|
|
const extra = f.gitea.addToken(USER, 'extra', SEAT_SCOPES);
|
|
const offDelete = f.gitea.rule((e) => e.method === 'DELETE' && e.path.endsWith(`/tokens/${extra.id}`), { status: 500, json: {} });
|
|
const offPatch = f.gitea.rule((e) => e.method === 'PATCH', { status: 500, json: {} });
|
|
f.call('human', 'credentials.delete', { agent: 'coder', reason: 'fired' });
|
|
await until(() => f.grantEvents().some((e) => e.action === 'grant.revoke.closed'));
|
|
await f.broker.giteaIdle();
|
|
offDelete();
|
|
offPatch();
|
|
const closed = f.grantEvents().find((e) => e.action === 'grant.revoke.closed');
|
|
assert.deepEqual(closed.serviceLeft, ['tokens', 'login']);
|
|
assert.equal(f.gitea.users.get(USER).prohibit_login, false);
|
|
assert.deepEqual(f.userTokens().map((x) => x.id), [extra.id]);
|
|
});
|
|
|
|
test('X-G14: a fire whose user is gone or remade skips the fire steps and says why', async (t) => {
|
|
const f = await setup(t);
|
|
await f.approve();
|
|
f.gitea.removeUser(USER);
|
|
f.call('human', 'credentials.delete', { agent: 'coder', reason: 'fired' });
|
|
await until(() => f.grantEvents().some((e) => e.action === 'grant.revoke.closed'));
|
|
await f.broker.giteaIdle();
|
|
assert.equal(f.grantEvents().find((e) => e.action === 'grant.revoke.closed').fireSkipped, 'gitea-user-gone');
|
|
const g = await setup(t);
|
|
await g.approve();
|
|
g.gitea.removeUser(USER);
|
|
g.gitea.addUser(USER);
|
|
g.call('human', 'credentials.delete', { agent: 'coder', reason: 'fired' });
|
|
await until(() => g.grantEvents().some((e) => e.action === 'grant.revoke.closed'));
|
|
await g.broker.giteaIdle();
|
|
assert.equal(g.grantEvents().find((e) => e.action === 'grant.revoke.closed').fireSkipped, 'gitea-user-foreign');
|
|
assert.equal(g.gitea.users.get(USER).prohibit_login, false);
|
|
});
|
|
|
|
test('X-G14: a sweep retries a fire only for the user it fired: a remade or gone user is skipped and named', async (t) => {
|
|
for (const reason of ['gitea-user-foreign', 'gitea-user-gone']) {
|
|
const f = await setup(t);
|
|
await f.approve();
|
|
// The fire's user check answers 500, so both fire steps are left for the sweep.
|
|
const off = f.gitea.rule((e) => e.method === 'GET' && e.path === `/api/v1/users/${USER}`, { status: 500, json: {} });
|
|
f.call('human', 'credentials.delete', { agent: 'coder', reason: 'fired' });
|
|
await until(() => f.grantEvents().some((e) => e.action === 'grant.revoke.closed'));
|
|
await f.broker.giteaIdle();
|
|
off();
|
|
assert.deepEqual(f.grantEvents().find((e) => e.action === 'grant.revoke.closed').serviceLeft, ['tokens', 'login']);
|
|
f.gitea.removeUser(USER);
|
|
if (reason === 'gitea-user-foreign') f.gitea.addUser(USER, { scopes: SEAT_SCOPES, tokenName: 'theirs' });
|
|
const before = f.gitea.requests.length;
|
|
const swept = await f.call('human', 'grants.sweep', { service: 'gitea' });
|
|
const after = f.gitea.requests.slice(before).map((r) => `${r.method} ${r.path.split('?')[0]}`);
|
|
assert.equal(after.some((c) => c.startsWith('PATCH ') || c.startsWith('DELETE ') || c.endsWith('/tokens')), false, reason);
|
|
assert.deepEqual(swept.left, []);
|
|
const skipped = [{ agent: 'coder', user: USER, id: 'tokens', reason }, { agent: 'coder', user: USER, id: 'login', reason }];
|
|
assert.deepEqual(swept.skipped, skipped);
|
|
assert.deepEqual(f.grantEvents().findLast((e) => e.action === 'grant.swept').skipped, skipped);
|
|
if (reason === 'gitea-user-foreign') {
|
|
assert.equal(f.gitea.users.get(USER).prohibit_login, false);
|
|
assert.deepEqual(f.userTokens().map((x) => x.name), ['theirs']);
|
|
}
|
|
// Skipped is not left: the next sweep has nothing to retry.
|
|
assert.deepEqual((await f.call('human', 'grants.status', { service: 'gitea' })).serviceLeft, []);
|
|
}
|
|
});
|
|
|
|
test('X-G14: a revoke Gitea cannot reach stays open, and a restart closes it', async (t) => {
|
|
const f = await setup(t);
|
|
await f.approve();
|
|
const tokenId = f.record().token_id;
|
|
const off = f.gitea.rule(() => true, { status: 500, json: {} });
|
|
const out = await f.call('tester', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'r' });
|
|
assert.deepEqual(out.serviceLeft, [tokenId]);
|
|
off();
|
|
const status = await f.call('human', 'grants.status', { service: 'gitea' });
|
|
assert.deepEqual(status.serviceLeft, [{ agent: 'coder', user: USER, id: tokenId }]);
|
|
await f.call('human', 'grants.sweep', { service: 'gitea' });
|
|
assert.deepEqual(f.userTokens(), []);
|
|
await rejects(f.call('pm', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'r' }), 'revoke-refused');
|
|
});
|
|
|
|
// Row 281 (#1786, d6 section 8, T8): grants.revoke shares its Gitea steps with a fire's step G now. This
|
|
// pins the tester's dev-only refusal, the verb's records and their order, and that it starts no job.
|
|
test('row 281 T8: a tester grants.revoke refuses tester-dev-only once bus.tester is off; the human revoke keeps its records and starts no job', async (t) => {
|
|
const f = await setup(t);
|
|
await f.approve();
|
|
f.broker.reloadBusiness('demo', { ...f.business, vars: { 'bus.tester': 'yes' } });
|
|
const before = f.grantEvents().length;
|
|
await rejects(f.call('tester', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'r' }), 'tester-dev-only');
|
|
assert.equal(f.grantEvents().length, before);
|
|
assert.ok(f.record());
|
|
await f.call('human', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'r' });
|
|
const after = f.grantEvents().slice(before)
|
|
.filter((e) => e.action !== 'grant.call' && e.action !== 'grant.result')
|
|
.map((e) => [e.action, e.fire ?? false]);
|
|
assert.deepEqual(after, [['grant.revoked', false], ['grant.revoke.closed', false]]);
|
|
assert.deepEqual(f.userTokens(), []);
|
|
const jobs = f.store.all("SELECT body FROM events WHERE business='demo' AND kind='action.allowed'").map((e) => JSON.parse(e.body).action)
|
|
.filter((a) => /^(fire|grants)\./.test(a));
|
|
assert.deepEqual(jobs, []);
|
|
});
|
|
|
|
test('X-G15: look-alike commit steps outside the grant writer are ignored at start, by revoke and by status', async (t) => {
|
|
const f = await setup(t);
|
|
const first = await f.approve();
|
|
const live = f.record();
|
|
const fake = { action: 'grant.step', method: 'gitea', grantDecision: first.id, agent: 'coder', user: USER, step: 'commit', previousTokenId: 999, tokenId: 998, tokenName: 'x', previous: null };
|
|
f.store.run(
|
|
"INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'action.refused',NULL,'grant','coder',?)",
|
|
randomUUID(), AT, 'demo', JSON.stringify(fake),
|
|
);
|
|
f.store.run(
|
|
"INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'human.input',NULL,NULL,'coder',?)",
|
|
randomUUID(), AT, 'demo', JSON.stringify({ ...fake, action: 'grant.failed', code: 'grant-token-unknown' }),
|
|
);
|
|
assert.deepEqual(openGiteaGrants(f.store, 'demo'), []);
|
|
assert.deepEqual([...tokenUnknown(f.store, 'demo').keys()], []);
|
|
const r = await restart(t, f);
|
|
assert.deepEqual(r.held, []);
|
|
assert.deepEqual(r.requests, []);
|
|
assert.equal(r.state, 'valid');
|
|
const out = await r.broker.request(r.human, { verb: 'grants.revoke', args: { service: 'gitea', agent: 'coder', reason: 'r' } });
|
|
assert.deepEqual(out.serviceLeft, []);
|
|
assert.deepEqual(f.grantEvents().find((e) => e.action === 'grant.revoked').tokens, [live.token_id]);
|
|
});
|
|
|
|
test('X-G15: a look-alike spend under another kind with the grant writer pair is not an open grant', async (t) => {
|
|
const f = await setup(t);
|
|
const id = f.fixtureGrant({}, null);
|
|
f.store.run(
|
|
"INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'action.refused',NULL,'grant',?,?)",
|
|
randomUUID(), AT, 'demo', id, JSON.stringify({ operation: 'grant.gitea', decision: id }),
|
|
);
|
|
assert.deepEqual(openGiteaGrants(f.store, 'demo'), []);
|
|
// The same body as the grant writer's spend is open.
|
|
writeGrantEvent(f.store, AT, 'demo', { operation: 'grant.gitea', decision: id }, id);
|
|
assert.deepEqual(openGiteaGrants(f.store, 'demo').map((o) => o.decision), [id]);
|
|
});
|
|
|
|
test('status lists the slot, users, grants and nothing secret, and every grants verb needs service gitea', async (t) => {
|
|
const f = await setup(t);
|
|
await f.approve();
|
|
const s = await f.call('chief', 'grants.status', { service: 'gitea' });
|
|
assert.deepEqual(s.admin, { ...f.meta, rotationDue: false });
|
|
assert.equal(s.adminTokenLeft, null);
|
|
assert.deepEqual(s.users.map((u) => u.agent), ['coder']);
|
|
assert.deepEqual(s.grants.map((g) => [g.agent, g.projects]), [['coder', ['app']]]);
|
|
for (const k of ['open', 'running', 'settlePending', 'tokenUnknown', 'serviceLeft', 'revoking']) assert.deepEqual(s[k], [], k);
|
|
await rejects(f.call('human', 'grants.status', { service: 'host' }), 'invalid-request');
|
|
await rejects(f.call('human', 'grants.sweep', {}), 'invalid-request');
|
|
const bare = new Broker({ store: f.store, businesses: { demo: f.business } });
|
|
const human = bare.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
await rejects(bare.request(human, { verb: 'grants.status', args: { service: 'gitea' } }), 'gitea-not-configured');
|
|
f.noLeak();
|
|
});
|
|
|
|
// C2-21: these catch library forking, wrong collaborator/token level, widened reach, and stale bindings.
|
|
function libraryBusiness(b, { agent = 'coder', definition = 'research', publish = ['app'], library = ['app'] } = {}) {
|
|
const original = b.roles.coder;
|
|
b.roles[agent] = { ...original, definition, publish,
|
|
identity: { ...original.identity, gitea: { ...original.identity.gitea, user: agent === 'chief' ? 'demo-chief' : USER } } };
|
|
b.definitions[definition] = { roleVersion: 2, credentials: [{ service: 'gitea',
|
|
scopes: definition === 'chief-of-staff' ? ['read:repository', 'read:user'] : ['read:user', 'write:repository'] }] };
|
|
for (const project of library) b.projects[project].kind = 'library';
|
|
}
|
|
|
|
for (const [definition, agent, level, scopes] of [
|
|
['research', 'coder', 'write', ['read:user', 'write:repository']],
|
|
['chief-of-staff', 'chief', 'read', ['read:repository', 'read:user']],
|
|
['coder', 'coder', 'read', ['read:user', 'write:repository']],
|
|
]) {
|
|
test(`C2-21 L1/L2/L9: ${definition} gets ${level} on her repository with declared scopes through raise and yes`, async (t) => {
|
|
const f = await setup(t, { business: (b) => {
|
|
libraryBusiness(b, { agent, definition });
|
|
b.launch = { by: 'chief', instances: ['coder', 'pm'] };
|
|
} });
|
|
const user = agent === 'chief' ? 'demo-chief' : USER;
|
|
const puts = [];
|
|
f.gitea.rule((e) => e.method === 'PUT' && e.path.includes('/collaborators/'), (e, body) => {
|
|
puts.push({ path: e.path, as: e.as, auth: e.auth, body });
|
|
return { pass: true };
|
|
});
|
|
const d = await f.raise({ agent });
|
|
const block = f.store.get('SELECT question FROM decisions WHERE id=?', d.id).question;
|
|
assert.ok(block.includes(`Repository: ${f.gitea.url}/acme/app, level ${level}.`));
|
|
assert.doesNotMatch(block, /\nForks:/);
|
|
assert.ok(block.includes(`Scopes: ${scopes.join(', ')}.`));
|
|
assert.ok(block.includes(level === 'write'
|
|
? "The token can write every path of this repository. Only the bus's path check limits it to supporting folders."
|
|
: "The token can read every path of this repository. It can't push to it, because the user is a read collaborator."));
|
|
f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
|
await until(() => f.ended(d.id));
|
|
await f.broker.giteaIdle();
|
|
const end = f.ended(d.id);
|
|
assert.equal(end.action, 'grant.applied', JSON.stringify(end));
|
|
assert.deepEqual(end.forks, []);
|
|
assert.deepEqual(end.repositories, [{ project: 'app', repository: `${f.gitea.url}/acme/app`, level }]);
|
|
assert.deepEqual(puts, [{ path: `/api/v1/repos/acme/app/collaborators/${user}`, as: 'mosaic-grant', auth: 'token', body: { permission: level } }]);
|
|
assert.equal(f.gitea.calls().filter((c) => c.endsWith('/forks')).length, 0);
|
|
assert.deepEqual(f.gitea.tokens(user)[0].scopes, scopes);
|
|
const messages = f.messages(d.id).map((m) => m.body).join('\n');
|
|
assert.ok(messages.includes(`repositories: ${f.gitea.url}/acme/app (${level})`));
|
|
assert.doesNotMatch(messages, /forks: \./);
|
|
const results = f.grantEvents();
|
|
assert.ok(results.some((e) => e.action === 'grant.call' && e.op === 'collaborator-add'));
|
|
f.noLeak();
|
|
// L5: token revocation leaves the collaborator, just as legacy forks remain.
|
|
await f.call('human', 'grants.revoke', { service: 'gitea', agent, reason: 'C2-21' });
|
|
await f.broker.giteaIdle();
|
|
assert.deepEqual(f.gitea.tokens(user), []);
|
|
assert.ok(f.gitea.repos.find((r) => r.owner === 'acme' && r.name === 'app').collaborators.includes(user));
|
|
});
|
|
}
|
|
|
|
for (const [agent, definition] of [['coder', 'research'], ['chief', 'chief-of-staff']]) {
|
|
for (const projects of [['lib'], ['app', 'lib']]) {
|
|
test(`C2-21 L7/L7b: ${definition} refuses non-library targets ${projects.join(',')}`, async (t) => {
|
|
const f = await setup(t, { business: (b) => libraryBusiness(b, { agent, definition, publish: ['app', 'lib'] }) });
|
|
await rejects(f.raise({ agent, target: { projects } }), 'gitea-not-declared');
|
|
assert.equal(f.store.get('SELECT count(*) AS n FROM decisions').n, 0);
|
|
assert.equal(f.gitea.requests.length, 0);
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const [name, permissions, fork] of [
|
|
['read widened to write', { admin: false, push: true, pull: true }, false],
|
|
['admin privilege', { admin: true, push: false, pull: true }, false],
|
|
['missing pull', { admin: false, push: false, pull: false }, false],
|
|
['missing permission object', null, false],
|
|
['library fork', { admin: false, push: false, pull: true }, true],
|
|
]) {
|
|
test(`C2-21 L4: ${name} refuses gitea-reach-wider and deletes the minted token`, async (t) => {
|
|
const f = await setup(t, { business: (b) => libraryBusiness(b, { agent: 'chief', definition: 'chief-of-staff' }) });
|
|
const repository = fork ? 'demo-chief/app' : 'acme/app';
|
|
f.gitea.rule((e) => e.as === 'demo-chief' && e.path.startsWith('/api/v1/user/repos?'), { status: 200, total: 1, json: [{
|
|
full_name: repository, owner: { login: fork ? 'demo-chief' : 'acme' },
|
|
parent: fork ? { full_name: 'acme/app' } : null, permissions,
|
|
}] });
|
|
const { end } = await f.approve({ agent: 'chief' });
|
|
assert.equal(end.code, 'gitea-reach-wider', JSON.stringify(end));
|
|
assert.equal(end.repository, repository);
|
|
assert.deepEqual(f.gitea.tokens('demo-chief'), []);
|
|
assert.equal(f.store.get("SELECT 1 FROM gitea_grants WHERE agent='chief'"), undefined);
|
|
f.noLeak();
|
|
});
|
|
}
|
|
|
|
test('C2-21 L8: untargeted library collaborator reach is disclosed and accepted publish-wide', async (t) => {
|
|
const f = await setup(t, { business: (b) => libraryBusiness(b, { publish: ['app', 'lib'], library: ['app', 'lib'] }) });
|
|
f.gitea.repos.find((r) => r.owner === 'acme' && r.name === 'lib').collaborators.push(USER);
|
|
const d = await f.raise();
|
|
const block = f.store.get('SELECT question FROM decisions WHERE id=?', d.id).question;
|
|
assert.ok(block.includes(`It may also reach these repositories as a collaborator: ${f.gitea.url}/acme/lib (write)`));
|
|
assert.ok(block.includes('Step 5 checks that this is only its forks and the repositories above at their level.'));
|
|
assert.doesNotMatch(block, /existing forks of:/);
|
|
f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
|
await until(() => f.ended(d.id));
|
|
await f.broker.giteaIdle();
|
|
assert.equal(f.ended(d.id).action, 'grant.applied');
|
|
});
|
|
|
|
test('C2-21 L10: project kind or library level changing after raise refuses decision-mismatch', async (t) => {
|
|
for (const change of ['kind', 'level']) {
|
|
const f = await setup(t, { business: (b) => {
|
|
b.definitions.coder.credentials = [{ service: 'gitea', scopes: ['read:user', 'write:repository'] }];
|
|
if (change === 'level') libraryBusiness(b);
|
|
} });
|
|
const d = await f.raise();
|
|
const next = structuredClone(f.business);
|
|
if (change === 'kind') next.projects.app.kind = 'library';
|
|
else {
|
|
next.definitions['chief-of-staff'] = structuredClone(next.definitions.research);
|
|
next.roles.coder.definition = 'chief-of-staff';
|
|
}
|
|
f.broker.reloadBusiness('demo', next);
|
|
f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
|
await until(() => f.ended(d.id));
|
|
await f.broker.giteaIdle();
|
|
assert.equal(f.ended(d.id).code, 'decision-mismatch');
|
|
assert.deepEqual(f.gitea.calls(), ['GET /api/v1/version', 'GET /api/v1/user', 'GET /api/v1/token', 'GET /api/v1/user/repos']);
|
|
assert.ok(f.gitea.requests.every((r) => r.method === 'GET' && !/\/admin\/users|\/tokens|\/collaborators/.test(r.path)));
|
|
assert.equal(existsSync(f.tokenFile), false);
|
|
assert.equal(f.record(), undefined);
|
|
}
|
|
});
|
|
|
|
test('C2-21 L4: creative write requires push true but ignores unrelated permission keys', async (t) => {
|
|
for (const push of [false, true]) {
|
|
const f = await setup(t, { business: (b) => libraryBusiness(b) });
|
|
f.gitea.rule((e) => e.as === USER && e.path.startsWith('/api/v1/user/repos?'), { status: 200, total: 1, json: [{
|
|
full_name: 'acme/app', owner: { login: 'acme' }, parent: null,
|
|
permissions: { admin: false, push, pull: true, issues: true },
|
|
}] });
|
|
const { end } = await f.approve();
|
|
assert.equal(end.action, push ? 'grant.applied' : 'grant.failed');
|
|
if (!push) assert.equal(end.code, 'gitea-reach-wider');
|
|
}
|
|
});
|
|
|
|
// mos-1590: the L10 fixture must not hide the no-declared-scopes refusal.
|
|
test('C2-21: a library grant with no declared scopes refuses gitea-not-declared at raise', async (t) => {
|
|
const f = await setup(t, { business: (b) => {
|
|
libraryBusiness(b);
|
|
delete b.definitions.research.credentials[0].scopes;
|
|
} });
|
|
await rejects(f.raise(), 'gitea-not-declared');
|
|
assert.equal(f.store.get('SELECT count(*) AS n FROM decisions').n, 0);
|
|
assert.equal(f.gitea.requests.length, 0);
|
|
});
|