import { test } from "node:test"; import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; import { chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { BusinessError, businessFilePath, configDir, loadBusiness, validateBusinessDocument } from "../src/index.mjs"; import { businessDoc, REPO, REPO_ROLES, rolesCopy, 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; }); } // Validate a mutated copy of the fixture document. function check(mutate, { rolesDir = REPO_ROLES } = {}) { const root = tmp(); const doc = businessDoc(root); mutate?.(doc, root); return validateBusinessDocument(doc, join(root, "businesses", "acme.json"), { rolesDir }); } test("config directory and file path follow MOSAIC_CONFIG", () => { assert.equal(configDir({ MOSAIC_CONFIG: "/x/cfg/config.json" }), "/x/cfg"); assert.match(configDir({}), /\.config\/mosaic-dev$/); assert.equal(businessFilePath("acme", "/x/cfg"), "/x/cfg/businesses/acme.json"); refuses(() => businessFilePath("../acme", "/x"), /business id/); }); test("the fixture business validates and comes back frozen", () => { const business = check(); assert.equal(business.id, "acme"); assert.deepEqual(Object.keys(business.roles), ["pm", "cto", "coder", "reviewer"]); assert.equal(business.roles.pm.holder, "sage"); assert.equal(business.roles.reviewer.holder, null); assert.equal(business.roles.coder.vars["limits.network"], "none"); assert.equal(business.definitions.pm.roleVersion, 2); assert.deepEqual(business.launch, { by: "pm", instances: ["coder", "reviewer"], max: { opus: 2, sonnet: 4 } }); assert.ok(Object.isFrozen(business.roles.pm.credentials.gitea)); assert.throws(() => { business.roles.pm.holder = "x"; }, TypeError); }); test("two instances may share a definition", () => { const business = check((doc) => { doc.roles.coder2 = { ...structuredClone(doc.roles.coder), tracker: { bot: "bot-acme-coder2", botId: 7 } }; }); assert.equal(business.roles.coder2.definition, "coder"); }); test("top-level refusals", () => { refuses(() => check((d) => { d.businessVersion = 2; }), /businessVersion/); refuses(() => check((d) => { d.owner = "x"; }), /unsupported business file key: "owner"/); refuses(() => check((d) => { d.id = "other"; }), /must match its filename/); refuses(() => check((d) => { d.id = "Acme"; }), /business id/); for (const key of ["human", "arbiters", "projects", "tracker", "roles"]) { refuses(() => check((d) => { delete d[key]; }), new RegExp(`requires "${key}"`)); } refuses(() => check((d) => { d.vars.harness = "pi"; }), /harness can't be set at the business layer/); refuses(() => check((d) => { d.vars.secret = "x"; }), /unknown variable "secret"/); }); test("arbiters and projects", () => { refuses(() => check((d) => { d.arbiters.technical = "ghost"; }), /arbiters\.technical names ghost/); refuses(() => check((d) => { delete d.arbiters.delivery; }), /arbiters\.delivery/); refuses(() => check((d) => { d.arbiters.final = "pm"; }), /unsupported .* arbiters key/); refuses(() => check((d) => { d.projects = {}; }), /at least one project/); refuses(() => check((d) => { d.projects.stack.root = "relative/path"; }), /normalized absolute path/); refuses(() => check((d) => { d.projects.stack.root += "/../x"; }), /normalized absolute path/); refuses(() => check((d) => { d.projects.stack.branch = "main"; }), /unsupported .* key: "branch"/); }); test("role instances", () => { refuses(() => check((d) => { d.roles = {}; }), /at least one role instance/); refuses(() => check((d) => { d.roles.pm.definition = "ghost"; }), /not found/, 4); refuses(() => check((d) => { d.roles.pm.definition = "researcher"; }), /version 1 and can't back/); refuses(() => check((d) => { d.roles.pm.token = "x"; }), /unsupported .*roles\.pm key: "token"/); refuses(() => check((d) => { d.roles.pm.holder = "Sage!"; }), /holder/); refuses(() => check((d) => { d.roles.pm.vars["tracker.baseUrl"] = "http://x"; }), /can't be set at the agent layer/); refuses(() => check((d) => { d.roles.pm.vars["limits.authority"] = ["deploy.prod"]; }), /unknown action/); }); test("Vikunja bots", () => { refuses(() => check((d) => { delete d.roles.pm.tracker; }), /needs "tracker"/); refuses(() => check((d) => { d.roles.pm.tracker.botId = 0; }), /botId must be a positive integer/); refuses(() => check((d) => { d.roles.pm.tracker.bot = "pm-bot"; }), /starting with "bot-"/); refuses(() => check((d) => { d.roles.cto.tracker.bot = d.roles.pm.tracker.bot; }), /used by another instance/); refuses(() => check((d) => { d.roles.cto.tracker.botId = d.roles.pm.tracker.botId; }), /botId 3 is used by another instance/); refuses(() => check((d) => { d.tracker.sync.bot = d.roles.pm.tracker.bot; }), /sync must use its own bot/); refuses(() => check((d) => { d.tracker.sync.botId = d.roles.cto.tracker.botId; }), /sync must use its own bot/); refuses(() => check((d) => { d.tracker.sync.botId = 0; }), /sync\.botId/); refuses(() => check((d) => { d.tracker.labels = { "needs-jason": 0 }; }), /labels\.needs-jason/); refuses(() => check((d) => { d.tracker.webhook = "x"; }), /unsupported .* tracker key/); assert.deepEqual(check((d) => { d.tracker.labels = { "needs-jason": 12 }; }).tracker.labels, { "needs-jason": 12 }); }); test("a role without Vikunja takes no tracker block", () => { const root = tmp(); const rolesDir = rolesCopy(root); const pm = JSON.parse(readFileSync(join(rolesDir, "pm.json"), "utf8")); pm.credentials = pm.credentials.filter((c) => c.service === "gitea"); writeJson(join(rolesDir, "pm.json"), pm); refuses(() => check(undefined, { rolesDir }), /has "tracker" but pm uses no vikunja credential/); refuses(() => check((d) => { delete d.roles.pm.tracker; }, { rolesDir }), /exactly the services its role definition needs \(gitea; got gitea, vikunja\)/); const business = check((d) => { delete d.roles.pm.tracker; delete d.roles.pm.credentials.vikunja; }, { rolesDir }); assert.equal(business.roles.pm.tracker, null); }); test("credential references match the definition's services", () => { refuses(() => check((d) => { delete d.roles.cto.credentials.gitea; }), /exactly the services .* \(gitea, vikunja; got vikunja\)/); refuses(() => check((d) => { d.roles.cto.credentials.github = { env: "X", rotateBy: "2099-01-01" }; }), /exactly the services/); refuses(() => check((d) => { d.roles.cto.credentials.gitea = { env: "X" }; }), /needs "rotateBy"/); refuses(() => check((d) => { d.roles.cto.credentials.vikunja.token = "tk_x"; }), /unsupported .* key: "token"/); refuses(() => check((d) => { d.tracker.sync.credentials = {}; }), /exactly the services .* \(vikunja; got none\)/); }); test("launch", () => { assert.equal(check((d) => { delete d.launch; }).launch, null); refuses(() => check((d) => { d.launch.by = "ghost"; }), /launch\.by names ghost/); refuses(() => check((d) => { d.launch.by = "cto"; d.launch.instances = ["coder"]; }), /launch\.by names cto, whose role cto doesn't hold role\.launch within-role/); refuses(() => check((d) => { d.launch.instances = []; }), /must not be empty/); refuses(() => check((d) => { d.launch.instances = ["coder", "coder"]; }), /duplicate/); refuses(() => check((d) => { d.launch.instances = ["ghost"]; }), /instances names ghost/); refuses(() => check((d) => { d.launch.instances = ["pm"]; }), /can't include the launcher itself/); refuses(() => check((d) => { d.launch.max = {}; }), /at least one model family/); refuses(() => check((d) => { d.launch.max = { glm: 1 }; }), /unknown model family "glm"/); refuses(() => check((d) => { d.launch.max = { opus: 5 }; }), /from 0 to 4/); refuses(() => check((d) => { d.launch.max = { sonnet: -1 }; }), /from 0 to 4/); refuses(() => check((d) => { d.launch.max = { opus: 1.5 }; }), /from 0 to 4/); refuses(() => check((d) => { d.launch.cap = 1; }), /unsupported .* launch key/); assert.deepEqual(check((d) => { d.launch.max = { opus: 0 }; }).launch.max, { opus: 0 }); }); test("loadBusiness: file checks", () => { const root = tmp(); const dir = join(root, "config"); refuses(() => loadBusiness("acme", { dir, rolesDir: REPO_ROLES }), /business file not found/, 4); const file = writeJson(join(dir, "businesses", "acme.json"), businessDoc(root)); assert.equal(loadBusiness("acme", { dir, rolesDir: REPO_ROLES }).file, file); chmodSync(file, 0o620); refuses(() => loadBusiness("acme", { dir, rolesDir: REPO_ROLES }), /writable by group or other \(mode 620\)/); chmodSync(file, 0o602); refuses(() => loadBusiness("acme", { dir, rolesDir: REPO_ROLES }), /writable by group or other/); chmodSync(file, 0o644); assert.equal(loadBusiness("acme", { dir, rolesDir: REPO_ROLES }).id, "acme"); writeJson(join(dir, "businesses", "real.json"), businessDoc(root, "linked")); symlinkSync("real.json", join(dir, "businesses", "linked.json")); refuses(() => loadBusiness("linked", { dir, rolesDir: REPO_ROLES }), /non-symbolic-link/, 4); writeFileSync(join(dir, "businesses", "broken.json"), "{ not json"); refuses(() => loadBusiness("broken", { dir, rolesDir: REPO_ROLES }), /not valid JSON/); // The owner and mode checks run on the opened file before the read, so // a group-writable file refuses for its mode even when it won't parse. chmodSync(join(dir, "businesses", "broken.json"), 0o660); refuses(() => loadBusiness("broken", { dir, rolesDir: REPO_ROLES }), /writable by group or other \(mode 660\)/); assert.throws(() => loadBusiness("acme", { dir }), /needs rolesDir/); }); // A directory or a FIFO under the business name refuses without a read. // Opening a FIFO without O_NONBLOCK would block the whole process, which // node:test can't time out, so that case runs in a child with a timeout. test("loadBusiness: not a regular file", () => { const dir = join(tmp(), "config"); mkdirSync(join(dir, "businesses", "folder.json"), { recursive: true }); refuses(() => loadBusiness("folder", { dir, rolesDir: REPO_ROLES }), /must be a regular, non-symbolic-link file/, 4); execFileSync("mkfifo", [join(dir, "businesses", "pipe.json")]); const index = pathToFileURL(join(REPO, "packages", "business", "src", "index.mjs")).href; const child = spawnSync(process.execPath, ["--input-type=module", "-e", ` import { loadBusiness } from ${JSON.stringify(index)}; try { loadBusiness("pipe", { dir: ${JSON.stringify(dir)}, rolesDir: ${JSON.stringify(REPO_ROLES)} }); } catch (error) { console.log(error.exitCode, error.message); } `], { encoding: "utf8", timeout: 5000 }); assert.equal(child.signal, null, "opening the FIFO waited for a writer"); assert.match(child.stdout, /^4 .*must be a regular, non-symbolic-link file/); }); test("loading writes nothing", () => { const root = tmp(); const dir = join(root, "config"); const file = writeJson(join(dir, "businesses", "acme.json"), businessDoc(root)); const listing = (d) => readdirSync(d, { recursive: true }).sort().join("\n"); const before = { tree: listing(root), mtime: lstatSync(file).mtimeMs, text: readFileSync(file, "utf8") }; loadBusiness("acme", { dir, rolesDir: REPO_ROLES }); assert.equal(listing(root), before.tree); assert.equal(lstatSync(file).mtimeMs, before.mtime); assert.equal(readFileSync(file, "utf8"), before.text); }); test("names that are Object.prototype properties don't count as declared", () => { refuses(() => check((d) => { d.arbiters.delivery = "constructor"; }), /arbiters\.delivery names constructor/); refuses(() => check((d) => { d.launch.by = "constructor"; }), /launch\.by names constructor/); refuses(() => check((d) => { d.launch.instances = ["constructor"]; }), /instances names constructor/); refuses(() => check((d) => { d.launch.max = { constructor: 1 }; }), /unknown model family "constructor"/); refuses(() => check((d) => { d.roles.pm.definition = "constructor"; }), /not found/, 4); const labels = check((d) => { d.tracker.labels = JSON.parse('{"__proto__": 5}'); }).tracker.labels; assert.equal(Object.getPrototypeOf(labels), Object.prototype); assert.equal(Object.getOwnPropertyDescriptor(labels, "__proto__").value, 5); }); test("the shipped example refuses as written and validates once filled in", () => { const example = readFileSync(join(REPO, "packages", "business", "examples", "mosaic-stack.example.json"), "utf8"); const root = tmp(); const file = join(root, "businesses", "mosaic-stack.json"); refuses(() => validateBusinessDocument(JSON.parse(example), file, { rolesDir: REPO_ROLES }), /botId must be a positive integer/); let id = 10; const filled = JSON.parse(example .replaceAll('"botId": 0', () => `"botId": ${id++}`) .replaceAll("YYYY-MM-DD", "2099-01-01") .replaceAll("/home/you", root)); const business = validateBusinessDocument(filled, file, { rolesDir: REPO_ROLES }); assert.deepEqual(Object.keys(business.roles), ["pm", "cto", "coder", "reviewer"]); assert.equal(business.tracker.sync.credentials.vikunja.file, join(root, ".config/mosaic-dev/secrets/mosaic-stack/sync-vikunja.token")); assert.equal(id, 15); });