Files
stack/packages/business/tests/role.test.mjs
T
jason.woltjeandClaude Opus 5.5 2d64c71eb2 feat(business): roles v2, business and project files, variable layers (row 36, S1, darkwing)
Darkwing's round 2 candidate, approved by Filbert (#1518 comment 26730).
build-r2.patch a27890d5, manifest 869168c7, 34 files, applied on HEAD and
checked 34/34. Integration gate on an export of HEAD plus the patch:
business 60/60 on Node 24 and 26, every package test and every
scripts/test-*.sh green, test-task 98/98 with the live-provider cases.
Conductor, queue, conversation and discord confirmed in git worktrees of
HEAD with and without the patch, identical results. Lead decision 63
accepts the vocabulary location, the example path and the business
branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 17:09:07 -05:00

159 lines
8.1 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"));
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/);
});