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.
522 lines
25 KiB
JavaScript
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');
|
|
});
|