// Row 235 (#1741, #436 row b, r6 X4, X5): the grant executor's Credentials calls. register and // unregister add and drop one store reference in the running bus, and claim, release and abortClaim // hold the (agent, host.) slot that host.run and revoke share. Keys are made by ssh-keygen in a // scratch root, and nothing connects to any host. import test from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Credentials } from '../src/credentials.mjs'; const AGENT = 'demo/ops'; const SERVICE = 'host.web1'; function setup(t) { const root = mkdtempSync(join(tmpdir(), 'bus-claim-')); t.after(() => rmSync(root, { recursive: true, force: true })); const storeRoot = join(root, 'secrets', 'agents'); mkdirSync(storeRoot, { recursive: true, mode: 0o700 }); chmodSync(join(root, 'secrets'), 0o700); const file = join(storeRoot, 'demo', 'ops', SERVICE); const ref = { service: SERVICE, store: true, file, rotateBy: '2099-01-01' }; const key = (name) => { execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'test', '-f', join(root, name)]); return readFileSync(join(root, name), 'utf8'); }; const c = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' }); t.after(() => c.close()); const state = () => c.status().find((e) => e.instance === AGENT && e.service === SERVICE)?.state; const run = () => c.use(AGENT, SERVICE, async () => ({ ok: true })); return { root, storeRoot, file, ref, key, c, state, run }; } test('X4: register adds a store reference to the running bus, and issue then writes the key', async (t) => { const s = setup(t); assert.throws(() => s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01'), /credential-unknown/); s.c.register(AGENT, SERVICE, s.ref); assert.equal(s.state(), 'not-issued'); await assert.rejects(s.run(), /credential-unavailable/); assert.equal(s.c.issue(AGENT, SERVICE, s.key('k2'), '2099-01-01').state, 'valid'); assert.deepEqual(await s.run(), { ok: true }); }); test('X4: register runs the constructor\'s reference checks', (t) => { const s = setup(t); const other = join(s.storeRoot, 'demo', 'pm', SERVICE); for (const [ref, code] of [ [null, /credential-reference/], [{ ...s.ref, rotateBy: '2099-02-30' }, /credential-date/], [{ ...s.ref, file: other }, /credential-reference/], [{ ...s.ref, extra: 1 }, /credential-reference/], [{ ...s.ref, service: 'host.web2' }, /credential-reference/], [{ ...s.ref, env: 'X' }, /credential-reference/], ]) assert.throws(() => s.c.register(AGENT, SERVICE, ref), code); assert.throws(() => s.c.register(AGENT, 'nope', s.ref), /credential-reference/); assert.throws(() => s.c.register('demo/@tester', SERVICE, { ...s.ref, file: join(s.storeRoot, 'demo', '@tester', SERVICE) }), /credential-reference/); assert.equal(s.state(), undefined); }); test('X4: register reads no file, so a key file left at the path stays unused, and a live token refuses', async (t) => { const s = setup(t); mkdirSync(join(s.storeRoot, 'demo', 'ops'), { recursive: true, mode: 0o700 }); writeFileSync(s.file, s.key('left'), { mode: 0o600 }); s.c.register(AGENT, SERVICE, s.ref); assert.equal(s.state(), 'not-issued'); await assert.rejects(s.run(), /credential-unavailable/); s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01'); assert.throws(() => s.c.register(AGENT, SERVICE, s.ref), /credential-reference/); assert.equal(s.state(), 'valid'); // A revoked entry has no token, so a new grant registers again. s.c.revoke(AGENT, SERVICE); assert.equal(s.state(), 'revoked'); s.c.register(AGENT, SERVICE, s.ref); assert.equal(s.state(), 'not-issued'); }); test('X3, X4: unregister drops an entry with no token and refuses one with a live token', (t) => { const s = setup(t); s.c.unregister(AGENT, SERVICE); s.c.register(AGENT, SERVICE, s.ref); s.c.unregister(AGENT, SERVICE); assert.equal(s.state(), undefined); assert.throws(() => s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01'), /credential-unknown/); s.c.register(AGENT, SERVICE, s.ref); s.c.issue(AGENT, SERVICE, s.key('k2'), '2099-01-01'); assert.throws(() => s.c.unregister(AGENT, SERVICE), /credential-reference/); assert.equal(s.state(), 'valid'); s.c.revoke(AGENT, SERVICE); assert.equal(existsSync(s.file), false); s.c.unregister(AGENT, SERVICE); assert.equal(s.state(), undefined); }); test('X5: a claim holds the slot, so a second claim and a host.run refuse host-busy until release', async (t) => { const s = setup(t); s.c.register(AGENT, SERVICE, s.ref); s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01'); const signal = s.c.claim(AGENT, SERVICE); assert.equal(signal.aborted, false); assert.equal(s.c.busy(AGENT, SERVICE), true); assert.throws(() => s.c.claim(AGENT, SERVICE), /host-busy/); await assert.rejects(s.run(), /host-busy/); // A claim needs no reference: the executor claims before it registers a new key. const other = s.c.claim('demo/pm', SERVICE); s.c.release('demo/pm', SERVICE, other); s.c.release(AGENT, SERVICE, signal); assert.equal(s.c.busy(AGENT, SERVICE), false); assert.deepEqual(await s.run(), { ok: true }); }); test('X5: a claim made while a host.run is in flight refuses host-busy', async (t) => { const s = setup(t); s.c.register(AGENT, SERVICE, s.ref); s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01'); let finish; const running = s.c.use(AGENT, SERVICE, () => new Promise((r) => (finish = r))); assert.throws(() => s.c.claim(AGENT, SERVICE), /host-busy/); finish({ ok: true }); assert.deepEqual(await running, { ok: true }); s.c.release(AGENT, SERVICE, s.c.claim(AGENT, SERVICE)); }); test('X5: a stale release leaves a later claim in place', (t) => { const s = setup(t); const first = s.c.claim(AGENT, SERVICE); s.c.release(AGENT, SERVICE, first); const second = s.c.claim(AGENT, SERVICE); s.c.release(AGENT, SERVICE, first); s.c.release(AGENT, SERVICE); assert.equal(s.c.busy(AGENT, SERVICE), true); s.c.release(AGENT, SERVICE, second); assert.equal(s.c.busy(AGENT, SERVICE), false); }); test('X5: abortClaim aborts a live claim and says pending, and revoke\'s abort reaches a claim too', (t) => { const s = setup(t); assert.deepEqual(s.c.abortClaim(AGENT, SERVICE), { pending: false }); const signal = s.c.claim(AGENT, SERVICE); assert.deepEqual(s.c.abortClaim(AGENT, SERVICE), { pending: true }); assert.equal(signal.aborted, true); // The slot stays held until the executor releases it. assert.equal(s.c.busy(AGENT, SERVICE), true); s.c.release(AGENT, SERVICE, signal); s.c.register(AGENT, SERVICE, s.ref); s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01'); const live = s.c.claim(AGENT, SERVICE); s.c.revoke(AGENT, SERVICE); assert.equal(live.aborted, true); s.c.release(AGENT, SERVICE, live); });