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/, commit2137dd7f). Candidate manifest sha256 bf4e5ed6, base324c064b, no drift on the candidate paths through8a42e819. Approval: Dewey, round 1, comment 27506 on #1567. Gate (Sage, /tmp/sage-g73 on8a42e819, 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]>
161 lines
8.3 KiB
JavaScript
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/);
|
|
});
|