git mv packages/business -> packages/organization (@mosaic/organization). The old package stays as one-line re-export shims of each moved module, with src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs forwarding to the moved executable. BusinessError is OrganizationConfigError (one constructor, name kept BusinessError); OrganizationError stays distinct. The bus adapter gains organization-context.mjs (busOrganization), with business.mjs (busBusiness) as the same function. Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure walker (organization-imports.mjs, both roots required) move to the new path. v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage A contract tests stay where they were and pass as before.
360 lines
20 KiB
JavaScript
360 lines
20 KiB
JavaScript
// Row 298 (#1804, Q228): the chief of staff's grant scope at the broker. The human's grants.scope and
|
|
// grants.scope.set (sage-3594 Q3), the in-scope close via 'scope' with grant.scope.used (Q4), the
|
|
// spend recheck's scope-changed (Q5), and the out-of-scope raise that still waits for the human.
|
|
// Gitea grants run against the fake Gitea in fake-gitea-api.mjs on 127.0.0.1; host grants stop at the
|
|
// spend, as no host key store is configured, and nothing connects to a host. Every token is a fake the
|
|
// fake made and none is printed. The organization writer is a fake here; the real one is
|
|
// packages/organization/tests/grant-scope.test.mjs.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createHash } from 'node:crypto';
|
|
import { mkdirSync, mkdtempSync, rmSync } 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 { giteaGrant, giteaKeys } from '../src/gitea-grant.mjs';
|
|
import { hostGrant, grantKeys, fingerprint } from '../src/host-grant.mjs';
|
|
import { Store } from '../src/store.mjs';
|
|
import { checkGrantScope, grantScopeOf } from '../../organization/src/vocabulary.mjs';
|
|
import { fakeGiteaApi } from './fake-gitea-api.mjs';
|
|
import { hostRecord, grantDecision } from './host-fixtures.mjs';
|
|
|
|
const USER = 'demo-coder';
|
|
const ENTRY = { address: '192.0.2.10', port: 22, user: 'jarvis', hostKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHostKeyForTests'] };
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
const sha256 = (x) => createHash('sha256').update(x).digest('hex');
|
|
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' }] },
|
|
ops: { roleVersion: 2, credentials: [{ service: 'host' }] },
|
|
};
|
|
const GITEA = { gitea: { agents: ['coder'], projects: ['app'] } };
|
|
const HOST = { host: { agents: ['ops'], hosts: ['web1'], commands: [['systemctl', 'status', 'nginx']] } };
|
|
const REVISION = 'a'.repeat(64);
|
|
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(Promise.resolve().then(p), (e) => (assert.equal(e.code, code), true));
|
|
|
|
// opts.scope: the value grantScope answers, or a function called at each read.
|
|
async function setup(t, opts = {}) {
|
|
const dir = mkdtempSync(join(tmpdir(), 'g298-'));
|
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
const gitea = await fakeGiteaApi(t, join(dir, 'tls'));
|
|
gitea.addUser('acme');
|
|
gitea.addRepo('acme', 'app');
|
|
gitea.addRepo('acme', 'lib');
|
|
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 = {
|
|
id: 'demo',
|
|
human: 'jason',
|
|
arbiters: { technical: 'pm', delivery: 'pm' },
|
|
roles: {
|
|
chief: { definition: 'chief-of-staff', ...none },
|
|
// A second chief-of-staff instance, as an instances.set or the tester could add. It isn't launch.by.
|
|
chief2: { definition: 'chief-of-staff', ...none },
|
|
pm: { definition: 'pm', ...none },
|
|
coder: { definition: 'coder', ...none, identity: { name: 'Coder', email: '[email protected]', gitea: { url: gitea.url, user: USER } }, publish: ['app', 'lib'] },
|
|
ops: { definition: 'ops', ...none },
|
|
},
|
|
definitions: structuredClone(DEFINITIONS),
|
|
projects: Object.fromEntries(['app', 'lib'].map((p) => [p, { remote: `${gitea.url}/acme/${p}` }])),
|
|
launch: { by: 'chief', instances: ['pm', 'coder', 'ops'] },
|
|
vars: { 'bus.tester': true },
|
|
};
|
|
const grantApi = giteaGrant({ configDir, ca: gitea.ca, timeoutMs: 5000 });
|
|
grantApi.writeAdmin('demo', gitea.adminToken, { url: gitea.url, account: 'mosaic-grant', rotateBy: dayFrom(60), setAt: '2026-10-21T00:00:00.000Z' });
|
|
// The fake organization writer: the scope in a box, checked as the real writer checks it.
|
|
const box = { value: opts.scope };
|
|
const orgCalls = [];
|
|
const organization = (id, verb, args, actor) => {
|
|
orgCalls.push([verb, actor]);
|
|
if (verb === 'grants.scope') return { business: id, revision: REVISION, ...grantScopeOf(box.value) };
|
|
if (verb === 'grants.scope.set') {
|
|
const scope = checkGrantScope(args.scope);
|
|
if (scope === null) throw Object.assign(new Error('scope is not a grant scope'), { code: 'invalid-request' });
|
|
const old = grantScopeOf(box.value).scope;
|
|
box.value = scope;
|
|
return { business: id, scope, old, transaction: 'tx-298' };
|
|
}
|
|
return { revision: REVISION };
|
|
};
|
|
// Keep the real key generation/store/records; replace only remote host calls.
|
|
const hostLines = new Set();
|
|
const hostApi = {
|
|
...hostGrant({ configDir, runRoot: join(configDir, 'secrets', 'run') }),
|
|
keyscan: async () => ENTRY.hostKeys,
|
|
install: async (_business, _host, _record, tag) => { hostLines.add(tag); },
|
|
verify: async () => {},
|
|
remove: async (_business, _host, _record, tag) => { hostLines.delete(tag); },
|
|
};
|
|
const broker = new Broker({
|
|
store,
|
|
businesses: { demo: business },
|
|
secretCheck: (value) => credentials.assertClean(value),
|
|
giteaGrant: grantApi,
|
|
giteaKeys: giteaKeys({ credentials, storeRoot }),
|
|
hostGrant: hostApi,
|
|
hostKeys: opts.hostKeys ? grantKeys({ credentials, storeRoot }) : null,
|
|
organization,
|
|
grantScope: () => ({ value: typeof box.value === 'function' ? box.value() : box.value, revision: Object.hasOwn(opts, 'revision') ? opts.revision : REVISION }),
|
|
});
|
|
const caps = {};
|
|
for (const role of ['chief', 'chief2', 'pm', 'ops']) {
|
|
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' });
|
|
caps.reader = broker.bindReader({ business: 'demo' });
|
|
caps.console = broker.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'p', iss: 'https://idp.example.test', sub: 's' } });
|
|
hostRecord(store, { host: 'web1', entry: ENTRY });
|
|
const call = (who, verb, args = {}) => broker.request(caps[who], { verb, args });
|
|
const raiseGitea = (over = {}, who = 'chief') =>
|
|
call(who, '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), ...over },
|
|
});
|
|
const raiseHost = (over = {}, who = 'chief') =>
|
|
call(who, 'decision.raise', {
|
|
action: 'credential.mint', question: 'Grant ops web1?', options, recommendation: 'yes', blocking: false,
|
|
grant: { method: 'host', agent: 'ops', host: 'web1', commands: [['systemctl', 'status', 'nginx']], rotateBy: dayFrom(30), ...over },
|
|
});
|
|
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 closes = (decision) => store.all('SELECT * FROM decision_events WHERE decision=? ORDER BY seq', decision);
|
|
const spent = (decision) => grantEvents().filter((e) => e.decision === decision && e.operation?.startsWith('grant.')).map((e) => e.operation);
|
|
const record = () => store.get("SELECT * FROM gitea_grants WHERE business='demo' AND agent='coder'");
|
|
return { store, broker, caps, box, orgCalls, call, raiseGitea, raiseHost, grantEvents, ended, closes, spent, record, gitea, hostLines };
|
|
}
|
|
|
|
test('row 298 (Q3): only the human at the cli reads or sets the scope; agents, the tester, a reader and the Console refuse', async (t) => {
|
|
const f = await setup(t);
|
|
for (const verb of ['grants.scope', 'grants.scope.set']) {
|
|
const args = verb === 'grants.scope' ? {} : { revision: REVISION, scope: GITEA };
|
|
for (const who of ['chief', 'chief2', 'pm', 'tester']) await rejects(() => f.call(who, verb, args), 'human-required');
|
|
await rejects(() => f.call('reader', verb, args), 'read-only');
|
|
await rejects(() => f.call('console', verb, args), 'human-required');
|
|
}
|
|
assert.deepEqual(f.orgCalls, []);
|
|
assert.equal(f.box.value, undefined);
|
|
assert.deepEqual(f.grantEvents().filter((e) => e.action === 'grant.scope'), []);
|
|
});
|
|
|
|
test('row 298 (Q1): organization.set and limits.set refuse a grantScope key, and nothing is written', async (t) => {
|
|
const f = await setup(t);
|
|
await rejects(() => f.call('human', 'limits.set', { revision: REVISION, grantScope: GITEA }), 'invalid-request');
|
|
await rejects(() => f.call('chief', 'organization.set', { revision: REVISION, grantScope: GITEA }), 'invalid-request');
|
|
assert.deepEqual(f.orgCalls, []);
|
|
assert.equal(f.box.value, undefined);
|
|
});
|
|
|
|
test('row 298 (Q3): the human verb writes the scope and a grant.scope record with the human proof and both sha256', async (t) => {
|
|
const f = await setup(t);
|
|
assert.deepEqual(await f.call('human', 'grants.scope'), { business: 'demo', revision: REVISION, scope: {}, fallback: false });
|
|
const r = await f.call('human', 'grants.scope.set', { revision: REVISION, scope: GITEA });
|
|
assert.deepEqual(r.scope, GITEA);
|
|
assert.deepEqual(f.orgCalls, [['grants.scope', 'jason'], ['grants.scope.set', 'jason']]);
|
|
const changes = f.grantEvents().filter((e) => e.action === 'grant.scope');
|
|
assert.deepEqual(changes, [{
|
|
action: 'grant.scope', by: 'jason', via: 'cli', outsideAgent: true,
|
|
oldSha256: sha256(JSON.stringify({})), newSha256: sha256(JSON.stringify(GITEA)), transaction: 'tx-298',
|
|
}]);
|
|
await rejects(() => f.call('human', 'grants.scope.set', { revision: REVISION, scope: { gitea: {} } }), 'invalid-request');
|
|
await rejects(() => f.call('human', 'grants.scope.set', { revision: REVISION }), 'invalid-request');
|
|
await rejects(() => f.call('human', 'grants.scope.set', { revision: REVISION, scope: {}, extra: 1 }), 'invalid-request');
|
|
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.scope').length, 1);
|
|
});
|
|
|
|
test('row 298 (Q4): an in-scope Gitea grant is issued with no inbox item, logged with 236 records, and revocable', async (t) => {
|
|
const f = await setup(t, { scope: GITEA });
|
|
const d = await f.raiseGitea();
|
|
const [close] = f.closes(d.id);
|
|
assert.deepEqual([close.op, close.by, close.choice, close.via], ['resolved', 'grant-scope', 'yes', 'scope']);
|
|
assert.ok(f.grantEvents().some(e => e.action === 'grant.scope.used' && e.grantDecision === d.id), 'scope close must have protected evidence');
|
|
const inbox = await f.call('human', 'inbox');
|
|
assert.ok(!JSON.stringify(inbox).includes(d.id), 'the decision is not in the human inbox');
|
|
await until(() => f.ended(d.id));
|
|
await f.broker.giteaIdle();
|
|
assert.equal(f.ended(d.id).action, 'grant.applied');
|
|
const used = f.grantEvents().find((e) => e.action === 'grant.scope.used');
|
|
assert.deepEqual(used, {
|
|
action: 'grant.scope.used', grantDecision: d.id, decisionSeq: close.seq, method: 'gitea', agent: 'coder', scope: GITEA, revision: REVISION, scopeSha256: sha256(JSON.stringify(GITEA)),
|
|
});
|
|
assert.deepEqual(f.spent(d.id), ['grant.gitea']);
|
|
assert.equal(f.record().decision, d.id);
|
|
// The spend is row 236's: a second start finds it consumed.
|
|
assert.equal(await f.broker.runGiteaGrant('demo', d.id), 'decision-consumed');
|
|
const out = await f.call('human', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'test' });
|
|
assert.deepEqual(out.serviceLeft, []);
|
|
assert.ok(f.grantEvents().some((e) => e.action === 'grant.revoked' && e.agent === 'coder'));
|
|
assert.equal(f.record(), undefined);
|
|
});
|
|
|
|
test('row 298: an out-of-scope Gitea grant waits for the human, whose Approve still issues it', async (t) => {
|
|
const f = await setup(t, { scope: GITEA });
|
|
const d = await f.raiseGitea({ target: { projects: ['app', 'lib'] } });
|
|
assert.deepEqual(f.closes(d.id), []);
|
|
assert.ok(JSON.stringify(await f.call('human', 'inbox')).includes(d.id));
|
|
assert.deepEqual(f.grantEvents().filter((e) => e.action === 'grant.scope.used'), []);
|
|
await 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('row 298: with no scope, a bad scope or an unreadable file, an in-scope-looking grant waits', async (t) => {
|
|
const bad = { gitea: { agents: ['coder'], projects: ['app'], note: 'not a key' } };
|
|
for (const scope of [undefined, bad, () => { throw new Error('unreadable'); }]) {
|
|
const f = await setup(t, { scope });
|
|
const d = await f.raiseGitea();
|
|
assert.deepEqual(f.closes(d.id), [], String(scope));
|
|
assert.deepEqual(f.grantEvents().filter((e) => e.action === 'grant.scope.used'), []);
|
|
}
|
|
});
|
|
|
|
test('row 298 (Q6): a chief-of-staff instance that is not launch.by never raises in scope', async (t) => {
|
|
const f = await setup(t, { scope: GITEA });
|
|
const d = await f.raiseGitea({}, 'chief2');
|
|
assert.deepEqual(f.closes(d.id), []);
|
|
});
|
|
|
|
test('row 298 (Q5): a scope narrowed between the raise and the spend ends the grant scope-changed', async (t) => {
|
|
let reads = 0;
|
|
const f = await setup(t, { scope: () => (reads++ === 0 ? GITEA : {}) });
|
|
const d = await f.raiseGitea();
|
|
assert.equal(f.closes(d.id)[0].via, 'scope');
|
|
await until(() => f.ended(d.id));
|
|
await f.broker.giteaIdle();
|
|
assert.deepEqual([f.ended(d.id).action, f.ended(d.id).code], ['grant.failed', 'scope-changed']);
|
|
assert.equal(f.record(), undefined);
|
|
assert.deepEqual(f.gitea.tokens(USER) ?? [], []);
|
|
assert.deepEqual(f.spent(d.id), ['grant.gitea']);
|
|
assert.equal(await f.broker.runGiteaGrant('demo', d.id), 'decision-consumed');
|
|
});
|
|
|
|
test('row 298 (Q2): an in-scope host grant is spent with no Approve; ls never covers lsblk, nor a scope command extra arguments', async (t) => {
|
|
const scope = { host: { agents: ['ops'], hosts: ['web1'], commands: [['ls'], ['systemctl', 'status', 'nginx']] } };
|
|
const f = await setup(t, { scope });
|
|
const d = await f.raiseHost();
|
|
assert.equal(f.closes(d.id)[0].via, 'scope');
|
|
await until(() => f.ended(d.id));
|
|
// The spend is row 235's. No host key store is configured, so the recheck after it ends here.
|
|
assert.deepEqual(f.spent(d.id), ['grant.host']);
|
|
assert.deepEqual([f.ended(d.id).action, f.ended(d.id).code], ['grant.failed', 'host-not-configured']);
|
|
for (const commands of [[['lsblk']], [['ls', '-l']], [['systemctl', 'restart']]]) {
|
|
const w = await f.raiseHost({ commands });
|
|
assert.deepEqual(f.closes(w.id), [], JSON.stringify(commands));
|
|
}
|
|
});
|
|
|
|
test('row 298 (Q5): a host scope narrowed before the spend ends scope-changed', async (t) => {
|
|
let reads = 0;
|
|
const f = await setup(t, { scope: () => (reads++ === 0 ? HOST : {}) });
|
|
const d = await f.raiseHost();
|
|
assert.equal(f.closes(d.id)[0].via, 'scope');
|
|
await until(() => f.ended(d.id));
|
|
assert.deepEqual([f.ended(d.id).action, f.ended(d.id).code], ['grant.failed', 'scope-changed']);
|
|
assert.deepEqual(f.spent(d.id), ['grant.host']);
|
|
});
|
|
|
|
// Break caught: accepting scope closes with no evidence or unbound evidence.
|
|
test('row 298 Q4: missing scope evidence or a bad scope digest cannot authorize execution', async (t) => {
|
|
const f = await setup(t, { scope: GITEA });
|
|
const good = { scope: GITEA, scopeSha256: sha256(JSON.stringify(GITEA)), revision: REVISION };
|
|
for (const evidence of [
|
|
null,
|
|
{ ...good, scopeSha256: '0'.repeat(64) },
|
|
{ ...good, revision: null },
|
|
{ ...good, scope: { gitea: 'all' } },
|
|
{ ...good, agent: 'other' },
|
|
{ ...good, method: 'host' },
|
|
{ ...good, decisionSeq: 999999 },
|
|
]) {
|
|
const grant = { method: 'gitea', agent: 'coder', target: { projects: ['app'] }, rotateBy: dayFrom(30) };
|
|
const id = grantDecision(f.store, { grant, close: { op: 'resolved', by: 'grant-scope', choice: 'yes', via: 'scope' } });
|
|
if (evidence) writeGrantEvent(f.store, new Date().toISOString(), 'demo', {
|
|
action: 'grant.scope.used', grantDecision: id, decisionSeq: f.closes(id)[0].seq,
|
|
method: 'gitea', agent: 'coder', ...evidence,
|
|
}, id);
|
|
assert.equal(await f.broker.runGiteaGrant('demo', id), 'not-authorized');
|
|
assert.deepEqual(f.spent(id), []);
|
|
assert.equal(f.record(), undefined);
|
|
}
|
|
// Positive control: the same fixture with valid scope evidence reaches the
|
|
// next grant check. Its intentionally absent definition binding refuses there.
|
|
const id = grantDecision(f.store, {
|
|
grant: { method: 'gitea', agent: 'coder', target: { projects: ['app'] }, rotateBy: dayFrom(30) },
|
|
close: { op: 'resolved', by: 'grant-scope', choice: 'yes', via: 'scope' },
|
|
});
|
|
writeGrantEvent(f.store, new Date().toISOString(), 'demo', {
|
|
action: 'grant.scope.used', grantDecision: id, decisionSeq: f.closes(id)[0].seq,
|
|
method: 'gitea', agent: 'coder', ...good,
|
|
}, id);
|
|
assert.equal(await f.broker.runGiteaGrant('demo', id), 'decision-mismatch');
|
|
assert.deepEqual(f.spent(id), []);
|
|
assert.equal(f.record(), undefined);
|
|
});
|
|
|
|
// Break caught: host grants bypassing records or losing the ordinary revoke path.
|
|
test('row 298 T4/T8: an in-scope host grant issues, records and revokes through the existing executor', async (t) => {
|
|
const f = await setup(t, { scope: HOST, hostKeys: true });
|
|
const d = await f.raiseHost();
|
|
assert.equal(f.closes(d.id)[0].via, 'scope');
|
|
assert.ok(!JSON.stringify(await f.call('human', 'inbox')).includes(d.id));
|
|
await until(() => f.ended(d.id));
|
|
await f.broker.grantsIdle();
|
|
assert.equal(f.ended(d.id).action, 'grant.applied');
|
|
assert.equal(f.store.get("SELECT decision FROM host_grants WHERE agent='ops'").decision, d.id);
|
|
assert.equal(f.hostLines.size, 1);
|
|
await f.call('human', 'grants.scope.set', { revision: REVISION, scope: {} });
|
|
// Historical approval still passes #hostAccess after scope is narrowed;
|
|
// only the absent command executor refuses this call, before any sudo decision.
|
|
await rejects(() => f.broker.requestHost(f.caps.ops,
|
|
{ verb: 'host.run', args: { host: 'web1', argv: ['systemctl', 'status', 'nginx'] } }, null), 'host-not-configured');
|
|
await f.call('human', 'hosts.revoke', { agent: 'ops', host: 'web1', reason: 'test' });
|
|
assert.equal(f.store.get("SELECT decision FROM host_grants WHERE agent='ops'"), undefined);
|
|
assert.equal(f.hostLines.size, 0);
|
|
assert.ok(f.grantEvents().some(e => e.action === 'grant.revoked' && e.agent === 'ops'));
|
|
});
|
|
|
|
// Break caught: a new host enrollment entering the scope shortcut.
|
|
test('row 298 T6: host enrollment stays open for human Approve even with both methods in scope', async (t) => {
|
|
const f = await setup(t, { scope: { ...GITEA, ...HOST } });
|
|
const d = await f.call('chief', 'decision.raise', {
|
|
action: 'credential.mint', question: 'Enroll host?', options, recommendation: 'yes', blocking: false,
|
|
grant: { method: 'host-enroll', host: 'web2', address: ENTRY.address, port: ENTRY.port,
|
|
user: ENTRY.user, sudo: true, fingerprints: ENTRY.hostKeys.map(fingerprint) },
|
|
});
|
|
assert.deepEqual(f.closes(d.id), []);
|
|
assert.ok(JSON.stringify(await f.call('human', 'inbox')).includes(d.id));
|
|
assert.deepEqual(f.grantEvents().filter(e => e.action === 'grant.scope.used'), []);
|
|
});
|
|
|
|
// Break caught: using a scope without a verifiable organization revision.
|
|
test('row 298: scope without an organization revision never closes automatically', async (t) => {
|
|
const f = await setup(t, { scope: GITEA, revision: null });
|
|
const d = await f.raiseGitea();
|
|
assert.deepEqual(f.closes(d.id), []);
|
|
assert.deepEqual(f.spent(d.id), []);
|
|
});
|