// 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)); });