Files
stack/packages/organization/tests/organization-config.test.mjs
jason.woltje 4c8bf9ca4e fix(organization,cli): refusals name the file by label, never by host path
PR #1916 rule: a refusal names the field or file, never a config value
or a host path. readJsonFile's three refusals and parseJsonFileText now
name the file by its `what` label only. Callers whose label alone is
ambiguous add a name that already passed a strict pattern (business id,
role name, org id, turn id, project key) or a fixed path under the data
root. Exit codes, refusal codes and check order are unchanged.

organization: util, access, project, config-command, library-seats,
org-service deleteOrg receipt, agent-levels regex; T1 files role.mjs and
organization-config.mjs change only the readJsonFile label.
cli: access lock, accounts owner, host state/lock, notifier journal,
notify-config, run-evidence, dev-fixture, git-socket, org-bus, org-console.
discord: turn record label names the turn id.

Sentinel tests (sk-test-NOT-A-SECRET-1234 as a path component) cover each
changed refusal and fail on BASE; old path pins updated.
2026-11-11 01:04:15 -06:00

388 lines
25 KiB
JavaScript

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 { THREAD_IDLE_SECONDS } from "../src/organization-config.mjs";
import { CREATIVE_DEFINITIONS } from "../src/vocabulary.mjs";
import { launchDoc, businessDoc, REPO, REPO_ROLES, rolesCopy, tmp, writeJson } from "./helpers.mjs";
test('C2-04: library mappings load without roots, remain frozen, and retain mapping refusals', async t => {
const { mappingEntry } = await import('../../library/tests/helpers.mjs');
const { rmSync } = await import('node:fs');
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const doc = businessDoc(root), file = join(root, 'businesses/acme.json');
doc.projects = { books: mappingEntry(), ...doc.projects };
doc.roles.chief.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } };
const loaded = validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES });
const { checkMapping } = await import('../../library/src/mapping.mjs');
assert.deepEqual(loaded.projects.books, checkMapping(mappingEntry()));
assert.equal(loaded.projects.books.root, undefined);
assert.ok(Object.isFrozen(loaded.projects.books.universes.harbor.books));
const only = structuredClone(doc); delete only.projects.stack;
assert.deepEqual(Object.keys(validateBusinessDocument(only, file, { rolesDir: REPO_ROLES }).projects), ['books']);
// C2-01c: a prefix outside every book is a universe-level playground; one inside another
// universe's book refuses.
doc.projects.books.universes.harbor.playground = ['Elsewhere/Playground/'];
assert.deepEqual(validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }).projects.books.universes.harbor.playground, ['Elsewhere/Playground/']);
doc.projects.books.universes.harbor.playground = ['Second World/Playground/'];
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-playground-other-universe/);
// C2-01d: a supporting prefix inside another universe's book refuses with its own code.
doc.projects.books = mappingEntry();
doc.projects.books.universes.harbor.supporting = ['Second World/Notes/'];
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-supporting-other-universe/);
doc.projects.books = { ...mappingEntry(), root: '/forbidden' };
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-mapping-invalid/);
});
test('C2-04: an otherwise valid publish grant cannot name a library repo', async t => {
const { mappingEntry } = await import('../../library/tests/helpers.mjs');
const { rmSync } = await import('node:fs');
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const doc = businessDoc(root);
doc.projects.books = { ...mappingEntry(), remote: 'https://git.example/writer/books' };
doc.roles.chief.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } };
doc.roles.coder.identity = { name: 'Coder', email: '[email protected]', gitea: { url: 'https://git.example', user: 'acme-coder' } };
doc.roles.coder.publish = ['books'];
refuses(() => validateBusinessDocument(doc, join(root, 'businesses/acme.json'), { rolesDir: REPO_ROLES }), /publish.*library/);
});
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", "chief"]);
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, { ...launchDoc(), threadIdleSeconds: THREAD_IDLE_SECONDS });
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", "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 = ["chief"]; }), /can't include the launcher itself/);
refuses(() => check((d) => { d.launch.models = {}; }), /at least one model/);
refuses(() => check((d) => { d.launch.models = { glm: {} }; }), /provider/);
assert.equal(check((d)=>{d.launch.limits.total=50;}).launch.limits.total,50);
refuses(() => check((d) => { d.launch.limits.total = -1; }), /whole number/);
refuses(() => check((d) => { d.launch.limits.total = 1.5; }), /whole number/);
refuses(() => check((d) => { d.launch.cap = 1; }), /unsupported .* launch key/);
assert.deepEqual(check((d) => { d.launch.limits = {total:0}; }).launch.limits, {total:0});
});
// REQ-THREAD-2 (row 121, test 10): an absent threadIdleSeconds is the one
// exported default, a set value is used, and anything but a positive integer
// refuses. No upper bound (Q150).
test("REQ-THREAD-2 REQ-LAUNCH-1 launch.threadIdleSeconds: absent is the default, a positive integer is used, anything else refuses", () => {
assert.equal(THREAD_IDLE_SECONDS, 900);
assert.equal(check().launch.threadIdleSeconds, 900);
assert.equal(check((d) => { d.launch.threadIdleSeconds = 60; }).launch.threadIdleSeconds, 60);
for (const bad of [0, -1, 1.5, "900", null]) {
refuses(() => check((d) => { d.launch.threadIdleSeconds = bad; }), /launch\.threadIdleSeconds must be a positive integer/);
}
assert.equal(check((d) => { d.launch.threadIdleSeconds = Number.MAX_SAFE_INTEGER; }).launch.threadIdleSeconds, Number.MAX_SAFE_INTEGER);
});
test("loadBusiness: file checks", () => {
const root = tmp();
const dir = join(root, "config");
refuses(() => loadBusiness("acme", { dir, rolesDir: REPO_ROLES }), /business file acme\.json 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", "organization", "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.roles.pm.vars.model = "constructor"; }), /variable model selects an unknown model/);
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", "organization", "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", "chief", "assistant"]);
// Row 133 (#1627): the arbiters declare no messaging lists, so the limited assistant beside them passes.
assert.deepEqual(business.arbiters, { delivery: "pm", technical: "cto" });
assert.equal(business.tracker.sync.credentials.vikunja.file, join(root, ".config/mosaic-dev/secrets/mosaic-stack/sync-vikunja.token"));
assert.equal(id, 15);
});
// Row 285 (C2-08b, #1790, mos-1330): a creative definition declares gitea, so its instance names
// the reference. The store form needs no token file, and the bus reads a missing one as no token.
const CREATIVE_GITEA = Object.freeze({ gitea: Object.freeze({ store: true, rotateBy: "2099-01-01" }) });
// Row 213 (C2-08, #1714), deviation D1: the persona vars sit on a creative instance only, and
// both or neither. The var checks themselves are vars.mjs's.
test("persona vars: agent layer, creative definition only, both or neither", () => {
const sha = "a".repeat(64);
const persona = { "persona.name": "Bean", "persona.sha256": sha };
const business = check((d) => { d.roles.bean = { definition: "companion", credentials: CREATIVE_GITEA, vars: { ...persona } }; });
assert.deepEqual({ ...business.roles.bean.vars }, persona);
assert.doesNotThrow(() => check((d) => { d.roles.bean = { definition: "ideas", credentials: CREATIVE_GITEA }; }));
refuses(() => check((d) => { d.vars["persona.name"] = "Bean"; }), /persona\.name can't be set at the business layer/);
refuses(() => check((d) => { d.roles.pm.vars = { ...d.roles.pm.vars, ...persona }; }),
/roles\.pm\.vars: persona vars need a creative definition \(pm isn't one\)/);
refuses(() => check((d) => { d.roles.chief.vars = { ...persona }; }), /chief-of-staff isn't one/);
for (const key of Object.keys(persona)) {
refuses(() => check((d) => { d.roles.bean = { definition: "research", credentials: CREATIVE_GITEA, vars: { [key]: persona[key] } }; }),
/roles\.bean\.vars: persona\.name and persona\.sha256 are set together or not at all/);
}
for (const [key, value] of [["persona.name", "x".repeat(41)], ["persona.name", "Be\nan"], ["persona.name", "Be\u0007an"], ["persona.name", "Be\u2028an"], ["persona.name", "Be\u2029an"], ["persona.name", 7],
["persona.sha256", "A".repeat(64)], ["persona.sha256", sha.slice(1)], ["persona.sha256", `${sha}0`]]) {
refuses(() => check((d) => { d.roles.bean = { definition: "companion", credentials: CREATIVE_GITEA, vars: { ...persona, [key]: value } }; }), new RegExp(`variable ${key.replace(".", "\\.")}`), 2);
}
});
// Row 285 (C2-08b, #1790, mos-1330): the chief and each creative definition declare gitea, so an
// instance without the reference refuses. Amendment 1 (mos-1580, sage-4060): the chief
// requires its reference only in a library business; creative definitions always require it.
test("row 285: a chief or creative instance without credentials.gitea refuses at load", async () => {
const { mappingEntry } = await import("../../library/tests/helpers.mjs");
refuses(() => check((d) => { d.projects.books = mappingEntry(); delete d.roles.chief.credentials; }),
/chief-library-gitea-required: .*roles\.chief\.credentials/);
for (const definition of CREATIVE_DEFINITIONS) {
refuses(() => check((d) => { d.roles.bean = { definition }; }),
/roles\.bean\.credentials must reference exactly the services its role definition needs \(gitea; got none\)/);
assert.doesNotThrow(() => check((d) => { d.roles.bean = { definition, credentials: CREATIVE_GITEA }; }), definition);
}
});
// Row 285 (C2-08b, brief): a creative instance may carry identity and publish, under the rules at
// organization-config.mjs checkRoles (identity and publish), which the grant path reads. The grant itself still refuses it (#giteaRole).
test("row 285: a creative instance with identity and publish loads", () => {
const identity = { name: "Bean", email: "[email protected]", gitea: { url: "https://git.example", user: "acme-bean" } };
const business = check((d) => {
d.projects.stack.remote = "https://git.example/acme/stack";
d.roles.bean = { definition: "companion", credentials: CREATIVE_GITEA, identity, publish: ["stack"] };
});
assert.deepEqual({ ...business.roles.bean.identity, gitea: { ...business.roles.bean.identity.gitea } }, identity);
assert.deepEqual([...business.roles.bean.publish], ["stack"]);
});
// Row 194 (#1692, D2.5, T17): the per-agent forbid and the retired list.
test("row 194 keep, noHire and retired validate, and a file without them loads as before", () => {
const plain = check();
assert.equal(Object.hasOwn(plain.launch, "keep"), false);
assert.equal(Object.hasOwn(plain.launch, "noHire"), false);
assert.equal(Object.hasOwn(plain, "retired"), false);
const business = check((d) => {
d.launch.keep = ["coder", "chief"];
d.launch.noHire = ["reviewer", "no-such-role"];
d.retired = ["old-coder"];
});
assert.deepEqual(business.launch.keep, ["coder", "chief"]);
// A stale noHire entry is harmless: only the id pattern is checked.
assert.deepEqual(business.launch.noHire, ["reviewer", "no-such-role"]);
assert.deepEqual(business.retired, ["old-coder"]);
assert.ok(Object.isFrozen(business.launch.keep) && Object.isFrozen(business.retired));
assert.deepEqual(check((d) => { d.launch.keep = []; d.launch.noHire = []; d.retired = []; }).launch.keep, []);
});
test("row 194 each bad keep, noHire or retired entry refuses", () => {
refuses(() => check((d) => { d.launch.keep = ["ghost"]; }), /launch\.keep names ghost, which the business file doesn't declare/);
refuses(() => check((d) => { d.launch.keep = ["coder", "coder"]; }), /duplicate entry in .*launch\.keep/);
refuses(() => check((d) => { d.launch.keep = "coder"; }), /launch\.keep must be an array/);
refuses(() => check((d) => { d.roles.assistant = { definition: "assistant" }; d.launch.keep = ["assistant"]; }),
/launch\.keep names assistant, whose definition assistant can't be kept/);
refuses(() => check((d) => { d.launch.noHire = ["Bad Name"]; }), /launch\.noHire entry/);
refuses(() => check((d) => { d.launch.noHire = ["coder", "coder"]; }), /duplicate entry in .*launch\.noHire/);
refuses(() => check((d) => { d.retired = ["coder"]; }), /retired names coder, which roles still declares/);
refuses(() => check((d) => { d.retired = ["x", "x"]; }), /duplicate entry in .*retired/);
refuses(() => check((d) => { d.retired = ["../x"]; }), /retired entry/);
refuses(() => check((d) => { d.retired = {}; }), /retired must be an array/);
});
// C2-21 B1-B4: catch a deleted/overbroad library publish fence while retaining identity/credential rules.
test('C2-21: only creative definitions and the chief with gitea may publish a library project', async (t) => {
const { mappingEntry } = await import('../../library/tests/helpers.mjs');
for (const definition of [...CREATIVE_DEFINITIONS, 'chief-of-staff']) {
const mutate = (d) => {
d.projects.books = { ...mappingEntry(), remote: 'https://git.example/writer/books' };
d.roles.chief.credentials = CREATIVE_GITEA;
const agent = definition === 'chief-of-staff' ? 'chief' : 'bean';
d.roles[agent] = { ...(d.roles[agent] ?? {}), definition, credentials: CREATIVE_GITEA,
identity: { name: 'Library reader', email: '[email protected]', gitea: { url: 'https://git.example', user: 'acme-reader' } }, publish: ['books'] };
};
const business = check(mutate);
assert.deepEqual(business.roles[definition === 'chief-of-staff' ? 'chief' : 'bean'].publish, ['books']);
refuses(() => check((d) => { mutate(d); delete d.roles[definition === 'chief-of-staff' ? 'chief' : 'bean'].identity; }), /publish needs identity and a gitea definition/);
}
for (const agent of ['coder', 'reviewer', 'pm', 'cto']) {
refuses(() => check((d) => {
d.projects.books = { ...mappingEntry(), remote: 'https://git.example/writer/books' };
d.roles.chief.credentials = CREATIVE_GITEA;
d.roles[agent].identity = { name: 'Other', email: '[email protected]', gitea: { url: 'https://git.example', user: 'acme-other' } };
d.roles[agent].publish = ['books'];
}), /publish.*library.*creative.*chief/);
}
});