Files
stack/packages/business/tests/role.test.mjs
T
jason.woltjeandClaude Opus 5.5 0453da7e10 feat(roles): coder drops the task.reassign grant the broker always refuses (#1567, row 73)
roles/coder.json crossRole goes from ["task.reassign", "task.scope.change"]
to ["task.scope.change"]. The broker refuses a non-pm task.reassign at the
writer check before it looks at authority, so the grant could never be
used. A coder's task.reassign now classifies as gated and goes to the
human. Backed by lead decision 90.

Tests: business role and resolve (the narrowing test moves to the pm,
which still holds two cross-role actions), a bus test on the shipped role
files, a harness test that a coder with a tracker gets no task_reassign,
the tasks fixture, and a verbs line showing a coder's task.reassign
refuses field-writer with no action.allowed. mutants.sh is Rocko's packet
rerun script, already committed.

Author: Rocko (packet agents/rocko/work/queue-73/, commit 2137dd7f).
Candidate manifest sha256 bf4e5ed6, base 324c064b, no drift on the
candidate paths through 8a42e819.
Approval: Dewey, round 1, comment 27506 on #1567.
Gate (Sage, /tmp/sage-g73 on 8a42e819, 23:42-23:48Z): tasks 71, bus 81,
business 60, harness 59, cli 113, webui 61, seat 27 node tests, 0 failed;
every scripts/test-*.sh exit 0, 0 failed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:49:09 -05:00

161 lines
8.3 KiB
JavaScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { loadRole, loadRoleFile, validateRoleDocument, GATED_ONLY, BusinessError } from "../src/index.mjs";
import { REPO_ROLES, tmp, writeJson } from "./helpers.mjs";
function refuses(fn, pattern, exitCode = 2) {
assert.throws(fn, (error) => {
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
assert.equal(error.exitCode, exitCode);
assert.match(error.message, pattern);
return true;
});
}
function scratchRole(overrides = {}, contract = "# contract\n") {
const dir = tmp();
if (contract !== null) writeFileSync(join(dir, "r.md"), contract);
const doc = {
roleVersion: 2, name: "r", title: "R", contract: "r.md", tools: ["read"], network: "none",
authority: { withinRole: ["message.send"], crossRole: [] },
credentials: [{ service: "gitea", scopes: ["read:issue"] }],
...overrides,
};
return { dir, file: join(dir, "r.json"), doc };
}
const check = (overrides, contract) => {
const { file, doc } = scratchRole(overrides, contract);
return validateRoleDocument(doc, file);
};
test("the four shipped version 2 roles load", () => {
for (const name of ["pm", "cto", "coder", "reviewer"]) {
const role = loadRole(REPO_ROLES, name);
assert.equal(role.roleVersion, 2);
assert.equal(role.contractPath, join(REPO_ROLES, `${name}.md`));
assert.deepEqual(role.credentials.map((c) => c.service), ["gitea", "vikunja"]);
for (const action of [...role.authority.withinRole, ...role.authority.crossRole]) {
assert.ok(!GATED_ONLY.includes(action), `${name} lists gated ${action}`);
}
assert.ok(Object.isFrozen(role.authority.withinRole));
}
});
test("shipped role scopes match addendum B section 2 and the SR runbook", () => {
const scopes = (name, service) => loadRole(REPO_ROLES, name).credentials.find((c) => c.service === service).scopes;
assert.deepEqual(scopes("pm", "vikunja"), {
tasks: ["read_one", "create", "update"], tasks_assignees: ["create", "delete"],
tasks_relations: ["create", "delete"], tasks_labels: ["create", "delete"],
tasks_comments: ["create"], labels: ["read_all"], projects: ["views_buckets_tasks"],
});
for (const worker of ["cto", "coder", "reviewer"]) {
assert.deepEqual(scopes(worker, "vikunja"), { tasks: ["read_one", "update"], tasks_comments: ["create"], projects: ["views_buckets_tasks"] });
assert.deepEqual(scopes(worker, "gitea"), ["write:issue", "write:repository", "read:user"]);
}
assert.deepEqual(scopes("pm", "gitea"), ["write:issue", "read:repository", "read:user"]);
});
test("shipped authority follows the note's table", () => {
const auth = (name) => loadRole(REPO_ROLES, name).authority;
assert.ok(auth("coder").withinRole.includes("git.push.working"));
// Assignee is pm-only (decision 90): a coder grant of task.reassign could never succeed.
assert.deepEqual(auth("coder").crossRole, ["task.scope.change"]);
assert.ok(!auth("reviewer").withinRole.includes("git.push.working"));
assert.deepEqual(auth("reviewer").crossRole, []);
assert.ok(auth("pm").withinRole.includes("role.launch"));
assert.ok(auth("cto").withinRole.includes("decision.resolve.technical"));
assert.ok(!auth("pm").withinRole.includes("decision.resolve.technical"));
});
test("version 1 files keep loading with no authority", () => {
const role = loadRole(REPO_ROLES, "researcher");
assert.equal(role.roleVersion, 1);
assert.equal(role.contractPath, null);
assert.deepEqual(role.authority, { withinRole: [], crossRole: [] });
assert.deepEqual(role.tools, ["read", "grep", "find", "ls", "bash"]);
const dir = tmp();
const file = writeJson(join(dir, "plain.json"), { roleVersion: 1, name: "plain", tools: ["read"] });
assert.equal(loadRoleFile(file).network, "none");
refuses(() => loadRoleFile(writeJson(join(dir, "v1x.json"), { roleVersion: 1, name: "v1x", tools: ["read"], authority: {} })), /unsupported role key: "authority"/);
});
test("the conductor policy isn't a role", () => {
refuses(() => loadRole(REPO_ROLES, "conductor-policy"), /roleVersion/);
});
test("a missing role file is exit 4, a symbolic link too", () => {
const dir = tmp();
refuses(() => loadRole(dir, "absent"), /not found/, 4);
writeJson(join(dir, "real.json"), { roleVersion: 1, name: "link", tools: ["read"] });
symlinkSync("real.json", join(dir, "link.json"));
refuses(() => loadRole(dir, "link"), /non-symbolic-link/, 4);
refuses(() => loadRole(dir, "../etc"), /role name/);
});
test("version 2 refusals", () => {
assert.equal(check({}).name, "r");
refuses(() => check({ roleVersion: 3 }), /roleVersion/);
refuses(() => check({ extra: 1 }), /unsupported role key: "extra"/);
refuses(() => check({ name: "other" }), /must match its filename/);
refuses(() => check({ title: undefined }), /requires "title"/);
refuses(() => check({ title: "x".repeat(81) }), /title/);
refuses(() => check({ network: "everywhere" }), /network/);
refuses(() => check({ tools: [] }), /tools/);
refuses(() => check({ tools: ["read", "read"] }), /duplicate/);
refuses(() => check({ tools: ["render3d"] }), /unsupported tool/);
});
test("authority: closed vocabulary, no gated-only action, no overlap", () => {
refuses(() => check({ authority: { withinRole: ["task.delete"], crossRole: [] } }), /unknown action/);
for (const gated of GATED_ONLY) {
refuses(() => check({ authority: { withinRole: [gated], crossRole: [] } }), /always gated/);
refuses(() => check({ authority: { withinRole: [], crossRole: [gated] } }), /always gated/);
}
refuses(() => check({ authority: { withinRole: ["task.reassign"], crossRole: ["task.reassign"] } }), /both withinRole and crossRole/);
refuses(() => check({ authority: { withinRole: [] } }), /crossRole must be an array/);
refuses(() => check({ authority: { withinRole: [], crossRole: [], gated: [] } }), /unsupported role "authority" key/);
});
test("credentials: Gitea scopes", () => {
const gitea = (scopes) => check({ credentials: [{ service: "gitea", scopes }] });
assert.deepEqual(gitea(["write:repository", "read:user"]).credentials[0].scopes, ["write:repository", "read:user"]);
refuses(() => gitea(["write:admin"]), /unsupported gitea scope/);
refuses(() => gitea(["all"]), /unsupported gitea scope/);
refuses(() => gitea(["sudo:repository"]), /unsupported gitea scope/);
refuses(() => gitea(["read:issue", "write:issue"]), /name issue twice/);
refuses(() => gitea([]), /must not be empty/);
});
test("credentials: Vikunja scopes are a group-to-verbs map from the grantable list", () => {
const vikunja = (scopes) => check({ credentials: [{ service: "vikunja", scopes }] });
assert.deepEqual(vikunja({ tasks: ["read_one"] }).credentials[0].scopes, { tasks: ["read_one"] });
refuses(() => vikunja({ tasks: ["delete"] }), /not grantable/);
refuses(() => vikunja({ projects: ["create"] }), /not grantable/);
refuses(() => vikunja({ tokens: ["read_all"] }), /route group not grantable/);
refuses(() => vikunja({ projects_webhooks: ["create"] }), /route group not grantable/);
refuses(() => vikunja({ tasks: [] }), /must not be empty/);
refuses(() => vikunja({}), /at least one route group/);
refuses(() => vikunja(["tasks.read"]), /JSON object/);
});
test("credentials: services", () => {
refuses(() => check({ credentials: [{ service: "github", scopes: [] }] }), /service must be one of/);
refuses(() => check({ credentials: [{ service: "gitea", scopes: ["read:issue"] }, { service: "gitea", scopes: ["read:user"] }] }), /twice/);
refuses(() => check({ credentials: [{ service: "gitea", scopes: ["read:issue"], token: "x" }] }), /unsupported role credentials\[0\] key/);
assert.deepEqual(check({ credentials: [] }).credentials, []);
});
test("contract: a non-empty regular Markdown file beside the role file", () => {
refuses(() => check({}, null), /contract not found/);
refuses(() => check({}, ""), /non-empty regular file/);
refuses(() => check({ contract: "../r.md" }), /Markdown file name/);
refuses(() => check({ contract: "roles/r.md" }), /Markdown file name/);
refuses(() => check({ contract: "r.txt" }), /Markdown file name/);
const { dir, file, doc } = scratchRole({ contract: "link.md" });
symlinkSync("r.md", join(dir, "link.md"));
refuses(() => validateRoleDocument(doc, file), /non-empty regular file/);
});