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]>
160 lines
7.0 KiB
JavaScript
160 lines
7.0 KiB
JavaScript
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);
|
|
});
|