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/); });