fix(bus): CB4 review round, fire revokes after the write, archive and delete revoke at Gitea (#1873)
Build review (changes 8) and security review (changes 4): - P1-1: an owner-approved fire no longer writes the revoke before the apply (decision-stale). The fire stops the copy's runs, the fire is written, then the retire revoke starts for the fired instance. - P2 (both): archive and delete from the CLI and the Console pass the organization's managed Gitea remote step (copyRemoteFor). A remote step that was not attempted or failed keeps the record revoking with failed remote, so the bus finishes it after restore; no done tombstone over a live token. - Security P2-1: a bound server that cannot be read is a failed remote step, not not-managed. updateOrg refuses project-repo-bound when a project a copy binding grants loses or changes its remote or git. - P3-1 (security): a delete whose organization file cannot be read says copiesUnreadable, never an empty copy list. - P3-2 (security): the remote step requires the record's incarnation to equal the registry's live one. - P3-5 (build): the archive's slot discard checks each parent directory (no symlink, own uid, private mode). - P3-6 (build): resume keeps credential-revoke-failed as the gate code while a failed op runs again. - P3-4 (build): a test pins the refused-begin reopen guard.
This commit is contained in:
1 parent
90245765f2
commit
ef1d3a4583
11 files changed
+250
-54
No files matched your search
@@ -6782,21 +6782,25 @@ export class Broker {
|
||||
for(const d of this.#store.all(`SELECT d.* FROM decisions d WHERE business=? AND action IN ('role.hire','role.fire','role.change') AND NOT EXISTS(SELECT 1 FROM decision_events x WHERE x.decision=d.id AND x.op IN ${CLOSED})`,s.business))
|
||||
this.#expireHire(s,d,this.#raised(s.business,d.id)?.hire,true);
|
||||
}
|
||||
// #1873 CB4: an unbind the decision left revoking runs its revoke now (the owner approved it);
|
||||
// any other applied decision may have changed, retired or shadowed a copy, which the sweep finds.
|
||||
// #1873 CB4: an unbind the decision left revoking runs its revoke now (the owner approved it); a
|
||||
// fired copy's binding is revoked now that the fire is written (cause `retire`; the root agents
|
||||
// file a root fire changes is not one the gate reads until the next start). Any other applied
|
||||
// decision may have changed, retired or shadowed a copy, which the sweep finds.
|
||||
#copyAfterHire(business,p) {
|
||||
if(!this.#copyRevoker)return;
|
||||
const instance=p.binding?.instance;
|
||||
if(typeof instance==='string' && this.#copyRevoker.binding(business,instance)?.state==='revoking')
|
||||
this.#copyRevoker.start(business,instance,{cause:'unbind',actor:'bus',owner:true});
|
||||
const fired=p.op==='fire' && p.level!=='org' ? (p.level==='project'?`${p.name}.${p.project}`:p.name) : null;
|
||||
if(fired && this.#copyRevoker.binding(business,fired))this.#copyRevoker.start(business,fired,{cause:'retire',actor:'bus'});
|
||||
this.#copyRevoker.sweep(business);
|
||||
}
|
||||
async #stopCopyApproved(s,p) {
|
||||
const instance=p.level==='project'?`${p.name}.${p.project}`:p.name;
|
||||
const b=this.#business(s.business);
|
||||
// #1873 CB4: a fired copy's binding is revoked, its runs stopped, before the fire is written.
|
||||
if(this.#copyRevoker?.binding(s.business,instance) && (!Object.hasOwn(b.roles,instance) || b.roles[instance]?.copyBound))
|
||||
this.#copyRevoker.start(s.business,instance,{cause:'retire',actor:'bus'});
|
||||
// #1873 CB4: a fired copy's runs are stopped here, before the fire is written; #copyAfterHire
|
||||
// revokes its binding after the write. A revoke begun here would write the organization file
|
||||
// under the fire's own revision check and refuse the fire decision-stale.
|
||||
if(!b.roles[instance])return;
|
||||
if(this.#revoking.has(s.business+'/'+instance))fail('fire-in-progress');
|
||||
let running=false;
|
||||
|
||||
@@ -25,6 +25,11 @@ import { BusError } from './broker.mjs';
|
||||
import { COPY_BINDABLE } from '../../organization/src/agent-copies.mjs';
|
||||
import { copyBotLogin } from '../../organization/src/copy-bindings.mjs';
|
||||
import { boundRepositories } from './copy-grant.mjs';
|
||||
import { giteaGrant } from './gitea-grant.mjs';
|
||||
import { organizationWriter } from '../../organization/src/organization.mjs';
|
||||
import { readOrgEntrySync } from '../../organization/src/org-registry.mjs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const REVOKE_POINTS = Object.freeze([
|
||||
'beforePublication', 'afterPublication', 'beforeStop', 'afterStop', 'beforeSecret', 'afterSecret',
|
||||
@@ -177,7 +182,8 @@ export function copyRevoker({ writer, gate = null, credentials, stopRuns, remote
|
||||
const d = doc(business);
|
||||
if (d === null) return [];
|
||||
const pending = Object.entries(d.copyBindings ?? {}).filter(([, r]) => r?.state === 'revoking');
|
||||
for (const [instance] of pending) gateOf(business)?.close(business, instance);
|
||||
// A pass that already failed a step keeps its failure code while it runs again.
|
||||
for (const [instance, r] of pending) gateOf(business)?.close(business, instance, r.revoke?.failed ? 'credential-revoke-failed' : undefined);
|
||||
return Promise.allSettled(pending.map(([instance, r]) => revoker.run(business, instance, { cause: r.revoke.cause, actor: 'bus', owner: true })));
|
||||
},
|
||||
};
|
||||
@@ -188,9 +194,11 @@ export function copyRevoker({ writer, gate = null, credentials, stopRuns, remote
|
||||
// copy's token is deleted by id from its bot user. 204 is `revoked`, 404 `absent`. With no admin slot,
|
||||
// or one on another server than the binding's projects, the organization does not manage the copy's
|
||||
// Gitea and the step is `not-managed`: the record says so and never claims the token is gone. Any
|
||||
// other answer, an admin slot that fails its checks, or a server that cannot be reached is `failed`,
|
||||
// other answer, an admin slot that fails its checks, a server that cannot be reached, bound projects
|
||||
// whose server can no longer be read from the organization file, or a record whose organization
|
||||
// incarnation is not the live organization's (`incarnation`, business => string|null) is `failed`,
|
||||
// and the copy stays fenced until a later pass revokes it.
|
||||
export function giteaCopyRemote({ grant, read, today = () => new Date().toISOString().slice(0, 10) }) {
|
||||
export function giteaCopyRemote({ grant, read, incarnation = null, today = () => new Date().toISOString().slice(0, 10) }) {
|
||||
return async (business, instance, record, service) => {
|
||||
if (service !== 'gitea' || !grant) return 'not-managed';
|
||||
const m = /^gitea:(\d+):(\d+)$/.exec(record.services?.gitea?.grantId ?? '');
|
||||
@@ -199,7 +207,14 @@ export function giteaCopyRemote({ grant, read, today = () => new Date().toISOStr
|
||||
try {
|
||||
origin = boundRepositories(read(business), record.services.gitea.resources?.projects).origin;
|
||||
} catch {
|
||||
return 'not-managed';
|
||||
return 'failed';
|
||||
}
|
||||
if (typeof incarnation === 'function') {
|
||||
let live = null;
|
||||
try {
|
||||
live = incarnation(business);
|
||||
} catch {}
|
||||
if (typeof live !== 'string' || live !== record.orgIncarnation) return 'failed';
|
||||
}
|
||||
let admin;
|
||||
try {
|
||||
@@ -226,3 +241,16 @@ export function giteaCopyRemote({ grant, read, today = () => new Date().toISOStr
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// The managed remote step outside a running bus, for archive and delete from the CLI or the Console:
|
||||
// the organization's Gitea admin slot, its file and its registry incarnation, each read when the
|
||||
// step runs. `transport` and `ca` are the scratch tests' (giteaGrant's own options).
|
||||
const ROLES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../../../roles');
|
||||
export function managedCopyRemote({ configDir, dataRoot, transport = undefined, ca = undefined }) {
|
||||
const writer = organizationWriter({ dir: configDir, rolesDir: ROLES_DIR, dataRoot });
|
||||
return giteaCopyRemote({
|
||||
grant: giteaGrant({ configDir, ...(transport ? { transport, ca } : {}) }),
|
||||
read: (id) => writer.read(id).document,
|
||||
incarnation: (id) => readOrgEntrySync(configDir, id)?.incarnation ?? null,
|
||||
});
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import { COPY_BINDABLE, loadWithCopies, promoteCopies } from '../../organization
|
||||
import { copyGrant, sweepCopySlots } from './copy-grant.mjs';
|
||||
import { copyLaunchGate } from './copy-launch.mjs';
|
||||
import { copyRevoker as makeCopyRevoker, giteaCopyRemote } from './copy-revoke.mjs';
|
||||
import { readOrgEntrySync } from '../../organization/src/org-registry.mjs';
|
||||
import { loadRole } from '../../organization/src/role.mjs';
|
||||
import { resolveInstance } from '../../organization/src/resolve.mjs';
|
||||
import { canonicalJson } from '../../organization/src/util.mjs';
|
||||
@@ -471,7 +472,7 @@ export async function startBroker({
|
||||
const b = await brokerReady;
|
||||
return b ? b.stopCopyRuns(id, instance, opts) : { confirmed: false };
|
||||
},
|
||||
remote: giteaCopyRemote({ grant, read: (id) => revokeWriter.read(id).document }),
|
||||
remote: giteaCopyRemote({ grant, read: (id) => revokeWriter.read(id).document, incarnation: (id) => readOrgEntrySync(organization.dir, id)?.incarnation ?? null }),
|
||||
definitions: (id) => (bootCopies?.org === id ? bootCopies.definitions ?? {} : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,14 +16,14 @@ import { Credentials } from '../src/credentials.mjs';
|
||||
import { REFUSALS } from '../src/refusals.mjs';
|
||||
import { copyGrant } from '../src/copy-grant.mjs';
|
||||
import { copyLaunchGate } from '../src/copy-launch.mjs';
|
||||
import { copyRevoker, giteaCopyRemote, REVOKE_POINTS } from '../src/copy-revoke.mjs';
|
||||
import { copyRevoker, giteaCopyRemote, managedCopyRemote, REVOKE_POINTS } from '../src/copy-revoke.mjs';
|
||||
import { giteaGrant, send } from '../src/gitea-grant.mjs';
|
||||
import { fakeGiteaApi } from './fake-gitea-api.mjs';
|
||||
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
|
||||
import { organizationWriter } from '../../organization/src/organization.mjs';
|
||||
import { checkRootAgents } from '../../organization/src/agent-levels.mjs';
|
||||
import { loadWithCopies, materializeCopies, promoteCopies } from '../../organization/src/agent-copies.mjs';
|
||||
import { activateOrg, listOrgs, reserveOrg } from '../../organization/src/org-registry.mjs';
|
||||
import { activateOrg, listOrgs, readOrgEntrySync, reserveOrg } from '../../organization/src/org-registry.mjs';
|
||||
import * as orgService from '../../organization/src/org-service.mjs';
|
||||
import { copyBotLogin } from '../../organization/src/copy-bindings.mjs';
|
||||
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
@@ -121,7 +121,7 @@ async function org(t, w, { id = 'demo', name = 'rev', services = READ, perm = 'r
|
||||
const gate = copyLaunchGate({ credentials, writer, configDir: root, rolesDir, snapshot, transport, ca: api.ca, timeoutMs: 3000, now: () => new Date(f.clock),
|
||||
onLoss: (business, instance, cause) => revoker?.start(business, instance, { cause, actor: 'bus' }) });
|
||||
revoker = copyRevoker({ writer, gate, credentials, hooks: f.hooks, stopRuns: (...a) => f.stopRuns(...a),
|
||||
remote: giteaCopyRemote({ grant, read: (x) => writer.read(x).document }), definitions: () => snapshot.definitions ?? {} });
|
||||
remote: giteaCopyRemote({ grant, read: (x) => writer.read(x).document, incarnation: (x) => readOrgEntrySync(root, x)?.incarnation ?? null }), definitions: () => snapshot.definitions ?? {} });
|
||||
let promoted = snapshot;
|
||||
const business = () => {
|
||||
const d = loadWithCopies(id, { dir: root, rolesDir, snapshot: promoted });
|
||||
@@ -336,13 +336,22 @@ test('CB4 fire: a copy whose root agent is gone at the next start (the fire land
|
||||
await assert.rejects(f.prepare(), refuses('copy-unavailable'));
|
||||
});
|
||||
|
||||
test('CB4 fire: the bus revokes a fired copy\'s binding before the fire is written, with its runs stopped', async (t) => {
|
||||
test('CB4 fire through the owner\'s decision: the fire is written, the copy\'s runs are stopped and its binding is revoked after the write', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f, r } = await live(t, w);
|
||||
// The fire path (#stopCopyApproved) starts the retire revoke; the revoker is the one it calls.
|
||||
await f.revoker.run(f.id, f.name, { cause: 'retire', actor: 'bus' });
|
||||
// The root agent is still in this boot's file, so the copy itself still exists: unbound.
|
||||
await assertRevoked(f, r, { cause: 'retire', code: 'credential-unbound' });
|
||||
const f = await org(t, w, { name: 'rev.stack', project: { rev: { definition: 'research' } } });
|
||||
f.rootAgents = {};
|
||||
f.start();
|
||||
await f.bind({ level: 'project', project: 'stack', name: 'rev' });
|
||||
f.start();
|
||||
await f.boot();
|
||||
const r = await f.launch('run-1');
|
||||
assert.equal((await f.use(r.cap)).status, 200);
|
||||
// The fire decision itself must not be refused decision-stale by a revoke written under it.
|
||||
const out = await f.call(f.owner, 'hire.start.console', { op: 'fire', level: 'project', project: 'stack', name: 'rev', reason: 'Fire the copy' });
|
||||
assert.ok(out, 'the fire applied');
|
||||
assert.equal(JSON.parse(readFileSync(f.file, 'utf8')).projects.stack.agents?.rev, undefined, 'the fire is written');
|
||||
assert.ok(f.killed().includes(r.run), 'the run was told to stop before the write');
|
||||
await assertRevoked(f, r, { cause: 'retire', code: 'copy-unavailable' });
|
||||
});
|
||||
|
||||
test('CB4 a project copy removed from its project while the bus runs (no reload): the next call refuses and the binding is revoked', async (t) => {
|
||||
@@ -521,16 +530,17 @@ for (const point of REVOKE_POINTS) {
|
||||
});
|
||||
}
|
||||
|
||||
test('CB4 archive discards the copy credential: tombstone, slot gone, receipt honest about the remote, restore brings nothing back', async (t) => {
|
||||
test('CB4 archive with no remote adapter: slot gone, the copy stays revoking and fenced, the receipt claims no revoke, and the bus finishes it after restore', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f, r } = await live(t, w);
|
||||
const revision = (await listOrgs(f.ctx)).find((o) => o.id === f.id).revision;
|
||||
const out = await orgService.archiveOrg(f.ctx, f.id, { ifRevision: revision, busStop: async () => ({ stopped: true, reason: 'stopped' }) });
|
||||
assert.deepEqual(out.copies, [{ agent: f.name, state: 'unbound', slot: 'removed', remote: { gitea: 'not-attempted' } }]);
|
||||
assert.ok(tombstoned(f));
|
||||
assert.deepEqual(out.copies, [{ agent: f.name, state: 'revoking', slot: 'removed', remote: { gitea: 'not-attempted' } }]);
|
||||
assert.equal(f.rec().state, 'revoking');
|
||||
assert.equal(f.rec().revoke.cause, 'archive');
|
||||
assert.deepEqual(f.rec().revoke.failed, ['remote']);
|
||||
assert.ok(!existsSync(f.slot));
|
||||
// Not wired to the managed adapter here, so the token is still at the service and the record says so.
|
||||
// Not attempted at the service: the token is still there and nothing says otherwise.
|
||||
assert.equal(f.bot.tokens.length, 1);
|
||||
const log = readFileSync(join(w.data, 'org-lifecycle.log'), 'utf8').trim().split('\n').map(JSON.parse);
|
||||
assert.deepEqual(log.at(-1).copies, out.copies);
|
||||
@@ -541,16 +551,21 @@ test('CB4 archive discards the copy credential: tombstone, slot gone, receipt ho
|
||||
f.start();
|
||||
await f.boot();
|
||||
assert.deepEqual(f.gate.bound(), {});
|
||||
await assert.rejects(f.prepare(), refuses('credential-unbound'));
|
||||
await assert.rejects(f.prepare(), (e) => ['credential-revoking', 'credential-revoke-failed'].includes(e.code));
|
||||
// The bus's resume at its start runs the remote step through the managed adapter.
|
||||
await f.revoker.resume(f.id);
|
||||
await assertRevoked(f, null, { cause: 'archive', code: 'credential-unbound' });
|
||||
});
|
||||
|
||||
test('CB4 archive with the managed adapter wired revokes the token at Gitea', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f } = await live(t, w);
|
||||
const revision = (await listOrgs(f.ctx)).find((o) => o.id === f.id).revision;
|
||||
const copyRemote = giteaCopyRemote({ grant: f.grant, read: (x) => f.writer.read(x).document });
|
||||
// The adapter the CLI and Console wire (org-bus.mjs copyRemoteFor), on the fake Gitea.
|
||||
const copyRemote = managedCopyRemote({ configDir: f.root, dataRoot: w.data, transport: send, ca: w.api.ca });
|
||||
const out = await orgService.archiveOrg(f.ctx, f.id, { ifRevision: revision, busStop: async () => ({ stopped: true, reason: 'stopped' }), copyRemote });
|
||||
assert.deepEqual(out.copies[0].remote, { gitea: 'revoked' });
|
||||
assert.deepEqual(out.copies, [{ agent: f.name, state: 'unbound', slot: 'removed', remote: { gitea: 'revoked' } }]);
|
||||
assert.ok(tombstoned(f));
|
||||
assert.deepEqual(f.bot.tokens, []);
|
||||
});
|
||||
|
||||
@@ -561,7 +576,8 @@ test('CB4 archive rerun after a crash joins the pending op', async (t) => {
|
||||
const begun = f.writer.revokeCopyBinding(f.id, { phase: 'begin', instance: f.name, cause: 'archive' }, { actor: 'bus', owner: true });
|
||||
assert.equal(begun.state, 'revoking');
|
||||
const revision = (await listOrgs(f.ctx)).find((o) => o.id === f.id).revision;
|
||||
const out = await orgService.archiveOrg(f.ctx, f.id, { ifRevision: revision, busStop: async () => ({ stopped: true, reason: 'stopped' }) });
|
||||
const copyRemote = managedCopyRemote({ configDir: f.root, dataRoot: w.data, transport: send, ca: w.api.ca });
|
||||
const out = await orgService.archiveOrg(f.ctx, f.id, { ifRevision: revision, busStop: async () => ({ stopped: true, reason: 'stopped' }), copyRemote });
|
||||
assert.equal(out.copies[0].state, 'unbound');
|
||||
assert.equal(f.rec().revoke.id, begun.op);
|
||||
assert.ok(!existsSync(f.slot));
|
||||
@@ -585,6 +601,89 @@ test('CB4 delete quarantines the copy slot and its receipt never claims a remote
|
||||
await assert.rejects(f.prepare(), (e) => ['org-unavailable', 'copy-unavailable', 'credential-unbound'].includes(e.code));
|
||||
});
|
||||
|
||||
test('CB4 delete with the managed adapter revokes each copy token at Gitea and says so in the receipt', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f } = await live(t, w);
|
||||
const revision = (await listOrgs(f.ctx)).find((o) => o.id === f.id).revision;
|
||||
const copyRemote = managedCopyRemote({ configDir: f.root, dataRoot: w.data, transport: send, ca: w.api.ca });
|
||||
const out = await orgService.deleteOrg(f.ctx, f.id, { confirm: f.id, ifRevision: revision, busStop: async () => ({ stopped: true, reason: 'stopped' }), copyRemote });
|
||||
assert.deepEqual(out.copies, [{ agent: f.name, service: 'gitea', slot: 'quarantined', remote: 'revoked' }]);
|
||||
assert.equal(out.copiesUnreadable, undefined);
|
||||
assert.deepEqual(f.bot.tokens, []);
|
||||
});
|
||||
|
||||
test('CB4 delete with an unreadable organization file says so, never an empty copy list', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f } = await live(t, w);
|
||||
const revision = (await listOrgs(f.ctx)).find((o) => o.id === f.id).revision;
|
||||
// The file fails the private read when the copies step reads it (busStop runs just before).
|
||||
const busStop = async () => { chmodSync(f.file, 0o644); return { stopped: true, reason: 'stopped' }; };
|
||||
const out = await orgService.deleteOrg(f.ctx, f.id, { confirm: f.id, ifRevision: revision, busStop });
|
||||
assert.equal(out.copiesUnreadable, true);
|
||||
assert.deepEqual(out.copies, []);
|
||||
assert.match(out.note, /copiesUnreadable/);
|
||||
});
|
||||
|
||||
test('CB4 an owner update cannot change the repository of a project a copy binding still grants', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f } = await live(t, w);
|
||||
const { revision } = (await listOrgs(f.ctx)).find((o) => o.id === f.id);
|
||||
const doc = { ...JSON.parse(readFileSync(f.file, 'utf8')), human: f.ctx.principal.sub };
|
||||
const moved = structuredClone(doc); moved.projects.stack.remote = `${w.api.url}/elsewhere/stack`;
|
||||
await assert.rejects(orgService.updateOrg(f.ctx, f.id, moved, { ifRevision: revision }), refuses('project-repo-bound'));
|
||||
// Removed (with another project left, so the document itself is valid).
|
||||
const dropped = structuredClone(doc);
|
||||
dropped.projects.other = { ...structuredClone(doc.projects.stack), root: join(w.data, 'org-runtime', f.id, 'projects', 'other'), remote: `${w.api.url}/owner/other` };
|
||||
delete dropped.projects.other.agents; delete dropped.projects.stack;
|
||||
await assert.rejects(orgService.updateOrg(f.ctx, f.id, dropped, { ifRevision: revision }), refuses('project-repo-bound'));
|
||||
// An update that leaves the bound project's repository as it is still goes through.
|
||||
const other = structuredClone(doc); other.vars = { ...other.vars, 'bus.tester': false };
|
||||
await orgService.updateOrg(f.ctx, f.id, other, { ifRevision: revision });
|
||||
});
|
||||
|
||||
test('CB4 a remote step whose bound server cannot be read, or whose record names another incarnation, fails and the copy stays fenced', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f } = await live(t, w);
|
||||
const rec = f.rec();
|
||||
const base = { grant: f.grant, read: (x) => f.writer.read(x).document };
|
||||
// The bound project is not in the file any more: no origin to revoke against, never not-managed.
|
||||
const gone = giteaCopyRemote({ ...base, read: (x) => { const d = structuredClone(f.writer.read(x).document); delete d.projects.stack; return d; } });
|
||||
assert.equal(await gone(f.id, f.name, rec, 'gitea'), 'failed');
|
||||
// Another incarnation, or none: the admin slot is never asked to delete.
|
||||
const sent = f.api.calls().length;
|
||||
for (const incarnation of [() => 'f'.repeat(32), () => null, () => { throw new Error('unreadable'); }])
|
||||
assert.equal(await giteaCopyRemote({ ...base, incarnation })(f.id, f.name, rec, 'gitea'), 'failed');
|
||||
assert.equal(f.bot.tokens.length, 1);
|
||||
assert.equal(f.api.calls().length, sent, 'no request reached the server');
|
||||
// Through the revoker: the record stays revoking with the remote step failed, and fenced.
|
||||
const revoker = copyRevoker({ writer: f.writer, gate: f.gate, credentials: f.credentials, stopRuns: (...a) => f.stopRuns(...a),
|
||||
remote: gone, definitions: () => f.snapshot.definitions ?? {} });
|
||||
const done = await revoker.run(f.id, f.name, { cause: 'revoke', actor: 'jason', owner: true });
|
||||
assert.equal(done.state, 'revoking');
|
||||
assert.deepEqual(f.rec().revoke.failed, ['remote']);
|
||||
assert.equal(f.bot.tokens.length, 1);
|
||||
await assert.rejects(f.prepare(), refuses('credential-revoke-failed'));
|
||||
});
|
||||
|
||||
// Build review P3-4: when the begin write refuses before any change, only an owner's verb on a copy
|
||||
// that was open reopens it (to its parked proof); a loss the bus found, or a copy already closed,
|
||||
// stays closed.
|
||||
test('CB4 a refused begin reopens the gate only for an owner verb on an open copy; a loss the bus found stays closed', async () => {
|
||||
const run = async ({ cause, closed }) => {
|
||||
const calls = [];
|
||||
const gate = { closed: () => closed, close: (b, i, c) => calls.push(['close', i, c ?? null]), open: (b, i, parked) => calls.push(['open', i, parked === true]), drain: async () => {} };
|
||||
const writer = { read: () => ({ document: { copyBindings: {} } }), revokeCopyBinding: () => { throw new BusError('org-mismatch'); } };
|
||||
const stopRuns = async () => { throw new Error('stop must not run'); };
|
||||
const revoker = copyRevoker({ writer, gate, credentials: { discardCopy: () => { throw new Error('slot must not go'); } }, stopRuns });
|
||||
await assert.rejects(revoker.run('acme', 'rev', { cause, owner: cause === 'revoke' }), { code: 'org-mismatch' });
|
||||
return calls;
|
||||
};
|
||||
for (const cause of ['expired', 'unavailable', 'retire', 'shadowed', 'upstream'])
|
||||
assert.deepEqual(await run({ cause, closed: false }), [['close', 'rev', null]], cause);
|
||||
assert.deepEqual(await run({ cause: 'revoke', closed: true }), [['close', 'rev', null]]);
|
||||
assert.deepEqual(await run({ cause: 'revoke', closed: false }), [['close', 'rev', null], ['open', 'rev', true]]);
|
||||
});
|
||||
|
||||
test('CB4 a second organization launches and calls throughout the first one\'s revoke, archive and delete', async (t) => {
|
||||
const w = await world(t);
|
||||
const { f: demo, r: dr } = await live(t, w);
|
||||
|
||||
+17
-9
@@ -1295,11 +1295,16 @@ organization bus, moves its local organization document, history, runtime and
|
||||
organization-specific secret directories into the instance's private
|
||||
`deleted/` directory, and removes it from the registry. The ID may then be
|
||||
reused. The deleted folder contains a receipt; `deleted/deleted.log` is an
|
||||
append-only index. Remote repositories and remote credentials are untouched.
|
||||
Purging deleted data and revoking remote credentials are separate operations and
|
||||
are not provided by this command. The receipt's `copies` lists each agent copy
|
||||
that held a service grant, with its slot `quarantined` and its remote outcome
|
||||
`not-attempted`: the copy's token at the service still has to be revoked there.
|
||||
append-only index. Remote repositories are untouched. Purging deleted data is a
|
||||
separate operation and is not provided by this command. The receipt's `copies`
|
||||
lists each agent copy that held a service grant (#1873 CB4), with its slot
|
||||
`quarantined` and its remote outcome: `revoked` or `absent` when the
|
||||
organization's managed Gitea admin slot deleted the copy's token, `not-managed`
|
||||
when the organization has no admin slot on that server, and `failed` when the
|
||||
service could not be reached or refused. For any outcome but `revoked` and
|
||||
`absent`, the copy's token at the service still has to be revoked there. When
|
||||
the organization file could not be read, the receipt says `copiesUnreadable:
|
||||
true` instead of listing copies; check the service for copy tokens by hand.
|
||||
|
||||
If deletion stops, the organization remains in `deleting` state and cannot
|
||||
start a bus. Inspect the reported receipt and correct the refusal, then resume
|
||||
@@ -1313,10 +1318,13 @@ require the source and deleted folder to be on the same filesystem.
|
||||
`bus stop` they refuse inside an agent run (exit 3, agent-guard #1873), after
|
||||
their usage checks and before any config is read. After the bus stops,
|
||||
`mosaic org archive` discards every agent copy's credential (#1873 CB4): each
|
||||
copy's binding becomes an unbound tombstone and its slot file is removed. The
|
||||
receipt's `copies` lists each one; its remote outcome is `not-attempted`, so a
|
||||
copy's token at the service still has to be revoked there. A restore brings no
|
||||
copy credential back.
|
||||
copy's slot file is removed and its token is revoked at the service through
|
||||
the organization's managed Gitea admin slot. The receipt's `copies` lists each
|
||||
one with its state and remote outcome. A copy whose token was revoked, was
|
||||
already gone, or is on a server the organization does not manage (`not-managed`)
|
||||
becomes an unbound tombstone. A copy whose remote revoke `failed` stays
|
||||
`revoking`, fenced, and the bus finishes the revoke when it starts after a
|
||||
restore. A restore brings no copy credential back.
|
||||
|
||||
`mosaic org restore ID --if-revision N` makes the organization active and starts
|
||||
its bus, as `mosaic bus start ID --dev-local-owner --detach` does (E1, P1-4). It
|
||||
|
||||
@@ -77,6 +77,12 @@ export async function busStop(ctx, id) {
|
||||
checkedState(org);
|
||||
return stopHost(org.root, { expectedBusiness: id });
|
||||
}
|
||||
// #1873 CB4: the remote step archive and delete give each copy's grant: the organization's managed
|
||||
// Gitea adapter (its admin slot), the one the bus's own revoke uses. Loaded only when needed.
|
||||
export async function copyRemoteFor(ctx) {
|
||||
const { managedCopyRemote } = await import('../../bus/src/copy-revoke.mjs');
|
||||
return managedCopyRemote({ configDir: ctx.configDir, dataRoot: ctx.dataRoot });
|
||||
}
|
||||
// Deletion refuses stale or ambiguous host state and any listening socket.
|
||||
export async function busStopForDelete(ctx, id) {
|
||||
const org = await target(ctx, id, true);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { installedSystemTypes } from '../../plugins/src/system-types.mjs';
|
||||
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
|
||||
import { loadSystem } from './config.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
import { busStart, busStopForDelete } from './org-bus.mjs';
|
||||
import { busStart, busStopForDelete, copyRemoteFor } from './org-bus.mjs';
|
||||
import { assertBuildStart, assertDevInstance } from './deployment.mjs';
|
||||
import { consoleBusRestart } from './org-console.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
@@ -52,7 +52,7 @@ export async function orgCommand(args, io, deps = {}) {
|
||||
else if (words[0] === 'lock' && ['show', 'clear'].includes(words[1]) && words.length === 3) { allowed([], []); result = await orgLock(ctx, words[2], { clear: words[1] === 'clear' }); }
|
||||
else if (words[0] === 'create' && words.length === 2) { allowed([], ['--from']); result = await createOrg(ctx, words[1], await document(need('--from'))); }
|
||||
else if (words[0] === 'update' && words.length === 2) { allowed([], ['--from', '--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await updateOrg(ctx, words[1], await document(need('--from')), { ifRevision: Number(r) }); }
|
||||
else if (words[0] === 'archive' && words.length === 2) result = await archiveOrg(ctx, words[1], { ...lifecycle, busStop: busStopForDelete });
|
||||
else if (words[0] === 'archive' && words.length === 2) result = await archiveOrg(ctx, words[1], { ...lifecycle, busStop: busStopForDelete, copyRemote: await (deps.copyRemoteFor ?? copyRemoteFor)(ctx) });
|
||||
else if (words[0] === 'restore' && words.length === 2) {
|
||||
const startBus = !p.flags.has('--no-bus');
|
||||
if (startBus) restoreBusCheck(system, io.env);
|
||||
@@ -70,7 +70,7 @@ export async function orgCommand(args, io, deps = {}) {
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (words[0] === 'delete' && words.length === 2) result = await deleteOrg(ctx, words[1], { busStop: busStopForDelete, ...lifecycle });
|
||||
else if (words[0] === 'delete' && words.length === 2) result = await deleteOrg(ctx, words[1], { busStop: busStopForDelete, copyRemote: await (deps.copyRemoteFor ?? copyRemoteFor)(ctx), ...lifecycle });
|
||||
// U1 (#1873): the F3 bus control from a terminal, through the running Console's own route, which
|
||||
// checks the owner and writes the action trail (org-console.mjs consoleBusRestart).
|
||||
else if (words[0] === 'reload' && words.length === 2) { allowed([], []); result = await consoleBusRestart(ctx, words[1], deps.consoleRequest ?? {}); }
|
||||
|
||||
@@ -2,8 +2,8 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fixture, tmp } from './helpers.mjs';
|
||||
import { busStart, busStop, busStopForDelete } from '../src/org-bus.mjs';
|
||||
import { fixture, orgFixture, tmp } from './helpers.mjs';
|
||||
import { busStart, busStop, busStopForDelete, copyRemoteFor } from '../src/org-bus.mjs';
|
||||
import { createOrg, deleteOrg, templateRender } from '../../organization/src/org-service.mjs';
|
||||
|
||||
test('deletion stops the recorded running bus before moving its runtime', { timeout: 60000 }, async t => {
|
||||
@@ -19,3 +19,17 @@ test('deletion stops the recorded running bus before moving its runtime', { time
|
||||
assert.equal(receipt.bus.stopped, true); assert.equal(receipt.bus.pid, started.pid);
|
||||
assert.ok(receipt.moves.some(move => move.from === started.root));
|
||||
});
|
||||
|
||||
// #1873 CB4: archive and delete from the CLI get the organization's managed Gitea remote step. With no
|
||||
// admin slot and no bound repository it never claims a revoke: a malformed grant fails, another
|
||||
// service is not managed, and a grant whose bound projects have no server fails (the copy stays fenced).
|
||||
test('CB4 copyRemoteFor gives the managed remote step, which never claims a revoke it did not make', async t => {
|
||||
const { root, f } = await orgFixture(t, 'a');
|
||||
const ctx = { configDir: join(root, '.config', 'mosaic-test'), dataRoot: f.dataRoot, instance: 'test' };
|
||||
const remote = await copyRemoteFor(ctx);
|
||||
assert.equal(typeof remote, 'function');
|
||||
const record = (grantId) => ({ orgIncarnation: 'x', copyId: 'c', services: { gitea: { grantId, resources: { projects: ['work'] } } } });
|
||||
assert.equal(await remote('a', 'rev', record('nope'), 'gitea'), 'failed');
|
||||
assert.equal(await remote('a', 'rev', record('gitea:1:2'), 'other'), 'not-managed');
|
||||
assert.equal(await remote('a', 'rev', record('gitea:1:2'), 'gitea'), 'failed');
|
||||
});
|
||||
@@ -195,6 +195,13 @@ export async function updateOrg(ctx, id, input, { ifRevision } = {}) {
|
||||
authorize(ctx); return withRegistry(ctx, async registry => {
|
||||
const current = await readCurrentUnlocked(ctx, id); if (!Number.isSafeInteger(ifRevision) || ifRevision !== current.revision) fail('revision-conflict', 'organization revision changed');
|
||||
const doc = normalizeDoc(ctx, id, input, current.doc); if (doc.human !== ctx.principal.sub) fail('org-forbidden', 'document human must name the local owner'); await checked(doc);
|
||||
// #1873 CB4 (F7): a project a copy binding still grants keeps its repository: its remote, its git
|
||||
// binding and its presence change only after that binding is revoked, as in project repo set.
|
||||
for (const [project, entry] of Object.entries(current.doc.projects ?? {})) {
|
||||
const bound = projectBindings(current.doc, project); if (!bound.length) continue;
|
||||
const next = doc.projects?.[project];
|
||||
if (!next || canonicalJson(next.remote ?? null) !== canonicalJson(entry.remote ?? null) || canonicalJson(next.git ?? null) !== canonicalJson(entry.git ?? null)) fail(REPO_BOUND, repoBoundMessage(project, bound));
|
||||
}
|
||||
const raw = encode(doc), revision = current.revision + 1; await atomic(join(ctx.configDir, 'businesses', `${id}.json`), raw); registry.orgs[id].digest = digest(raw); registry.orgs[id].revision = revision;
|
||||
return receipt(current.root, 'org.update', id, ctx.principal, { root: current.root, revision });
|
||||
});
|
||||
@@ -306,12 +313,23 @@ export async function projectRepoShow(ctx, id, project) { return showSetup(ctx,
|
||||
// The bus has stopped, so no run of a copy is left; each binding takes the same two writes as the
|
||||
// bus's revoke (`revoking` with an op id, then the tombstone), and a rerun joins the op a crash left.
|
||||
// The slot file goes from the organization's agent store. The remote step is the caller's
|
||||
// `copyRemote(id, instance, record, service)` (the CLI or Console wires the managed adapter, as it
|
||||
// wires busStop); without one it is recorded `not-attempted`, never claimed. A slot that cannot be
|
||||
// removed keeps the record revoking and refuses the archive. Returns the writes and one line per copy.
|
||||
// `copyRemote(id, instance, record, service)` (the CLI and Console wire the managed adapter, as they
|
||||
// wire busStop). A remote step that failed, or that was not attempted because no adapter was given,
|
||||
// keeps the record revoking (`failed: ['remote']`): the copy stays fenced, and the bus's resume at its
|
||||
// next start, after a restore, runs the remote step again. The copy's line says which, never
|
||||
// claiming a revoke. A slot that cannot be removed also keeps the record revoking, and refuses the
|
||||
// archive. Returns the writes and one line per copy.
|
||||
const tombstoned = r => r?.state === 'unbound' && r.revoke?.done === true;
|
||||
async function discardCopySlot(ctx, id, instance, service) {
|
||||
const file = join(ctx.configDir, 'secrets', 'agents', id, instance, service);
|
||||
// Each parent must be the stack user's own private directory, or missing (no slot below it).
|
||||
let parent = join(ctx.configDir, 'secrets');
|
||||
for (const part of ['', 'agents', id, instance]) {
|
||||
if (part) parent = join(parent, part);
|
||||
let st;
|
||||
try { st = await fs.lstat(parent); } catch (e) { if (e.code === 'ENOENT') return 'absent'; return 'failed'; }
|
||||
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) return 'failed';
|
||||
}
|
||||
const file = join(parent, service);
|
||||
let removed = false;
|
||||
for (const path of [file + '.tmp', file]) {
|
||||
let st;
|
||||
@@ -348,7 +366,9 @@ async function discardCopies(ctx, id, cause, copyRemote) {
|
||||
try { remote[service] = await copyRemote(id, instance, record, service); } catch { remote[service] = 'failed'; }
|
||||
if (!['revoked', 'absent', 'not-managed', 'failed'].includes(remote[service])) remote[service] = 'failed';
|
||||
}
|
||||
const done = writer.revokeCopyBinding(id, { phase: 'finish', instance, opId: begun.op, steps: { stop: 'confirmed', slot, remote } }, { actor: 'bus', owner: true });
|
||||
// The bus is stopped (busStop answered before this), so the copy has no run left to stop.
|
||||
const steps = Object.fromEntries(Object.entries(remote).map(([k, v]) => [k, v === 'not-attempted' ? 'failed' : v]));
|
||||
const done = writer.revokeCopyBinding(id, { phase: 'finish', instance, opId: begun.op, steps: { stop: 'confirmed', slot, remote: steps } }, { actor: 'bus', owner: true });
|
||||
if (done.revision !== undefined) writes++;
|
||||
copies.push({ agent: instance, state: done.state, slot, remote });
|
||||
}
|
||||
@@ -436,10 +456,11 @@ async function deleteParents(base, relative) {
|
||||
// #1873 CB4: the deletion quarantine moves every copy slot with the agent store, and the receipt
|
||||
// lists each copy that held a service grant with its remote outcome. With no `copyRemote` from the
|
||||
// caller that outcome is `not-attempted`: the receipt never claims a token was revoked at the service.
|
||||
const DELETE_NOTE = 'Purging deleted data is out of scope. Remote-side revocation happened only where copies lists a copy with remote revoked.';
|
||||
// An organization file that cannot be read is said so (`copiesUnreadable`), never an empty list.
|
||||
const DELETE_NOTE = 'Purging deleted data is out of scope. Remote-side revocation happened only where copies lists a copy with remote revoked. When copiesUnreadable is true the organization file could not be read, so any copy grant still live at its service must be revoked there by hand.';
|
||||
async function deleteCopies(ctx, id, copyRemote) {
|
||||
let doc;
|
||||
try { doc = JSON.parse(await privateRead(join(ctx.configDir, 'businesses', `${id}.json`))); } catch { return []; }
|
||||
try { doc = JSON.parse(await privateRead(join(ctx.configDir, 'businesses', `${id}.json`))); } catch { return { copies: [], unreadable: true }; }
|
||||
const out = [];
|
||||
for (const instance of Object.keys(doc?.copyBindings ?? {}).sort()) {
|
||||
const record = doc.copyBindings[instance];
|
||||
@@ -454,7 +475,7 @@ async function deleteCopies(ctx, id, copyRemote) {
|
||||
out.push({ agent: instance, service, slot: 'quarantined', remote });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
return { copies: out, unreadable: false };
|
||||
}
|
||||
export async function deleteOrg(ctx, id, { confirm, ifRevision, busStop, copyRemote = null } = {}) {
|
||||
authorize(ctx); checkId(id);
|
||||
@@ -490,7 +511,8 @@ export async function deleteOrg(ctx, id, { confirm, ifRevision, busStop, copyRem
|
||||
}
|
||||
if (!Array.isArray(result.copies)) {
|
||||
phase = 'copies';
|
||||
result.copies = await deleteCopies(ctx, id, copyRemote);
|
||||
const found = await deleteCopies(ctx, id, copyRemote);
|
||||
result.copies = found.copies; if (found.unreadable) result.copiesUnreadable = true;
|
||||
await atomic(receiptFile, encode(result));
|
||||
}
|
||||
phase = 'inspect';
|
||||
|
||||
@@ -464,16 +464,18 @@ async function route(req, res, { path, search, org, admin, principal, write, log
|
||||
if ((kind === 'archive' || kind === 'restore') && !sub && write) {
|
||||
const body = await readBody(req); strictKeys(body, ['revision']);
|
||||
if (!Number.isSafeInteger(body.revision) || body.revision < 1) return json(res, 422, { error: 'invalid', step: kind, message: 'A valid revision is required.' });
|
||||
let busStop;
|
||||
let busStop, copyRemote = null;
|
||||
if (kind === 'archive') {
|
||||
const bus = await need(admin, 'bus', res, kind); if (!bus) return;
|
||||
if (typeof bus.busStopForDelete !== 'function') return json(res, 503, { error: 'not-configured', step: kind, message: 'bus control cannot stop a bus for archive on this Console yet' });
|
||||
busStop = bus.busStopForDelete;
|
||||
// #1873 CB4: archive revokes each copy's grant through the organization's managed adapter.
|
||||
if (typeof bus.copyRemoteFor === 'function') copyRemote = await bus.copyRemoteFor(ctx);
|
||||
}
|
||||
try {
|
||||
const fn = kind === 'archive' ? service.archiveOrg : service.restoreOrg;
|
||||
if (typeof fn !== 'function') return json(res, 503, { error: 'not-configured', step: kind });
|
||||
const receipt = await fn(ctx, id, { ifRevision: body.revision, ...(busStop ? { busStop } : {}) });
|
||||
const receipt = await fn(ctx, id, { ifRevision: body.revision, ...(busStop ? { busStop } : {}), ...(copyRemote ? { copyRemote } : {}) });
|
||||
const done = { outcome: 'done', step: kind, receipt: forReceipt(ctx, receipt), verb: verbFor(kind, id, { revision: body.revision }) };
|
||||
if (kind === 'archive') return json(res, 200, done);
|
||||
return json(res, 200, { ...done, bus: await restoreBus({ id, ctx, admin, log, trail, by }) });
|
||||
@@ -485,7 +487,8 @@ async function route(req, res, { path, search, org, admin, principal, write, log
|
||||
const bus = await need(admin, 'bus', res, 'delete'); if (!bus) return;
|
||||
if (typeof bus.busStopForDelete !== 'function') return json(res, 503, { error: 'not-configured', step: 'delete', message: 'bus control cannot stop a bus for deletion on this Console yet' });
|
||||
try {
|
||||
const receipt = await service.deleteOrg(ctx, id, { busStop: bus.busStopForDelete, confirm: body.confirm, ifRevision: body.revision });
|
||||
const copyRemote = typeof bus.copyRemoteFor === 'function' ? await bus.copyRemoteFor(ctx) : null;
|
||||
const receipt = await service.deleteOrg(ctx, id, { busStop: bus.busStopForDelete, confirm: body.confirm, ifRevision: body.revision, ...(copyRemote ? { copyRemote } : {}) });
|
||||
return json(res, 200, { outcome: 'done', step: 'delete', receipt: forReceipt(ctx, receipt), verb: verbFor('delete', id, { revision: body.revision }) });
|
||||
} catch (e) { if (e.code === 'revision-conflict') return refusal(res, e, { step: 'delete', id }); return json(res, 409, { error: e.code || 'delete-refused', step: e.deleteStep || 'delete', message: plainMessage(ctx, e.message || 'Deletion stopped; inspect the receipt and resume with the CLI.'), receipt: forReceipt(ctx, e.deleteReceipt), verb: verbFor('delete', id, { revision: body.revision }) }); }
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { actionTrail } from '../src/action-trail.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
class OrgError extends Error { constructor(code, message = code) { super(message); this.code = code; } }
|
||||
const COPY_REMOTE = async () => 'revoked';
|
||||
const template = id => ({ businessVersion: 1, id, human: 'local-dev-owner', arbiters: { delivery: 'chief', technical: 'chief' }, roles: { chief: { definition: 'chief-of-staff' }, assistant: { definition: 'assistant' } }, projects: { work: { root: `/__mosaic_org__/${id}/projects/work` } } });
|
||||
|
||||
// In-memory C1/C2/C3 fakes with the PLAN.md signatures. `fail` injects errors.
|
||||
@@ -40,6 +41,8 @@ function fakes() {
|
||||
busStatus: async (ctx, id) => { get(id); return { org: id, instance: 'test', root: `/data/org-runtime/${id}`, socket: '/run/s', healthy: running === id, pid: running === id ? 4242 : null }; },
|
||||
busStart: async (ctx, id, opts) => { calls.push(['busStart', id, opts]); running = id; return { org: id, pid: 4242, socket: '/run/s', root: `/data/org-runtime/${id}` }; },
|
||||
busStopForDelete: async (ctx, id) => { calls.push(['busStopForDelete', id]); running = null; return { stopped: false, reason: 'no host runs' }; },
|
||||
// #1873 CB4: the managed remote step archive and delete hand to the organization service.
|
||||
copyRemoteFor: async ctx => { assert.equal(ctx.configDir, '/cfg'); return COPY_REMOTE; },
|
||||
busRestart: async (ctx, id) => { calls.push(['busRestart', id]); running = id; return { org: id, pid: 4343, socket: '/run/s', root: `/data/org-runtime/${id}` }; },
|
||||
busStop: async (ctx, id) => { calls.push(['busStop', id]); const was = running === id; running = null; return was ? { stopped: true, business: id, pid: 4242 } : { stopped: false, reason: 'no host runs' }; },
|
||||
};
|
||||
@@ -589,6 +592,14 @@ test('organization delete route enforces write fence and validates confirmation
|
||||
assert.equal((await c.call('POST', '/api/orgs/acme/delete', { confirm: 'acme', revision: 1, extra: true })).status, 400);
|
||||
});
|
||||
|
||||
test('CB4 delete hands the managed copy remote to the organization service', async t => {
|
||||
const c = await console_(t);
|
||||
let seen = null;
|
||||
c.service.deleteOrg = async (ctx, id, opts) => { seen = opts; return { org: id, bus: { stopped: false, reason: 'no host runs' }, moves: [], copies: [] }; };
|
||||
const r = await c.call('POST', '/api/orgs/acme/delete', { confirm: 'acme', revision: 1 });
|
||||
assert.equal(r.status, 200); assert.equal(seen.copyRemote, COPY_REMOTE); assert.equal(typeof seen.busStop, 'function');
|
||||
});
|
||||
|
||||
test('delete refusal exposes progress and redacts receipt paths', async t => {
|
||||
const c = await console_(t);
|
||||
c.service.deleteOrg = async () => { const e = new OrgError('org-mismatch', 'unsafe path /data/org-runtime/acme'); e.deleteStep = 'inspect'; e.deleteReceipt = { bus: { stopped: false, reason: 'no host runs' }, moves: [], destination: '/data/deleted/acme-x' }; throw e; };
|
||||
@@ -599,7 +610,7 @@ test('delete refusal exposes progress and redacts receipt paths', async t => {
|
||||
|
||||
test('archive and restore use fenced owner routes with strict revisions and injected bus stop', async t => {
|
||||
const c = await console_(t);
|
||||
c.service.archiveOrg = async (ctx, id, opts) => { assert.equal(typeof opts.busStop, 'function'); c.calls.push(['archiveOrg', id, opts.ifRevision]); return { org: id, revision: 2 }; };
|
||||
c.service.archiveOrg = async (ctx, id, opts) => { assert.equal(typeof opts.busStop, 'function'); assert.equal(opts.copyRemote, COPY_REMOTE); c.calls.push(['archiveOrg', id, opts.ifRevision]); return { org: id, revision: 2 }; };
|
||||
c.service.restoreOrg = async (ctx, id, opts) => { c.calls.push(['restoreOrg', id, opts.ifRevision]); return { org: id, revision: 3 }; };
|
||||
for (const step of ['archive', 'restore']) {
|
||||
const path = `/api/orgs/acme/${step}`;
|
||||
|
||||
Reference in new issue
Block a user