Files
stack/packages/bus/tests/lifecycle-revoke.test.mjs

223 lines
13 KiB
JavaScript

import { fixtureConsole, approveFixtureFire } from './hire-fixtures.mjs';
// Row 194 (#1692, brief item 8, row 199 D13, T20): fire and delete revoke the agent's credentials
// after the writer's checks and before the write. Fire revokes each service and keeps the store
// directory. Delete calls remove (sage-1593). A failed revoke stops the verb with
// credential-revoke-failed and the business file's bytes unchanged. Fake tokens in a scratch root.
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { busOrganization } from '../src/organization-context.mjs';
import { startBroker } from '../src/runtime.mjs';
import { Store } from '../src/store.mjs';
import { hostGrantRecord, hostRecord } from './host-fixtures.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { businessDoc, FAR_DATE, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
const TOKEN = 'fake_issued_token_0123456789';
const store = (agent, service) => ({ store: true, [service === 'gitea' ? 'rotateBy' : 'expires']: FAR_DATE });
// coder: gitea in the store, vikunja a file reference. rev-2 and rev-3: both in the store, with a
// tracker bot. `drop` names instances whose credentials the boot copy leaves out, as a restart that
// dropped the entry would: the running bus then has no entry for a file on disk (sage-1592).
async function setup(t, { drop = [], seed = null } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-lifecycle-revoke-'));
const rolesDir = rolesCopy(root);
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } };
doc.roles.coder.credentials.gitea = store('coder', 'gitea');
for (const [name, botId] of [['rev-2', 40], ['rev-3', 41]]) {
doc.roles[name] = { definition: 'reviewer', tracker: { bot: `bot-demo-${name}`, botId }, credentials: { gitea: store(name, 'gitea'), vikunja: store(name, 'vikunja') } };
}
doc.launch.instances.push('rev-2', 'rev-3');
const file = writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
// Row 281 T1: records written before the start, as an earlier bus left them.
if (seed) {
const seeded = new Store(join(root, 'data'));
seed(seeded);
seeded.close();
}
const b = loadBusiness('demo', { dir: root, rolesDir });
const resolved = {};
for (const i of Object.keys(b.roles)) resolved[i] = resolveInstance({ system: systemFor(root), business: b, instance: i });
const boot = busOrganization(b, resolved);
for (const name of drop) delete boot.roles[name].credentials;
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: boot }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => {
await bus.close();
chmodSync(join(root, 'secrets', 'agents'), 0o700);
rmSync(root, { recursive: true, force: true });
});
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const chief = bus.broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
bus.broker.request(chief, { verb: 'role.claim' });
const ask = (verb, args) => bus.broker.request(human, { verb, args });
const hireConsole=fixtureConsole(bus.broker);
const fire = name => approveFixtureFire(bus.broker,hireConsole,name);
const agentDir = (agent) => join(root, 'secrets', 'agents', 'demo', agent);
const plant = (agent, service) => {
mkdirSync(agentDir(agent), { recursive: true, mode: 0o700 });
writeFileSync(join(agentDir(agent), service), TOKEN + '\n', { mode: 0o600 });
};
// A delete runs as the launcher's pass would: the verb's request, the take, then lifecycleDelete.
// A refused delete closes the request with its code, as the launcher does.
const remove = (instance) => {
const { request } = ask('instances.set', { revision: ask('instances').revision, instance, op: 'delete' });
assert.equal(bus.lifecycleTake({ business: 'demo', request }).go, true);
try {
return { request, ...bus.lifecycleDelete({ business: 'demo', request }) };
} catch (e) {
bus.lifecycleDone({ business: 'demo', request, refusal: e.code });
throw e;
}
};
const bytes = () => readFileSync(file, 'utf8');
const code = (fn) => { try { fn(); } catch (e) { return e.code; } return null; };
const status = (agent) => Object.fromEntries(bus.credentialStatus('demo', agent).map((e) => [e.service, e.state]));
return { root, bus, ask, fire, remove, agentDir, plant, bytes, code, status, file };
}
test('T20 fire: a revoke that fails stops the fire with the file unchanged; one that succeeds revokes, keeps the directory and names the services outside the store', async (t) => {
const f = await setup(t);
f.ask('credentials.issue', { agent: 'coder', service: 'gitea', token: TOKEN, date: '2099-01-01' });
assert.ok(existsSync(join(f.agentDir('coder'), 'gitea')));
// The revoke can't unlink the token, so it fails before the write: the file still lists coder.
const before = f.bytes();
chmodSync(f.agentDir('coder'), 0o500);
await assert.rejects(f.fire('coder'),{code:'credential-revoke-failed'});
chmodSync(f.agentDir('coder'), 0o700);
assert.equal(f.bytes(), before);
assert.ok(JSON.parse(before).launch.instances.includes('coder'));
const fired = await f.fire('coder');
assert.equal(fired.delisted, true);
assert.deepEqual(fired.credentials, { revoked: ['gitea', 'vikunja'], outsideStore: ['vikunja'] });
assert.ok(!JSON.parse(f.bytes()).launch.instances.includes('coder'));
assert.deepEqual(f.status('coder'), { gitea: 'revoked', vikunja: 'revoked' });
// The directory stays, empty, so the status reads revoked and the human removes the remote token.
assert.deepEqual(readdirSync(f.agentDir('coder')), []);
});
// webby-0094 N1: a fire of an instance already delisted (here a hand edit) still revokes its tokens,
// with no write, and the instance is no longer firing.
test('T20 fire: an instance already delisted still has its tokens revoked and the file is left alone', async (t) => {
const f = await setup(t);
f.ask('credentials.issue', { agent: 'rev-2', service: 'gitea', token: TOKEN, date: '2099-01-01' });
assert.ok(existsSync(join(f.agentDir('rev-2'), 'gitea')));
const doc = JSON.parse(f.bytes());
doc.launch.instances = doc.launch.instances.filter((name) => name !== 'rev-2');
writeFileSync(f.file, JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 });
const before = f.bytes();
const fired = await f.fire('rev-2');
assert.equal(fired.delisted, false);
assert.equal(fired.transaction, null);
assert.deepEqual(fired.credentials, { revoked: ['gitea', 'vikunja'], outsideStore: [] });
assert.equal(f.bytes(), before);
assert.deepEqual(readdirSync(f.agentDir('rev-2')), []);
assert.deepEqual(f.status('rev-2'), { gitea: 'revoked', vikunja: 'revoked' });
});
test('T20 fire: with no entry in the running bus, a token on disk refuses and an empty store goes on', async (t) => {
const f = await setup(t, { drop: ['rev-2'] });
f.plant('rev-2', 'gitea');
const before = f.bytes();
await assert.rejects(f.fire('rev-2'),{code:'credential-revoke-failed'});
assert.equal(f.bytes(), before);
rmSync(join(f.agentDir('rev-2'), 'gitea'));
const fired = await f.fire('rev-2');
assert.equal(fired.delisted, true);
assert.deepEqual(fired.credentials, { revoked: ['gitea', 'vikunja'], outsideStore: [] });
});
test('T20 delete: a revoke that fails keeps the file; one that succeeds clears the store, deletes, and names the tracker bot', async (t) => {
const f = await setup(t);
f.ask('credentials.issue', { agent: 'rev-2', service: 'vikunja', token: TOKEN, date: '2099-01-01' });
// remove refuses a name it doesn't own (credential-file), so the delete stops with the file unchanged.
writeFileSync(join(f.agentDir('rev-2'), 'notes'), 'x');
const before = f.bytes();
assert.equal(f.code(() => f.remove('rev-2')), 'credential-revoke-failed');
assert.equal(f.bytes(), before);
// remove revokes the services it knows before it reads the directory (row 199), so the token is
// already gone: a failed delete leaves the agent listed with nothing it can use.
assert.equal(existsSync(join(f.agentDir('rev-2'), 'vikunja')), false);
assert.deepEqual(f.status('rev-2'), { gitea: 'revoked', vikunja: 'revoked' });
rmSync(join(f.agentDir('rev-2'), 'notes'));
const out = f.remove('rev-2');
assert.equal(out.bot, 'bot-demo-rev-2');
assert.equal(existsSync(f.agentDir('rev-2')), false);
assert.deepEqual(f.status('rev-2'), { gitea: 'revoked', vikunja: 'revoked' });
const after = JSON.parse(f.bytes());
assert.equal(Object.hasOwn(after.roles, 'rev-2'), false);
assert.ok(after.retired.includes('rev-2'));
// The launcher's close carries the bot, so the CLI and the Console name it.
const done = f.bus.lifecycleDone({ business: 'demo', request: out.request, transaction: out.transaction, bot: out.bot });
assert.equal(done.done.bot, 'bot-demo-rev-2');
});
test('T20 delete: with no entry in the running bus, a token file is cleared and the delete goes on; a file reference refuses', async (t) => {
const f = await setup(t, { drop: ['rev-3'] });
f.plant('rev-3', 'gitea');
const out = f.remove('rev-3');
assert.equal(out.bot, 'bot-demo-rev-3');
assert.equal(existsSync(f.agentDir('rev-3')), false);
assert.equal(Object.hasOwn(JSON.parse(f.bytes()).roles, 'rev-3'), false);
// coder's vikunja is a file reference: deleting it stays a hand edit, refused at the request.
const before = f.bytes();
assert.equal(f.code(() => f.remove('coder')), 'organization-credentials-out-of-scope');
assert.equal(f.bytes(), before);
assert.deepEqual(f.status('coder'), { gitea: 'not-issued', vikunja: 'valid' });
});
test('T21 delete: a write between the raise and the take makes the delete refuse revision-conflict before it revokes anything', async (t) => {
const f = await setup(t);
f.ask('credentials.issue', { agent: 'rev-2', service: 'vikunja', token: TOKEN, date: '2099-01-01' });
const { request } = f.ask('instances.set', { revision: f.ask('instances').revision, instance: 'rev-2', op: 'delete' });
assert.equal(f.bus.lifecycleTake({ business: 'demo', request }).go, true);
// A write after the raise: the request's revision is now stale.
const raised = f.bytes();
f.ask('credentials.issue', { agent: 'rev-3', service: 'gitea', token: 'fake_other_token_9876543210', date: '2099-02-01' });
assert.ok(f.bytes() !== raised, 'the issue wrote the business file');
const before = f.bytes();
const entry = structuredClone(JSON.parse(before).roles['rev-2'].credentials);
assert.equal(f.code(() => f.bus.lifecycleDelete({ business: 'demo', request })), 'revision-conflict');
// Nothing revoked: the token stays issued, Credentials is unchanged and no store file is removed.
assert.equal(f.bytes(), before);
assert.deepEqual(JSON.parse(f.bytes()).roles['rev-2'].credentials, entry);
assert.equal(readFileSync(join(f.agentDir('rev-2'), 'vikunja'), 'utf8'), TOKEN + '\n');
assert.deepEqual(readdirSync(f.agentDir('rev-2')), ['vikunja']);
assert.deepEqual(f.status('rev-2'), { gitea: 'not-issued', vikunja: 'valid' });
});
// Row 281 (#1786, d6 section 1) T1: pins what section 1 read from the code, and what still holds after the
// change. Step B's revoke refuses while a grant's file is in the agent's store, which is why step G runs
// first. A fire with no grant record goes straight to step B, so a stray host.<id> file planted after the
// start (the start sweep would unlink it) still refuses there.
test('row 281 T1 fire: a host.<id> or granted gitea file in the store refuses step B credential-revoke-failed, file unchanged', async (t) => {
const f = await setup(t);
f.plant('coder', 'host.web1');
const before = f.bytes();
await assert.rejects(f.fire('coder'),{code:'credential-revoke-failed'});
assert.equal(f.bytes(), before);
assert.deepEqual(readdirSync(f.agentDir('coder')), ['host.web1']);
rmSync(join(f.agentDir('coder'), 'host.web1'));
assert.equal((await f.fire('coder')).delisted, true);
});
// T1, the start: a host_grants record whose key file is gone (what a delete left before this row). The
// start loads the reference with no token, so the bus comes up and the entry reads not-issued.
test('row 281 T1 start: a host grant record whose key file is gone starts and reads not-issued', async (t) => {
const entry = { address: '192.0.2.10', port: 22, user: 'deploy', hostKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHostKeyForTests'] };
const f = await setup(t, {
seed: (store) => {
hostRecord(store, { host: 'web1', entry });
hostGrantRecord(store, { agent: 'coder', host: 'web1', commands: [['uptime']] });
},
});
assert.equal(existsSync(join(f.agentDir('coder'), 'host.web1')), false);
assert.equal(f.status('coder')['host.web1'], 'not-issued');
});