Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62c3403e17 | ||
|
|
aecb014279 | ||
|
|
4b1b1bf2e5 |
No files matched your search
@@ -0,0 +1,138 @@
|
||||
# Row 207 build notes: 193c, `mosaic access user` verbs and `--level viewer` (#1705)
|
||||
|
||||
Builder Megavolt, reviewer Duckworth, gate owner Sage (sage-2790, Q220, mos-1301). Branch megavolt/row207 off
|
||||
aca16404c. The brief is "Brief: 193c, mosaic access user verbs and --level viewer" in
|
||||
docs/plans/2026-10-20_human-users-build.md (blob c74af2510). Its gate: Duckworth approves the manifest, the cli
|
||||
suite and every `scripts/test-*.sh` are green on Sage's gate, and the mutants are killed.
|
||||
|
||||
## Sources
|
||||
|
||||
- The brief, quoted: "`mosaic access user add/remove` and `grant --level viewer`, only where R5 lacks them. They
|
||||
refuse tester and agent callers, write a receipt line and call `access.reload` (section 8, CLI). If R5 already
|
||||
has all of them, this row records that and closes with no code."
|
||||
- Morgana's row 193 design, section 4.5 and section 8 (agents/morgana/work/queue-193/DESIGN.md at e2d161e2e on
|
||||
morgana/row193): `user add <id> --home local --subject <provider>:<sub>`, `user remove <id>`, `--level viewer`
|
||||
on `grant`, a receipt line in `<dataRoot>/state/access-log.jsonl` and the `access.reload` call.
|
||||
- R5 as it stands at aca16404c: packages/cli/src/access.mjs, packages/cli/tests/access.test.mjs,
|
||||
packages/business/src/access.mjs (checkAccess, read only here), packages/bus/src/broker.mjs and runtime.mjs.
|
||||
|
||||
## 1. What R5 already has
|
||||
|
||||
- `grant --level viewer`: present. LEVELS is `["collaborator", "viewer"]` (packages/business/src/vocabulary.mjs),
|
||||
grant checks `--level` against it, and access.test.mjs already grants at viewer. This row adds no code for it
|
||||
and records it here.
|
||||
- `user add` and `user remove`: absent. accessVerb takes only list, grant and revoke, and the only way to add a
|
||||
user today is to hand-edit access.json. This row adds them.
|
||||
- `access.reload`: absent everywhere. No bus verb has that name. The R5 brief says "R4 later makes these verbs
|
||||
call `access.reload`, so R5 leaves no other reload path", FED R3's brief leaves it to R4, and access.mjs's
|
||||
proveHuman says "Row R4 swaps this call for access.reload". FED R4 is not filed.
|
||||
|
||||
## 2. Decisions (Q items for Sage and Duckworth, stated as assumptions, not blockers)
|
||||
|
||||
- Q1, the reload call. 193c adds no `access.reload` call, because there is no verb to call. The user verbs take
|
||||
R5's path exactly: the same `project.list` human proof, and R4 swaps the proof for `access.reload` on all five
|
||||
verbs at once. Nothing is lost meanwhile: runtime.mjs hands the broker `() => loadAccess(...)`, and the broker
|
||||
reads the file once per user request (broker.mjs:384), so a written change acts on the next request. If Sage
|
||||
or Duckworth reads the brief as "add `access.reload` here", that is R4's bus verb and a scope change.
|
||||
- Q2, remove with grants. `user remove` refuses with `user-has-grants`, exit 2, naming the grant ids, while any
|
||||
grant at any business names the user. The other reading drops the user's grants in the same revision. Refusing
|
||||
is the smaller, fail-closed choice: nothing disappears that the owner did not name, and the help text already
|
||||
says to revoke first. checkAccess would also refuse the file (a grant naming a missing user), but with a key
|
||||
path, not a reason, and exit 3.
|
||||
- Q3, the subject. `--subject PROVIDER:SUB` splits at the first colon, so a sub may hold colons. One subject per
|
||||
add, because parse keeps one value per flag. A second subject is a later row if anyone needs it; checkAccess
|
||||
allows several.
|
||||
- Q4, `--business`. Required on the user verbs too. The user list is instance-wide, but the human proof runs
|
||||
through one business's bus, and the receipt names it.
|
||||
- Q5, the receipt. `{at, verb: "user.add" | "user.remove", business, user, actor: "human", revisionBefore,
|
||||
revisionAfter, grants: []}`: grant and revoke's shape, with an empty grants list.
|
||||
- `user remove` of an id not in the file refuses, exit 2, and writes nothing, as revoke of nothing does.
|
||||
- `user add` of a duplicate id, a reserved id (human, tester), a business's human, an unknown provider, a
|
||||
non-local home, an owner sso subject or a subject another user holds refuses through checkAccess, exit 3,
|
||||
before any write. The verb does not repeat those checks.
|
||||
|
||||
### Rulings (sage-2810, 16:40Z, cc Duckworth)
|
||||
|
||||
- Q1 agreed: no reload call. The reason is broker.mjs:384-386, the `#access` field's comment: "() => the checked
|
||||
access document (R1's loadAccess), read once per user request", wired in runtime.mjs:263 as
|
||||
`() => loadAccess(access.file, access.context)`. A write acts on the next request. FED R4 owns any reload verb.
|
||||
- Q2 agreed: `user remove` refuses with `user-has-grants`, exit 2, naming the grant ids, and writes nothing.
|
||||
Dropping the grants silently would make removal revoke access without saying so.
|
||||
- Usage and help pins: grep the tests for the changed strings, sha256 and snapshot pins included, and run every
|
||||
reader whole (section 6).
|
||||
- Row 203 (Quackerjack) does not touch access.mjs; its stale help lines stay a follow-up, so no overlap.
|
||||
|
||||
### The usage and help pin sweep (sage-2810), on aca16404c plus this row
|
||||
|
||||
- Source readers of ACCESS_USAGE and ACCESS_HELP: packages/cli/src/access.mjs only (cli.mjs imports
|
||||
accessCommand and accessContextFor, not the strings).
|
||||
- `git grep` over 'packages/*/tests/*' and 'scripts/*' for ACCESS_USAGE, ACCESS_HELP, "mosaic access",
|
||||
"access list", "access grant", "access revoke", "Access (FED" and the help's "revoke them here first": the only
|
||||
hits are packages/cli/tests/access.test.mjs (positive control: `accessCommand` hits it twice).
|
||||
- Hash pins: no test file names packages/cli/README.md or packages/cli/src/access.mjs next to a sha256 or a
|
||||
64-hex value. The one 64-hex file that mentions a README (packages/queue/tests/fixtures/queue-marked.md) names
|
||||
packages/queue/README.md and packages/ledger/README.md, not this one. scripts/stack-copy.sh copies
|
||||
packages/cli/README.md; stack-copy.test.mjs builds its own fake repo and is in the cli package, run whole.
|
||||
- packages/business/tests/access*.test.mjs import packages/business/src/access.mjs, a different file, unchanged.
|
||||
- So the readers are access.test.mjs, run whole in step 1, and the cli package whole in step 2.
|
||||
|
||||
## 3. The change (code and docs)
|
||||
|
||||
- packages/cli/src/access.mjs: two ACCESS_USAGE lines and one help line; accessVerb accepts `user add ID` and
|
||||
`user remove ID` (three positionals) and hands off to `userVerb` after the shared checks (tester, business,
|
||||
proof, file, context, revision). `userVerb` takes the lock, checks the revision, builds the new users list,
|
||||
runs checkAccess on the whole document, writes atomically and appends the receipt.
|
||||
- packages/cli/README.md: the usage block and one paragraph.
|
||||
- No change to packages/business, packages/bus or cli.mjs (it already routes `access` to accessCommand).
|
||||
|
||||
## 4. Tests (packages/cli/tests/access.test.mjs, whole file)
|
||||
|
||||
Manifest check against Morgana's row 275 (sage-2790), 10-22: morgana/row275 has no commits past aca16404c, and
|
||||
row 275's brief ("193b1b, viewer verbs and board refusals") is bus only: the viewer verb list, `covers` level
|
||||
checks and board refusals, gated on the bus suite. This row touches only packages/cli (access.mjs, README,
|
||||
access.test.mjs) and this file, so no path is shared on the briefs. I recheck the paths when Morgana pushes.
|
||||
|
||||
- T1 `user add` writes the user (id, home, one subject), bumps the revision by one, prints the line and appends
|
||||
one receipt with verb `user.add` and `grants: []`. A sub with a colon (`authentik:bob:x`) keeps `bob:x`.
|
||||
- T2 `user add` refusals before any write, file and receipts unchanged: missing `--home` or `--subject`, a
|
||||
subject with no colon, an empty provider or sub (exit 4); an unknown provider, a duplicate id, `tester`, the
|
||||
business's human, an owner sso subject, a subject alice already holds, a non-local home (exit 3).
|
||||
- T3 `user remove` of a user with no grants drops them, bumps the revision, receipt `user.remove`.
|
||||
- T4 `user remove` of a user with a grant refuses `user-has-grants` naming the grant id, exit 2, nothing written.
|
||||
- T5 `user remove` of an id not in the file refuses, exit 2, nothing written.
|
||||
- T6 a stale `--revision` refuses `revision-conflict` on both user verbs, exit 3.
|
||||
- T7 `--tester` and an agent env (MOSAIC_RUN_ID) refuse both user verbs, exit 3, and the fake human CLI is never
|
||||
called.
|
||||
- T8 a bad shape (`user`, `user add`, `user list x`, `user add a b`) prints the help, exit 4.
|
||||
- T9 a bus that refuses the human proof (the fixture's `mode: "refuse"`) blocks both user verbs: exit 3, the
|
||||
file and receipts unchanged.
|
||||
|
||||
## 5. Mutants
|
||||
|
||||
Each runs the whole access.test.mjs and counts as killed only on an assertion in the named test.
|
||||
|
||||
- M1 drop the `user-has-grants` refusal. Second fence: checkAccess then refuses the file with exit 3, so T4 must
|
||||
assert the `user-has-grants` text and exit 2, not only "nothing written". Killed by T4.
|
||||
- M2 drop the "no user to remove" check. The write then lands with an unchanged user list. Killed by T5.
|
||||
- M3 split the subject at the last colon. Killed by T1's colon case.
|
||||
- M4 skip checkAccess in userVerb (write the unchecked document). Killed by T2's exit-3 cases.
|
||||
- M5 drop the receipt in userVerb. Killed by T1 and T3.
|
||||
- M6 drop the revision check in userVerb. Killed by T6.
|
||||
- M7 exempt the user verbs from the `--tester` check (`&& sub !== "user"`). Killed by T7's tester case.
|
||||
- M8 skip proveHuman for the user verbs (`if (sub !== "user")`). Killed by T9. T7's agent case does not reach it: accessCommand
|
||||
calls refuseInsideAgent before accessVerb, so an agent env is refused whatever the order inside.
|
||||
|
||||
## 6. Gate plan (mos-1275)
|
||||
|
||||
On Sage's CLEAR, from a rebase onto 253's landing under a new branch name, stopping at the first red:
|
||||
|
||||
1. access.test.mjs whole, then M1-M8, each under `timeout -k 10 180`.
|
||||
2. The cli package whole.
|
||||
3. Every `scripts/test-*.sh` as `timeout -k 10 LIMIT bash scripts/test-X.sh`, no script edited. LIMIT is row
|
||||
281's table (Gander's row 278 limits, 3x the longest of three records plus 120 s): auth 126, conductor 145,
|
||||
config 130, discord 147, extension-package 130, foundation 240, land-work 158, queue 191, release 157,
|
||||
task 231 seconds. The base has the same ten scripts. Any timeout goes to Sage, then Mos, unplaced.
|
||||
|
||||
## Runs
|
||||
|
||||
Filled in after the runs.
|
||||
@@ -1084,6 +1084,8 @@ prints a token. A usage error exits 4 and a bus refusal 2.
|
||||
mosaic access list --business ID [--json]
|
||||
mosaic access grant --business ID --user ID --projects UUID|NAME,...|* --instances NAME,...|* --level collaborator|viewer [--expires ISO] --revision N
|
||||
mosaic access revoke --business ID --user ID [--grant GID] --revision N
|
||||
mosaic access user add ID --business ID --home local --subject PROVIDER:SUB --revision N
|
||||
mosaic access user remove ID --business ID --revision N
|
||||
```
|
||||
|
||||
These edit `<configDir>/access.json` (design section 3.5). The human proof is one `project.list`
|
||||
@@ -1107,6 +1109,14 @@ once. Removing a provider from `console.sso` takes effect only after a Console r
|
||||
Peer homes are not read yet, so a grant that names a peer home refuses until row 203 adds the
|
||||
federation key. A grant that names a sign-in subject needs `console.sso` in the system config.
|
||||
|
||||
`user add` and `user remove` (row 193c, #1705) take the same proof, lock, revision check, file
|
||||
checks and receipt. `user add` takes one sign-in subject, `PROVIDER:SUB`, split at the first colon;
|
||||
the provider must be in `console.sso`. `user remove` refuses with `user-has-grants` while any grant,
|
||||
at any business, names the user: revoke those first. `--business` names the business whose bus
|
||||
takes the proof; the user list itself is instance-wide. Their receipt verbs are `user.add` and
|
||||
`user.remove`. The bus has no `access.reload` yet (FED R4). As with grant and revoke, the broker
|
||||
reads the file once per user request (`packages/bus/src/broker.mjs`), so the change acts at once.
|
||||
|
||||
## Token usage
|
||||
|
||||
`mosaic usage [--business ID] [--json] [--tester]` reads the bus's accounting, without
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// `mosaic access list|grant|revoke` (federation design section 3.5, FED R5, #414).
|
||||
// `mosaic access list|grant|revoke|user` (federation design section 3.5, FED R5, #414;
|
||||
// `user add|remove`, row 193c, #1705).
|
||||
// The human edits <configDir>/access.json here. Each write is checked against the
|
||||
// instance's context before it lands, bumps the revision by one, and appends one
|
||||
// receipt line to <dataRoot>/state/access-log.jsonl. The human proof is one call
|
||||
@@ -18,6 +19,8 @@ export const ACCESS_USAGE = [
|
||||
"mosaic access list --business ID [--json]",
|
||||
"mosaic access grant --business ID --user ID --projects UUID|NAME,...|* --instances NAME,...|* --level collaborator|viewer [--expires ISO] --revision N",
|
||||
"mosaic access revoke --business ID --user ID [--grant GID] --revision N",
|
||||
"mosaic access user add ID --business ID --home local --subject PROVIDER:SUB --revision N",
|
||||
"mosaic access user remove ID --business ID --revision N",
|
||||
].join("\n");
|
||||
|
||||
// The help text. A grant's `instances` is the real limit: the granted instance
|
||||
@@ -33,6 +36,7 @@ list is the real limit: a collaborator's message drives a granted instance with
|
||||
the owner's role.
|
||||
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.
|
||||
user remove refuses while the user has a grant at any business: revoke those first.
|
||||
Peer homes are not read yet (row 203 adds the federation key), so a grant that
|
||||
names a peer home refuses. A grant that names a sign-in subject needs console.sso
|
||||
in the system config; without it every such grant refuses.
|
||||
@@ -206,7 +210,8 @@ export async function accessCommand(args, io, deps) {
|
||||
async function accessVerb(args, io, deps) {
|
||||
const parsed = parse(args);
|
||||
const sub = parsed.positional[0];
|
||||
if (!["list", "grant", "revoke"].includes(sub) || parsed.positional.length !== 1) throw new CliError(ACCESS_HELP, 4);
|
||||
const shape = sub === "user" ? ["add", "remove"].includes(parsed.positional[1]) && parsed.positional.length === 3 : ["list", "grant", "revoke"].includes(sub) && parsed.positional.length === 1;
|
||||
if (!shape) throw new CliError(ACCESS_HELP, 4);
|
||||
if (parsed.flags.has("--tester")) throw new CliError("refused: access writes take the human's shell, not --tester", 3);
|
||||
const business = parsed.values["--business"];
|
||||
if (!business) throw new CliError(`--business is required\n${ACCESS_USAGE}`, 4);
|
||||
@@ -224,6 +229,7 @@ async function accessVerb(args, io, deps) {
|
||||
|
||||
const expected = parsed.values["--revision"];
|
||||
if (!/^\d+$/.test(expected ?? "")) throw new CliError(`--revision takes the revision you read\n${ACCESS_USAGE}`, 4);
|
||||
if (sub === "user") return userVerb(parsed, io, deps, { business, file, context, expected });
|
||||
const user = parsed.values["--user"];
|
||||
if (!user) throw new CliError(`${sub} needs --user\n${ACCESS_USAGE}`, 4);
|
||||
|
||||
@@ -278,6 +284,53 @@ async function accessVerb(args, io, deps) {
|
||||
io.stdout.write(`access ${sub}: ${result.touched.join(", ")} at ${business}, revision ${result.checked.revision}\n`);
|
||||
}
|
||||
|
||||
// `user add ID` and `user remove ID` (row 193c): the same lock, revision check, file checks,
|
||||
// atomic write and receipt as grant and revoke. add takes one subject, PROVIDER:SUB, split at the
|
||||
// first colon, so a sub may hold colons. remove refuses while any grant names the user, at any
|
||||
// business, so a user is never removed with live grants (the owner revokes first, as the help says).
|
||||
async function userVerb(parsed, io, deps, { business, file, context, expected }) {
|
||||
const [, action, user] = parsed.positional;
|
||||
let entry;
|
||||
if (action === "add") {
|
||||
const home = parsed.values["--home"];
|
||||
const subject = parsed.values["--subject"] ?? "";
|
||||
const colon = subject.indexOf(":");
|
||||
if (home === undefined || colon < 1 || colon === subject.length - 1) {
|
||||
throw new CliError(`user add needs --home and --subject PROVIDER:SUB\n${ACCESS_USAGE}`, 4);
|
||||
}
|
||||
entry = { id: user, home, subjects: [{ provider: subject.slice(0, colon), sub: subject.slice(colon + 1) }] };
|
||||
}
|
||||
const result = withLock(file, () => {
|
||||
const { document, revision } = readCurrent(file, context);
|
||||
if (Number(expected) !== revision) {
|
||||
throw new CliError(`refused: revision-conflict: the access file is at revision ${revision}, not ${expected}; run list again`, 3);
|
||||
}
|
||||
let users;
|
||||
if (action === "add") users = [...document.users, entry];
|
||||
else {
|
||||
if (!document.users.some((u) => u.id === user)) throw new CliError(`refused: no user ${user} to remove`, 2);
|
||||
const held = document.grants.filter((g) => g.user === user).map((g) => g.id);
|
||||
if (held.length) throw new CliError(`refused: user-has-grants: ${user} still holds ${held.join(", ")}; revoke them first`, 2);
|
||||
users = document.users.filter((u) => u.id !== user);
|
||||
}
|
||||
const checked = checkAccess({ ...document, revision: revision + 1, users }, context);
|
||||
writeAtomic(file, `${JSON.stringify(checked, null, 2)}\n`, deps.fs);
|
||||
return { checked, revision };
|
||||
}, deps.fs);
|
||||
|
||||
receipt(deps.system.dataRoot, {
|
||||
at: (deps.now ?? (() => new Date()))().toISOString(),
|
||||
verb: `user.${action}`,
|
||||
business,
|
||||
user,
|
||||
actor: "human",
|
||||
revisionBefore: result.revision,
|
||||
revisionAfter: result.checked.revision,
|
||||
grants: [],
|
||||
});
|
||||
io.stdout.write(`access user ${action}: ${user}, revision ${result.checked.revision}\n`);
|
||||
}
|
||||
|
||||
function formatList(doc) {
|
||||
const lines = [`access file revision ${doc.revision ?? "none"}`];
|
||||
for (const u of doc.users) lines.push(`user ${u.id} home ${u.home}`);
|
||||
|
||||
@@ -312,3 +312,140 @@ test("the real human-cli, run from agent ancestry, refuses and writes nothing",
|
||||
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: alice/],
|
||||
[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(), "");
|
||||
});
|
||||
Reference in new issue
Block a user