git mv packages/business -> packages/organization (@mosaic/organization). The old package stays as one-line re-export shims of each moved module, with src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs forwarding to the moved executable. BusinessError is OrganizationConfigError (one constructor, name kept BusinessError); OrganizationError stays distinct. The bus adapter gains organization-context.mjs (busOrganization), with business.mjs (busBusiness) as the same function. Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure walker (organization-imports.mjs, both roots required) move to the new path. v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage A contract tests stay where they were and pass as before.
111 lines
5.2 KiB
JavaScript
111 lines
5.2 KiB
JavaScript
// Case 2 M7/M8 (design 5.1, row 220): loadSeatSets reads every business file in
|
|
// the config directory. A seat is a library seat when a business with a library
|
|
// project names it; a creative seat is a library seat whose definition is creative.
|
|
// Any file it can't read or validate throws, and so does a businesses path that is
|
|
// not a directory. An absent businesses directory gives empty sets.
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync, chmodSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { loadSeatSets } from "../src/library-seats.mjs";
|
|
import { BusinessError } from "../src/index.mjs";
|
|
import { businessDoc, writeJson, REPO_ROLES } from "./helpers.mjs";
|
|
import { mappingEntry } from "../../library/tests/helpers.mjs";
|
|
|
|
function fixture(t) {
|
|
const root = mkdtempSync(join(tmpdir(), "row220-seats-"));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const dir = join(root, "config");
|
|
mkdirSync(join(dir, "businesses"), { recursive: true });
|
|
return { root, dir };
|
|
}
|
|
|
|
const sets = (dir) => loadSeatSets({ dir, rolesDir: REPO_ROLES });
|
|
|
|
test("M7/M8: library seats come from library businesses, creative seats from their creative definitions", (t) => {
|
|
const { root, dir } = fixture(t);
|
|
const doc = businessDoc(root, "acme");
|
|
doc.projects = { books: mappingEntry(), ...doc.projects };
|
|
doc.roles.chief.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } };
|
|
doc.roles.bean = { definition: "companion" };
|
|
doc.roles.bean.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } };
|
|
writeJson(join(dir, "businesses", "acme.json"), doc);
|
|
const found = sets(dir);
|
|
assert.deepEqual([...found.librarySeats].sort(), ["bean", "chief", "coder", "cto", "pm", "reviewer"]);
|
|
assert.deepEqual([...found.creativeSeats], ["bean"]);
|
|
assert.ok([...found.creativeSeats].every((seat) => found.librarySeats.has(seat)));
|
|
});
|
|
|
|
test("M8: a business with no library project puts no seat in either set", (t) => {
|
|
const { root, dir } = fixture(t);
|
|
const doc = businessDoc(root, "other");
|
|
doc.roles.bean = { definition: "companion" };
|
|
doc.roles.bean.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } };
|
|
writeJson(join(dir, "businesses", "other.json"), doc);
|
|
const found = sets(dir);
|
|
assert.equal(found.librarySeats.size, 0);
|
|
assert.equal(found.creativeSeats.size, 0);
|
|
});
|
|
|
|
test("M7/M8: a business file that is not valid JSON throws; no empty set", (t) => {
|
|
const { root, dir } = fixture(t);
|
|
writeJson(join(dir, "businesses", "acme.json"), businessDoc(root, "acme"));
|
|
const broken = join(dir, "businesses", "broken.json");
|
|
writeFileSync(broken, "{");
|
|
chmodSync(broken, 0o600);
|
|
assert.throws(() => sets(dir), BusinessError);
|
|
});
|
|
|
|
test("M7/M8: an absent businesses directory gives empty sets", (t) => {
|
|
const root = mkdtempSync(join(tmpdir(), "row220-seats-none-"));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const found = sets(join(root, "config"));
|
|
assert.equal(found.librarySeats.size, 0);
|
|
assert.equal(found.creativeSeats.size, 0);
|
|
});
|
|
|
|
test("M7/M8: a businesses path that is not a directory throws; no empty set", (t) => {
|
|
const root = mkdtempSync(join(tmpdir(), "row220-seats-file-"));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const dir = join(root, "config");
|
|
mkdirSync(dir);
|
|
writeFileSync(join(dir, "businesses"), "not a directory");
|
|
assert.throws(() => sets(dir), (err) => err instanceof BusinessError && /cannot list businesses/.test(err.message));
|
|
});
|
|
|
|
test("M7/M8: a businesses symlink to a missing target throws; no empty set", (t) => {
|
|
const root = mkdtempSync(join(tmpdir(), "row220-seats-dangling-"));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const dir = join(root, "config");
|
|
mkdirSync(dir);
|
|
symlinkSync(join(root, "missing"), join(dir, "businesses"));
|
|
assert.throws(() => sets(dir), (err) => err instanceof BusinessError && /cannot list businesses/.test(err.message));
|
|
});
|
|
|
|
test("M7/M8: a config directory under a regular file throws (lstat fails with ENOTDIR); no empty set", (t) => {
|
|
const root = mkdtempSync(join(tmpdir(), "row220-seats-notdir-"));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const dir = join(root, "config");
|
|
writeFileSync(dir, "not a directory");
|
|
assert.throws(() => sets(dir), (err) => err instanceof BusinessError && /cannot list businesses/.test(err.message));
|
|
});
|
|
|
|
test("M7/M8: a business file that can't be read throws; no empty set", (t) => {
|
|
const { root, dir } = fixture(t);
|
|
writeJson(join(dir, "businesses", "acme.json"), businessDoc(root, "acme"));
|
|
const file = join(dir, "businesses", "acme.json");
|
|
chmodSync(file, 0o000);
|
|
t.after(() => {
|
|
// The fixture's rmSync hook can run first, so the file may already be gone.
|
|
try {
|
|
chmodSync(file, 0o600);
|
|
} catch (err) {
|
|
if (err.code !== "ENOENT") throw err;
|
|
}
|
|
});
|
|
// Root reads a mode 000 file, so the check cannot hold there.
|
|
if (process.getuid?.() === 0) return t.skip("running as root: mode 000 files stay readable");
|
|
assert.throws(() => sets(dir), (err) => err instanceof BusinessError && /must be a regular, non-symbolic-link file/.test(err.message));
|
|
});
|