Files
stack/packages/cli/tests/access.test.mjs
jason.woltje 4c8bf9ca4e fix(organization,cli): refusals name the file by label, never by host path
PR #1916 rule: a refusal names the field or file, never a config value
or a host path. readJsonFile's three refusals and parseJsonFileText now
name the file by its `what` label only. Callers whose label alone is
ambiguous add a name that already passed a strict pattern (business id,
role name, org id, turn id, project key) or a fixed path under the data
root. Exit codes, refusal codes and check order are unchanged.

organization: util, access, project, config-command, library-seats,
org-service deleteOrg receipt, agent-levels regex; T1 files role.mjs and
organization-config.mjs change only the readJsonFile label.
cli: access lock, accounts owner, host state/lock, notifier journal,
notify-config, run-evidence, dev-fixture, git-socket, org-bus, org-console.
discord: turn record label names the turn id.

Sentinel tests (sk-test-NOT-A-SECRET-1234 as a path component) cover each
changed refusal and fail on BASE; old path pins updated.
2026-11-11 01:04:15 -06:00

522 lines
25 KiB
JavaScript

// `mosaic access` (FED R5, #414). Every write goes to a scratch config dir and a
// scratch data root. The success paths run on a stand-in human CLI (fakeCli).
// The one test that runs the real human-cli.mjs does it on a scratch bus from
// agent ancestry, so the bus must refuse, and nothing is written.
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import * as nodeFs from "node:fs";
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { startBroker } from "../../bus/src/runtime.mjs";
import { accessCommand, accessContextFor, withLock } from "../src/access.mjs";
import { CliError } from "../src/errors.mjs";
import { socketPath } from "../src/config.mjs";
import { HUMAN_CLI, humanTransport } from "../src/transport.mjs";
import { fakeCli, humanEnv, io, tmp, fixture } from "./helpers.mjs";
import { systemFor, writeJson } from "../../business/tests/helpers.mjs";
const PROJECT = "3f1c2a64-0d8e-4b7a-9c55-1a2b3c4d5e6f";
const ROWS = [{ id: PROJECT, name: "hub", repo: null }];
const CONTEXT = {
providers: ["authentik"],
peers: [],
businesses: { acme: { human: "jason", instances: ["coder"] } },
ssoSubjects: [{ provider: "authentik", sub: "jason-sub" }],
};
const DOC = {
accessVersion: 1,
revision: 1,
users: [{ id: "alice", home: "local", subjects: [{ provider: "authentik", sub: "alice-sub" }] }],
grants: [],
};
// The stand-in human CLI. It answers project.list with ROWS, or refuses like a bus
// that sees no human shell when FAKE_MODE is "refuse".
const BODY = `
import { readFileSync, writeFileSync } from "node:fs";
const input = JSON.parse(readFileSync(0, "utf8"));
writeFileSync(process.env.FAKE_LOG, JSON.stringify(input));
if (process.env.FAKE_MODE === "refuse") { process.stderr.write("human-required\\n"); process.exit(3); }
process.stdout.write(JSON.stringify(${JSON.stringify(ROWS)}));
`;
function setup(t, { mode = "ok", doc = DOC } = {}) {
const root = tmp(t, "mosaic-access-");
const config = join(root, "config");
mkdirSync(config, { recursive: true });
const dataRoot = join(root, "data");
const log = join(root, "fake.log");
const cli = fakeCli(root, BODY);
const env = humanEnv({ FAKE_LOG: log, FAKE_MODE: mode });
const file = join(config, "access.json");
if (doc) writeFileSync(file, JSON.stringify(doc, null, 2), { mode: 0o600 });
const deps = {
system: { dataRoot },
configDir: config,
contextFor: () => CONTEXT,
now: () => new Date("2026-10-20T20:00:00Z"),
transport: (business) => humanTransport({ socket: join(root, "bus.sock"), business, env, cli, timeoutMs: 5000 }),
};
const receipts = () => (existsSync(join(dataRoot, "state", "access-log.jsonl")) ? readFileSync(join(dataRoot, "state", "access-log.jsonl"), "utf8") : "");
return { root, config, file, deps, env, log, receipts, read: () => JSON.parse(readFileSync(file, "utf8")) };
}
const GRANT_BASE = ["grant", "--business", "acme", "--user", "alice"];
const GRANT = [...GRANT_BASE, "--projects", PROJECT, "--instances", "coder", "--level", "collaborator"];
async function run(args, deps, env = humanEnv()) {
const i = io(env);
await accessCommand(args, i, deps);
return i.out.text;
}
async function refusal(promise, pattern) {
await assert.rejects(promise, (e) => e instanceof CliError && pattern.test(e.message));
return promise.catch((e) => e);
}
test("list prints the revision and the users", async (t) => {
const s = setup(t);
const out = await run(["list", "--business", "acme"], s.deps);
assert.match(out, /access file revision 1/);
assert.match(out, /user alice home local/);
});
test("grant writes one revision up, a receipt line, and the id in the file", async (t) => {
const s = setup(t);
const out = await run([...GRANT, "--revision", "1"], s.deps);
assert.match(out, /access grant: g-[0-9a-f]{8} at acme, revision 2/);
const doc = s.read();
assert.equal(doc.revision, 2);
assert.equal(doc.grants.length, 1);
assert.deepEqual(doc.grants[0].projects, [PROJECT]);
const [line] = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
assert.equal(line.verb, "grant");
assert.equal(line.revisionBefore, 1);
assert.equal(line.revisionAfter, 2);
assert.equal(line.actor, "human");
});
test("a viewer grant writes the viewer level, one revision up", async (t) => {
const s = setup(t);
await run([...GRANT_BASE, "--projects", PROJECT, "--instances", "coder", "--level", "viewer", "--revision", "1"], s.deps);
const doc = s.read();
assert.equal(doc.revision, 2);
assert.equal(doc.grants[0].level, "viewer");
});
test("a second write appends a second receipt and leaves the first intact", async (t) => {
const s = setup(t);
await run([...GRANT, "--revision", "1"], s.deps);
await run(["revoke", "--business", "acme", "--user", "alice", "--revision", "2"], s.deps);
const lines = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
assert.deepEqual(lines.map((l) => l.verb), ["grant", "revoke"]);
assert.equal(lines[0].revisionAfter, 2);
});
test("a stale revision refuses with revision-conflict and writes nothing", async (t) => {
const s = setup(t);
await run([...GRANT, "--revision", "1"], s.deps);
const before = readFileSync(s.file, "utf8");
await refusal(run([...GRANT, "--revision", "1"], s.deps), /revision-conflict/);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts().trim().split("\n").length, 1);
});
test("a live holder's lock refuses and leaves the file and the lock alone", async (t) => {
const s = setup(t);
writeFileSync(`${s.file}.lock`, `${process.pid}\n`, { mode: 0o600 });
const before = readFileSync(s.file, "utf8");
await refusal(run([...GRANT, "--revision", "1"], s.deps), /being written by another command/);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(readFileSync(`${s.file}.lock`, "utf8"), `${process.pid}\n`);
assert.equal(readdirSync(s.config).filter((f) => f.startsWith(".access-")).length, 0);
});
test("an empty or unreadable lock refuses, since its holder may still be writing", async (t) => {
const s = setup(t);
writeFileSync(`${s.file}.lock`, "");
await refusal(run([...GRANT, "--revision", "1"], s.deps), /is unreadable or not a pid/);
writeFileSync(`${s.file}.lock`, "not-a-pid\n");
await refusal(run([...GRANT, "--revision", "1"], s.deps), /is unreadable or not a pid/);
assert.equal(existsSync(`${s.file}.lock`), true);
});
test("a lock left by a dead pid refuses, names the pid, and is not reclaimed", async (t) => {
const s = setup(t);
const dead = spawnSync(process.execPath, ["-e", ""]);
writeFileSync(`${s.file}.lock`, `${dead.pid}\n`, { mode: 0o600 });
const before = readFileSync(s.file, "utf8");
await refusal(run([...GRANT, "--revision", "1"], s.deps), new RegExp(`^refused: access file lock is left by pid ${dead.pid}, which is not running`));
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(readFileSync(`${s.file}.lock`, "utf8"), `${dead.pid}\n`);
assert.equal(s.receipts(), "");
});
test("a failed rename leaves the old bytes, removes the temp file, and writes no receipt", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
const fs = { ...nodeFs, renameSync: () => { throw Object.assign(new Error("injected rename failure"), { code: "EXDEV" }); } };
await assert.rejects(run([...GRANT, "--revision", "1"], { ...s.deps, fs }), /injected rename failure/);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.deepEqual(readdirSync(s.config).filter((f) => f.startsWith(".access-")), []);
assert.equal(existsSync(`${s.file}.lock`), false);
assert.equal(s.receipts(), "");
});
test("a group-writable access file refuses with exit 3, names the file by label, never by path, and changes nothing", async (t) => {
const s = setup(t);
chmodSync(s.file, 0o664);
const before = readFileSync(s.file, "utf8");
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps), /must not be writable by group or other/);
assert.equal(err.exitCode, 3);
assert.equal(err.message, "refused: access file must not be writable by group or other (mode 664)");
assert.ok(!err.message.includes(s.file), err.message);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
assert.equal(existsSync(`${s.file}.lock`), false);
});
test("an agent or tester caller is refused before the context or the access file is read", async (t) => {
const s = setup(t);
chmodSync(s.file, 0o664);
const deps = { ...s.deps, contextFor: () => { throw new Error("context was read"); } };
const env = { ...humanEnv(), MOSAIC_RUN_ID: "run-1" };
await refusal(run([...GRANT, "--revision", "1"], deps, env), /human shell/);
await refusal(run(["list", "--business", "acme", "--tester"], deps), /not --tester/);
});
test("the lock is mode 0600 while held and removed after a refused write", async (t) => {
const s = setup(t);
let mode;
withLock(s.file, () => {
mode = statSync(`${s.file}.lock`).mode & 0o777;
});
assert.equal(mode, 0o600);
assert.equal(existsSync(`${s.file}.lock`), false);
await refusal(run([...GRANT, "--revision", "9"], s.deps), /revision-conflict/);
assert.equal(existsSync(`${s.file}.lock`), false);
});
test("a grant naming an unknown instance is refused before any write", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
const bad = [...GRANT_BASE, "--projects", PROJECT, "--instances", "nobody", "--level", "collaborator", "--revision", "1"];
await refusal(run(bad, s.deps), /refused: /);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
test("a project name resolves to its id at grant time", async (t) => {
const s = setup(t);
const args = [...GRANT_BASE, "--projects", "hub", "--instances", "coder", "--level", "collaborator", "--revision", "1"];
await run(args, s.deps);
assert.deepEqual(s.read().grants[0].projects, [PROJECT]);
});
test("an unknown project name refuses before any write", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
const args = [...GRANT_BASE, "--projects", "nope", "--instances", "coder", "--level", "collaborator", "--revision", "1"];
await refusal(run(args, s.deps), /project-not-found/);
assert.equal(readFileSync(s.file, "utf8"), before);
});
test("revoke removes the user's grants and leaves a revision behind", async (t) => {
const s = setup(t);
await run([...GRANT, "--revision", "1"], s.deps);
await run(["revoke", "--business", "acme", "--user", "alice", "--revision", "2"], s.deps);
const doc = s.read();
assert.equal(doc.revision, 3);
assert.deepEqual(doc.grants, []);
});
test("revoke of a user with no grant refuses and writes nothing", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
await refusal(run(["revoke", "--business", "acme", "--user", "alice", "--revision", "1"], s.deps), /no grant/);
assert.equal(readFileSync(s.file, "utf8"), before);
});
test("an agent caller is refused before the bus is asked", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
const env = { ...humanEnv(), MOSAIC_RUN_ID: "run-1" };
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps, env), /human shell/);
assert.equal(err.exitCode, 3);
assert.equal(existsSync(s.log), false);
assert.equal(readFileSync(s.file, "utf8"), before);
});
test("--tester is refused on every verb before the bus is asked", async (t) => {
const s = setup(t);
for (const verb of [["list"], [...GRANT, "--revision", "1"], ["revoke", "--user", "alice", "--revision", "1"]]) {
// GRANT already names the business; a repeated --business refuses (rename design r2).
const args = verb.includes("--business") ? [...verb, "--tester"] : [...verb, "--business", "acme", "--tester"];
const err = await refusal(run(args, s.deps), /not --tester/);
assert.equal(err.exitCode, 3);
}
assert.equal(existsSync(s.log), false);
});
test("a bus that refuses the human proof blocks the write", async (t) => {
const s = setup(t, { mode: "refuse" });
const before = readFileSync(s.file, "utf8");
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps), /human-required/);
assert.equal(err.exitCode, 3);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
test("the context reads console.sso subjects and the business files, and peers stay empty", async (t) => {
const root = tmp(t, "mosaic-access-ctx-");
const f = fixture(root, "acme");
const system = { console: { sso: { providers: [{ id: "authentik", subjects: [{ sub: "jason-sub" }] }] } } };
const ctx = accessContextFor(system, { MOSAIC_CONFIG: join(root, "config", "config.json") });
assert.deepEqual(ctx.providers, ["authentik"]);
assert.deepEqual(ctx.ssoSubjects, [{ provider: "authentik", sub: "jason-sub" }]);
assert.deepEqual(ctx.peers, []);
assert.equal(ctx.businesses.acme.human, "jason");
assert.ok(Array.isArray(ctx.businesses.acme.instances) && ctx.businesses.acme.instances.length > 0);
const bare = accessContextFor({}, { MOSAIC_CONFIG: join(root, "config", "config.json") });
assert.deepEqual(bare.ssoSubjects, []);
assert.deepEqual(bare.providers, []);
});
// Real human-cli.mjs on a scratch bus, run from agent ancestry. The proof must fail.
test("the real human-cli, run from agent ancestry, refuses and writes nothing", async (t) => {
const root = tmp(t, "mosaic-access-real-");
const f = fixture(root, "acme");
const config = join(root, "config");
const bus = await startBroker({
dataRoot: f.dataRoot,
businesses: { acme: { id: "acme", human: "jason", arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } },
organization: { dir: config, rolesDir: f.roles, system: systemFor(root) },
});
t.after(() => bus.close());
writeJson(join(config, "access.json"), DOC);
const file = join(config, "access.json");
const before = readFileSync(file, "utf8");
const agentEnv = { ...humanEnv(), CLAUDECODE: "1" };
const deps = {
system: { dataRoot: f.dataRoot },
configDir: config,
contextFor: () => CONTEXT,
transport: (business) => humanTransport({ socket: socketPath(f.dataRoot), business, env: agentEnv, cli: HUMAN_CLI, timeoutMs: 10000 }),
};
// The bus closes the socket on a failed human proof, so the client reports
// outcome-unknown (client.mjs:32-33), not human-required. That is a bus behavior
// outside R5 (BUILD.md). The test pins it, and nothing is written.
const err = await refusal(run([...GRANT, "--revision", "1"], deps), /^outcome-unknown/);
assert.equal(err.code, "outcome-unknown");
assert.equal(err.exitCode, 1);
assert.equal(readFileSync(file, "utf8"), before);
assert.equal(existsSync(join(f.dataRoot, "state", "access-log.jsonl")), false);
});
// Row 193c (#1705): `user add` and `user remove`. BUILD.md (agents/megavolt/work/queue-207) names
// each test T1-T9 and the mutant it kills.
const USER_ADD = (id, { subject = "authentik:bob-sub", home = "local", revision = "1" } = {}) => [
"user", "add", id, "--business", "acme", "--home", home, "--subject", subject, "--revision", revision,
];
const USER_REMOVE = (id, revision = "1") => ["user", "remove", id, "--business", "acme", "--revision", revision];
test("T1 user add writes the user one revision up, prints it and appends a user.add receipt", async (t) => {
const s = setup(t);
const out = await run(USER_ADD("bob", { subject: "authentik:bob:x" }), s.deps);
assert.match(out, /access user add: bob, revision 2/);
const doc = s.read();
assert.equal(doc.revision, 2);
assert.deepEqual(doc.users[1], { id: "bob", home: "local", subjects: [{ provider: "authentik", sub: "bob:x" }] });
assert.deepEqual(doc.users[0], DOC.users[0]);
const lines = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
assert.equal(lines.length, 1);
assert.deepEqual(lines[0], {
at: "2026-10-20T20:00:00.000Z", verb: "user.add", business: "acme", user: "bob", actor: "human",
revisionBefore: 1, revisionAfter: 2, grants: [],
});
});
test("T2 a bad user add refuses before any write", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
const usage = [
["user", "add", "bob", "--business", "acme", "--subject", "authentik:bob-sub", "--revision", "1"],
["user", "add", "bob", "--business", "acme", "--home", "local", "--revision", "1"],
USER_ADD("bob", { subject: "authentik" }),
USER_ADD("bob", { subject: ":bob-sub" }),
USER_ADD("bob", { subject: "authentik:" }),
];
for (const args of usage) {
const err = await refusal(run(args, s.deps), /user add needs --home and --subject PROVIDER:SUB/);
assert.equal(err.exitCode, 4, args.join(" "));
}
const checked = [
[USER_ADD("bob", { subject: "github:bob-sub" }), /provider must be a configured provider/],
[USER_ADD("alice", { subject: "authentik:alice-2" }), /duplicate user id/],
[USER_ADD("tester"), /a bus principal/],
[USER_ADD("jason"), /a business's human/],
[USER_ADD("bob", { subject: "authentik:jason-sub" }), /owner sign-in subject/],
[USER_ADD("bob", { subject: "authentik:alice-sub" }), /appears more than once/],
[USER_ADD("bob", { home: "peer-one" }), /home must be a configured peer/],
];
for (const [args, pattern] of checked) {
const err = await refusal(run(args, s.deps), pattern);
assert.equal(err.exitCode, 3, args.join(" "));
}
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
test("T3 user remove of a user with no grant drops them one revision up, with a user.remove receipt", async (t) => {
const s = setup(t);
const out = await run(USER_REMOVE("alice"), s.deps);
assert.match(out, /access user remove: alice, revision 2/);
const doc = s.read();
assert.equal(doc.revision, 2);
assert.deepEqual(doc.users, []);
const [line] = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
assert.equal(line.verb, "user.remove");
assert.equal(line.user, "alice");
assert.equal(line.revisionBefore, 1);
assert.equal(line.revisionAfter, 2);
assert.deepEqual(line.grants, []);
});
// M1's second fence: without the user-has-grants refusal, checkAccess refuses the file with exit 3
// (a grant naming a missing user). The test pins the reason and exit 2, so only the verb's refusal passes.
test("T4 user remove refuses with user-has-grants while the user holds a grant, and writes nothing", async (t) => {
const s = setup(t);
await run([...GRANT, "--revision", "1"], s.deps);
const id = s.read().grants[0].id;
const before = readFileSync(s.file, "utf8");
const err = await refusal(run(USER_REMOVE("alice", "2"), s.deps), /user-has-grants: alice still holds /);
assert.equal(err.exitCode, 2);
assert.ok(err.message.includes(id));
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts().trim().split("\n").length, 1);
});
test("T5 user remove of an id not in the file refuses and writes nothing", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
const err = await refusal(run(USER_REMOVE("carol"), s.deps), /no user carol to remove/);
assert.equal(err.exitCode, 2);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
test("T6 a stale revision refuses both user verbs with revision-conflict", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
for (const args of [USER_ADD("bob", { revision: "2" }), USER_REMOVE("alice", "5")]) {
const err = await refusal(run(args, s.deps), /revision-conflict/);
assert.equal(err.exitCode, 3);
}
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
test("T7 --tester and an agent caller are refused on both user verbs before the bus is asked", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
for (const args of [USER_ADD("bob"), USER_REMOVE("alice")]) {
const tester = await refusal(run([...args, "--tester"], s.deps), /not --tester/);
assert.equal(tester.exitCode, 3);
const agent = await refusal(run(args, s.deps, { ...humanEnv(), MOSAIC_RUN_ID: "run-1" }), /human shell/);
assert.equal(agent.exitCode, 3);
}
assert.equal(existsSync(s.log), false);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
test("T8 a user verb of the wrong shape prints the help", async (t) => {
const s = setup(t);
for (const shape of [["user"], ["user", "add"], ["user", "list", "x"], ["user", "add", "a", "b"]]) {
const err = await refusal(run([...shape, "--business", "acme", "--revision", "1"], s.deps), /mosaic access user add ID/);
assert.equal(err.exitCode, 4, shape.join(" "));
}
assert.equal(existsSync(s.log), false);
});
test("T9 a bus that refuses the human proof blocks both user verbs", async (t) => {
const s = setup(t, { mode: "refuse" });
const before = readFileSync(s.file, "utf8");
for (const args of [USER_ADD("bob"), USER_REMOVE("alice")]) {
const err = await refusal(run(args, s.deps), /human-required/);
assert.equal(err.exitCode, 3);
}
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
// FED R4 (#1801, design 3.7 trigger 3): grant and revoke ask the bus to re-read the file after the
// write. This stand-in CLI logs every call, one line each, and with FAKE_MODE "refuse-reload"
// refuses access.reload alone, as a bus would refuse a call it can't take. BUILD.md
// (agents/morgana/work/queue-295) names the mutant each test kills.
const RELOAD_BODY = `
import { appendFileSync, readFileSync } from "node:fs";
const input = JSON.parse(readFileSync(0, "utf8"));
appendFileSync(process.env.FAKE_LOG, JSON.stringify(input) + "\\n");
if (input.verb === "access.reload") {
if (process.env.FAKE_MODE === "refuse-reload") { process.stderr.write("human-required\\n"); process.exit(3); }
process.stdout.write(JSON.stringify({ ended: [] }));
} else process.stdout.write(JSON.stringify(${JSON.stringify(ROWS)}));
`;
function reloading(t, mode = "ok") {
const s = setup(t);
mkdirSync(join(s.root, "reload"));
const cli = fakeCli(join(s.root, "reload"), RELOAD_BODY);
const env = humanEnv({ FAKE_LOG: s.log, FAKE_MODE: mode });
s.deps.transport = (business) => humanTransport({ socket: join(s.root, "bus.sock"), business, env, cli, timeoutMs: 5000 });
const calls = () => readFileSync(s.log, "utf8").trim().split("\n").map((l) => JSON.parse(l));
return { ...s, calls };
}
test("R4 grant and revoke call access.reload after the write, and nothing else does", async (t) => {
const s = reloading(t);
await run([...GRANT, "--revision", "1"], s.deps);
assert.deepEqual(s.calls().map((c) => [c.verb, c.business, c.args]), [["project.list", "acme", {}], ["access.reload", "acme", {}]]);
await run(["revoke", "--business", "acme", "--user", "alice", "--revision", "2"], s.deps);
await run(["list", "--business", "acme"], s.deps);
assert.deepEqual(s.calls().map((c) => c.verb), ["project.list", "access.reload", "project.list", "access.reload", "project.list"]);
// A refused write never asks.
await refusal(run([...GRANT, "--revision", "1"], s.deps), /revision-conflict/);
assert.equal(s.calls().at(-1).verb, "project.list");
assert.equal(s.calls().filter((c) => c.verb === "access.reload").length, 2);
});
// sage-3139 Q2: the write stands, so a refused reload is exit 0. stdout reports the write as before,
// and stderr names the refusal and the timer.
test("R4 a refused access.reload after a good write exits 0, stdout reports the write, stderr names the code and the 30 s timer", async (t) => {
const s = reloading(t, "refuse-reload");
const i = io(humanEnv());
assert.equal(await accessCommand([...GRANT, "--revision", "1"], i, s.deps), undefined, "no error: exit 0");
assert.match(i.out.text, /^access grant: g-[0-9a-f]{8} at acme, revision 2\n$/);
assert.equal(i.err.text, "access.reload refused: human-required; the bus's timer applies the change within 30 s\n");
assert.equal(s.read().revision, 2, "the write stands");
assert.equal(s.receipts().trim().split("\n").length, 1);
});
test("R4 the help keeps the provider-removal order and says what a refused reload means", async (t) => {
const s = setup(t);
const err = await refusal(run(["help"], s.deps), /revoke them here first/);
assert.equal(err.exitCode, 4);
const text = err.message.replace(/\n/g, " ");
assert.match(text, /To cut a user off, revoke them here first\. It acts at once\. Removing a provider from console\.sso takes effect only after a Console restart, so do that second\./);
assert.match(text, /grant and revoke ask the bus to re-read the file after the write\. If the bus refuses, the write still stands \(exit 0\), stderr names the refusal, and the bus's timer applies the change within 30 s\./);
assert.ok(text.indexOf("so do that second") < text.indexOf("re-read the file"), "next to the provider-removal order");
});
test('v1 grant receipt preserves business identity and exact JSON line shape', async t => {
const s = setup(t);
await run([...GRANT, '--revision', '1'], s.deps);
const raw = s.receipts();
const grant = s.read().grants[0].id;
assert.match(grant, /^g-[0-9a-f]{8}$/);
assert.equal(raw, JSON.stringify({ at: '2026-10-20T20:00:00.000Z', verb: 'grant', business: 'acme', user: 'alice', actor: 'human', revisionBefore: 1, revisionAfter: 2, grants: [grant] }) + '\n');
});