Files

284 lines
17 KiB
JavaScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { loadRole, loadRoleFile, validateRoleDocument, ACTIONS, GATED_ONLY, BusinessError } from "../src/index.mjs";
import { REPO_ROLES, tmp, writeJson } from "./helpers.mjs";
import { CREATIVE_DEFINITIONS } from "../src/vocabulary.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\n## Authority\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("chief-of-staff").withinRole.includes("role.launch"));
assert.ok(!auth("pm").withinRole.includes("role.launch"));
// Row 141 (REQ-MSG-2): the chief alone of the shipped roles sets instance names.
assert.ok(auth("chief-of-staff").withinRole.includes("instance.name"));
const others = readdirSync(REPO_ROLES).filter((f) => f.endsWith(".json") && !["conductor-policy.json", "chief-of-staff.json"].includes(f));
assert.ok(others.length >= 10, String(others.length));
for (const name of others.map((f) => f.slice(0, -5))) {
assert.ok(!auth(name).withinRole.includes("instance.name"), name);
assert.ok(!auth(name).crossRole.includes("instance.name"), name);
// Row 194 (PRD 1.7 REQ-COS-3, sage-1471): hire and fire are the chief's alone too.
for (const action of ["role.hire", "role.fire"]) {
assert.ok(!auth(name).withinRole.includes(action), `${name} ${action}`);
assert.ok(!auth(name).crossRole.includes(action), `${name} ${action}`);
}
}
assert.ok(auth("chief-of-staff").withinRole.includes("role.hire") && auth("chief-of-staff").withinRole.includes("role.fire"));
assert.ok(auth("cto").withinRole.includes("decision.resolve.technical"));
assert.ok(!auth("pm").withinRole.includes("decision.resolve.technical"));
});
test("every role file in roles/ loads through the validator, and a v2 contract exists", () => {
// conductor-policy.json is the conductor's policy, not a role definition.
const names = readdirSync(REPO_ROLES).filter((f) => f.endsWith(".json") && f !== "conductor-policy.json").map((f) => f.slice(0, -5));
for (const required of ["assistant", "chief-of-staff", "pm"]) assert.ok(names.includes(required), required);
for (const name of names) {
const role = loadRole(REPO_ROLES, name);
assert.equal(role.name, name);
if (role.roleVersion === 2) assert.ok(existsSync(role.contractPath), `${name} contract`);
}
});
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.equal(role.messaging, null);
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/);
// REQ-HARN-2: "web" is the grant for the broker's web tools.
assert.deepEqual(check({ tools: ["read", "web"] }).tools, ["read", "web"]);
});
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("messaging: optional, role definitions and human, never the tester", () => {
assert.equal(check({}).messaging, null);
const role = check({ messaging: { sendTo: ["human", "chief-of-staff"], receiveFrom: [] } });
assert.deepEqual(role.messaging, { sendTo: ["human", "chief-of-staff"], receiveFrom: [] });
assert.ok(Object.isFrozen(role.messaging.sendTo));
refuses(() => check({ messaging: "human" }), /role "messaging" must be a JSON object/);
refuses(() => check({ messaging: { sendTo: [] } }), /messaging\.receiveFrom must be an array/);
refuses(() => check({ messaging: { receiveFrom: [] } }), /messaging\.sendTo must be an array/);
refuses(() => check({ messaging: { sendTo: [], receiveFrom: [], extra: [] } }), /unsupported role "messaging" key/);
refuses(() => check({ messaging: { sendTo: ["pm", "pm"], receiveFrom: [] } }), /duplicate entry in role messaging\.sendTo/);
refuses(() => check({ messaging: { sendTo: [], receiveFrom: ["not a role"] } }), /messaging\.receiveFrom entry/);
refuses(() => check({ messaging: { sendTo: [7], receiveFrom: [] } }), /messaging\.sendTo entry/);
refuses(() => check({ messaging: { sendTo: ["tester"], receiveFrom: [] } }), /messaging\.sendTo lists tester/);
refuses(() => check({ messaging: { sendTo: [], receiveFrom: ["tester"] } }), /messaging\.receiveFrom lists tester/);
// Version 1 has no messaging key.
const file = writeJson(join(tmp(), "v1m.json"), { roleVersion: 1, name: "v1m", tools: ["read"], messaging: { sendTo: [], receiveFrom: [] } });
refuses(() => loadRoleFile(file), /unsupported role key: "messaging"/);
});
test("row 64 skills: optional, distinct skill directory names, none on version 1", () => {
assert.deepEqual(check({}).skills, []);
const role = check({ skills: ["ms-unslop", "ms_sdlc.v2"] });
assert.deepEqual(role.skills, ["ms-unslop", "ms_sdlc.v2"]);
assert.ok(Object.isFrozen(role.skills));
refuses(() => check({ skills: "ms-unslop" }), /role skills must be an array/);
refuses(() => check({ skills: ["ms-unslop", "ms-unslop"] }), /duplicate entry in role skills/);
for (const bad of [".", "..", "", "a/b", "../ms-unslop", "ms unslop", 7, null]) {
refuses(() => check({ skills: [bad] }), /role skill must be a skill directory name/);
}
const file = writeJson(join(tmp(), "v1s.json"), { roleVersion: 1, name: "v1s", tools: ["read"], skills: ["ms-unslop"] });
refuses(() => loadRoleFile(file), /unsupported role key: "skills"/);
assert.deepEqual(validateRoleDocument({ roleVersion: 1, name: "v1s", tools: ["read"] }, file).skills, []);
});
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/);
});
// Row 213 (C2-08, #1714, M1): a creative role reads and answers the human, and nothing else.
const CREATIVE = { companion: ["read", "web"], ideas: ["read"], research: ["read", "web"], proofreader: ["read"], "fact-checker": ["read"], continuity: ["read"] };
// Row 285 (C2-08b, #1790, mos-1309 item 5): each also holds library.write and the gitea credential, whose
// scopes equal what the Gitea grant path mints (gitea-grant.mjs SEAT_SCOPES, sage-2915).
test("the six creative roles load: read tools, message.send and library.write, gitea, messaging with the human only", () => {
for (const [name, tools] of Object.entries(CREATIVE)) {
const role = loadRole(REPO_ROLES, name);
assert.equal(role.roleVersion, 2, name);
assert.deepEqual(role.tools, tools, name);
assert.equal(role.network, tools.includes("web") ? "api-only" : "none", name);
assert.deepEqual(role.authority, { withinRole: ["message.send", "library.write"], crossRole: [] }, name);
assert.deepEqual(role.credentials, [{ service: "gitea", scopes: ["read:user", "write:repository"] }], name);
assert.deepEqual(role.messaging, { sendTo: ["human"], receiveFrom: ["human"] }, name);
}
});
test("M1: a creative role file wider than read and answer is refused", () => {
const creative = (name, overrides) => {
const dir = tmp();
writeFileSync(join(dir, `${name}.md`), "# contract\n\n## Authority\n");
const doc = {
roleVersion: 2, name, title: "C", contract: `${name}.md`, tools: ["read"], network: "none",
authority: { withinRole: ["message.send"], crossRole: [] }, credentials: [],
messaging: { sendTo: ["human"], receiveFrom: ["human"] }, ...overrides,
};
// An override of undefined drops the key.
for (const key of Object.keys(doc)) if (doc[key] === undefined) delete doc[key];
return () => validateRoleDocument(doc, join(dir, `${name}.json`));
};
assert.doesNotThrow(creative("ideas", { tools: ["read", "grep", "find", "ls", "web"] }));
for (const tool of ["write", "edit", "bash"]) {
refuses(creative("ideas", { tools: ["read", tool] }), /^creative role ideas may not name tools (?:write|edit|bash) \(allowed: /);
}
const gitea = [{ service: "gitea", scopes: ["read:user", "write:repository"] }];
assert.doesNotThrow(creative("ideas", { authority: { withinRole: ["message.send", "library.write"], crossRole: [] }, credentials: gitea }));
// Row 285: any other action, at either class, refuses, each routed action looped from the list.
const only = /^creative role proofreader may hold only withinRole message\.send and library\.write$/;
for (const action of ACTIONS.filter((a) => !GATED_ONLY.includes(a) && !["message.send", "library.write"].includes(a))) {
refuses(creative("proofreader", { authority: { withinRole: ["message.send", "library.write", action], crossRole: [] } }), only);
}
refuses(creative("proofreader", { authority: { withinRole: [], crossRole: ["message.send"] } }), only);
refuses(creative("proofreader", { authority: { withinRole: ["message.send"], crossRole: ["library.write"] } }), only);
// Row 285: library.write with any credential but gitea refuses.
refuses(creative("ideas", { authority: { withinRole: ["message.send", "library.write"], crossRole: [] }, credentials: [...gitea, { service: "vikunja", scopes: { tasks: ["read_one"] } }] }),
/^creative role ideas may hold only the gitea credential \(got vikunja\)$/);
refuses(creative("ideas", { authority: { withinRole: ["message.send", "library.write"], crossRole: [] }, credentials: [{ service: "host" }] }),
/^creative role ideas may hold only the gitea credential \(got host\)$/);
refuses(creative("continuity", { messaging: undefined }), /^creative role continuity requires "messaging"$/);
refuses(creative("research", { messaging: { sendTo: ["human", "pm"], receiveFrom: ["human"] } }), /^creative role research messaging.sendTo may name only human$/);
refuses(creative("research", { messaging: { sendTo: ["human"], receiveFrom: ["assistant"] } }), /^creative role research messaging.receiveFrom may name only human$/);
refuses(creative("companion", { roleVersion: 1, title: undefined, contract: undefined, authority: undefined, credentials: undefined, messaging: undefined }), /^creative role companion must be version 2$/);
});
test("A4 removes the hireable key from shipped and custom roles", () => {
assert.equal(Object.hasOwn(loadRole(REPO_ROLES,"chief-of-staff"),"hireable"),false);
refuses(() => check({hireable:[]}),/unsupported role key/);
});