import { test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { chmodSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { projectFilePath } from "../src/index.mjs"; import { businessDoc, REPO, rolesCopy, systemConfig, tmp, tokenFile, writeJson } from "./helpers.mjs"; const CLI = join(REPO, "packages", "business", "src", "cli.mjs"); // A complete scratch setup: system config, business file, roles copy. // HOME points into the scratch tree too, so nothing reads the real one. function setup(mutate) { const root = tmp(); const config = systemConfig(root); const doc = businessDoc(root); mutate?.(doc, root); writeJson(join(root, "config", "businesses", `${doc.id}.json`), doc); const env = { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: rolesCopy(root) }; return { root, doc, env }; } function run(s, ...args) { const proc = spawnSync(process.execPath, [CLI, ...args], { env: s.env, encoding: "utf8" }); return { code: proc.status, out: proc.stdout, err: proc.stderr }; } test("usage errors exit 4", () => { const s = setup(); assert.equal(run(s).code, 4); assert.equal(run(s, "show", "acme").code, 4); assert.equal(run(s, "validate").code, 4); assert.equal(run(s, "validate", "acme", "extra").code, 4); assert.equal(run(s, "resolve", "acme").code, 4); assert.equal(run(s, "resolve", "acme", "pm", "--project").code, 4); assert.equal(run(s, "resolve", "acme", "pm", "--project", "a", "--project", "b").code, 4); assert.match(run(s).err, /usage: mosaic business validate/); }); test("validate: a good business exits 0 and prints instance digests", () => { const s = setup(); const r = run(s, "validate", "acme"); assert.equal(r.code, 0, r.err); const out = JSON.parse(r.out); assert.equal(out.business, "acme"); assert.deepEqual(out.projects, { stack: "absent" }); assert.deepEqual(Object.keys(out.instances), ["pm", "cto", "coder", "reviewer"]); for (const digest of Object.values(out.instances)) assert.match(digest, /^[0-9a-f]{64}$/); assert.match(r.err, /warning: project stack: no project file/); assert.doesNotMatch(r.out + r.err, /placeholder-not-a-token/); }); test("validate: project files", () => { const s = setup(); const file = projectFilePath(s.doc.projects.stack.root); writeJson(file, { projectVersion: 1, id: "stack", vars: { "tracker.project": 3 } }); const r = run(s, "validate", "acme"); assert.equal(r.code, 0, r.err); assert.deepEqual(JSON.parse(r.out).projects, { stack: "valid" }); writeJson(file, { projectVersion: 1, id: "other" }); assert.match(run(s, "validate", "acme").err, /has id other, but business acme declares it as stack/); assert.equal(run(s, "validate", "acme").code, 2); writeJson(file, { projectVersion: 1, id: "stack", roles: { ghost: {} } }); const ghost = run(s, "validate", "acme"); assert.equal(ghost.code, 2); assert.match(ghost.err, /role instance ghost/); writeJson(file, { projectVersion: 1, id: "stack", vars: { harness: "pi" } }); assert.equal(run(s, "validate", "acme").code, 2); }); test("validate: missing files and a broken system config", () => { const s = setup(); const missing = run(s, "validate", "nobody"); assert.equal(missing.code, 4); assert.match(missing.err, /business file not found/); assert.equal(run(s, "validate", "Bad!").code, 2); const noConfig = { ...s, env: { ...s.env, MOSAIC_CONFIG: join(s.root, "absent", "config.json") } }; const r = run(noConfig, "validate", "acme"); assert.equal(r.code, 3); assert.match(r.err, /system config problem/); writeFileSync(s.env.MOSAIC_CONFIG, "{"); assert.equal(run(s, "validate", "acme").code, 3); }); test("validate: credential reference problems exit 2 and name each one", () => { const s = setup((doc, root) => { chmodSync(doc.roles.cto.credentials.gitea.file, 0o644); doc.roles.coder.credentials.vikunja.file = tokenFile(join(root, "data", "tokens"), "coder.token"); doc.roles.reviewer.credentials.vikunja.expires = "2026-01-01"; }); const r = run(s, "validate", "acme"); assert.equal(r.code, 2); assert.equal(r.out, ""); assert.match(r.err, /cto-gitea\.token: mode 644/); assert.match(r.err, /coder\.token: inside .*data/); assert.match(r.err, /expired on 2026-01-01/); assert.match(r.err, /3 credential reference problem/); }); test("validate: a token file inside the repository is refused", () => { const s = setup((doc) => { doc.roles.pm.credentials.gitea.file = join(REPO, "roles", "pm.md"); }); const r = run(s, "validate", "acme"); assert.equal(r.code, 2); assert.match(r.err, /inside .*; token files live outside the repository and dataRoot/); }); test("validate: role definitions come from MOSAIC_ROLES_DIR", () => { const s = setup(); const before = JSON.parse(run(s, "validate", "acme").out).instances; const pmFile = join(s.env.MOSAIC_ROLES_DIR, "pm.json"); const pm = JSON.parse(readFileSync(pmFile, "utf8")); pm.tools = pm.tools.filter((t) => t !== "write"); writeJson(pmFile, pm); const after = JSON.parse(run(s, "validate", "acme").out).instances; assert.notEqual(after.pm, before.pm); assert.equal(after.cto, before.cto); pm.authority.withinRole.push("deploy"); writeJson(pmFile, pm); const gated = run(s, "validate", "acme"); assert.equal(gated.code, 2); assert.match(gated.err, /always gated/); }); test("resolve: prints one instance's record", () => { const s = setup(); writeJson(projectFilePath(s.doc.projects.stack.root), { projectVersion: 1, id: "stack", vars: { "tracker.project": 3 }, roles: { coder: { vars: { "limits.tools": ["read", "bash"] } } }, }); const r = run(s, "resolve", "acme", "coder", "--project", "stack"); assert.equal(r.code, 0, r.err); const coder = JSON.parse(r.out); assert.equal(coder.project, "stack"); assert.equal(coder.vars["tracker.project"], 3); assert.deepEqual(coder.limits.tools, ["read", "bash"]); assert.equal(coder.limits.network, "none"); assert.equal(coder.contract, join(s.env.MOSAIC_ROLES_DIR, "coder.md")); assert.doesNotMatch(r.out, /placeholder-not-a-token/); const pm = JSON.parse(run(s, "resolve", "acme", "pm").out); assert.equal(pm.project, null); assert.ok(pm.limits.authority.withinRole.includes("role.launch")); const validated = JSON.parse(run(s, "validate", "acme").out).instances; assert.equal(pm.digest, validated.pm); }); test("resolve: refusals", () => { const s = setup((doc) => { chmodSync(doc.roles.reviewer.credentials.gitea.file, 0o604); }); assert.equal(run(s, "resolve", "acme", "pm").code, 0); const reviewer = run(s, "resolve", "acme", "reviewer"); assert.equal(reviewer.code, 2); assert.equal(reviewer.out, ""); assert.match(reviewer.err, /mode 604/); assert.equal(run(s, "resolve", "acme", "ghost").code, 2); assert.equal(run(s, "resolve", "acme", "constructor").code, 2); assert.match(run(s, "resolve", "acme", "pm", "--project", "ghost").err, /declares no project "ghost"/); assert.equal(run(s, "resolve", "acme", "pm", "--project", "stack").code, 4); });