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.
108 lines
6.3 KiB
JavaScript
108 lines
6.3 KiB
JavaScript
// vikunja:19/302: a bad organization file keeps the bus down (fail closed) and says why. The status
|
|
// is written by `bus start` before it throws, readable with the bus down, and cleared by the next
|
|
// start that loads the file. No test reaches a running bus: a start with a good file stops at the
|
|
// missing notifier config, after the load was recorded.
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { existsSync, readFileSync, writeFileSync, statSync, mkdirSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { spawnSync } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
import { fixture, tmp } from "./helpers.mjs";
|
|
import { bootEventsFile, bootLine, bootStatusFile, readBootStatus, recordBootOk, recordBootRefused } from "../src/boot-status.mjs";
|
|
import { hostFile } from "../src/host.mjs";
|
|
import { reserveOrg, activateOrg } from "../../organization/src/org-registry.mjs";
|
|
|
|
const CLI = fileURLToPath(new URL("../src/cli.mjs", import.meta.url));
|
|
const run = (f, ...args) => spawnSync(process.execPath, [CLI, ...args], { env: f.env, encoding: "utf8", timeout: 40000 });
|
|
const businessFile = (f) => join(f.config.replace(/config\.json$/, ""), "businesses", "acme.json");
|
|
const write = (f, text) => writeFileSync(businessFile(f), text, { mode: 0o600 });
|
|
|
|
async function orgFixture(t) {
|
|
const root = tmp(t, "o-");
|
|
const f = fixture(root);
|
|
const configDir = join(root, ".config", "mosaic-boot");
|
|
const env = { ...f.env, HOME: root, MOSAIC_CONFIG: join(configDir, "config.json") };
|
|
const bootstrap = spawnSync(join(process.cwd(), "scripts/bootstrap.sh"), ["--instance", "boot"], { env, encoding: "utf8" });
|
|
assert.equal(bootstrap.status, 0, bootstrap.stderr);
|
|
mkdirSync(join(configDir, "businesses"), { recursive: true, mode: 0o700 });
|
|
writeFileSync(join(configDir, "businesses", "acme.json"), readFileSync(businessFile(f)), { mode: 0o600 });
|
|
const ctx = { configDir, dataRoot: join(root, ".mosaic-boot"), instance: "boot" };
|
|
const org = await reserveOrg(ctx, "acme");
|
|
await activateOrg(ctx, "acme");
|
|
return { ...f, env, config: env.MOSAIC_CONFIG, dataRoot: org.root };
|
|
}
|
|
|
|
test("recordBootRefused keeps the last good load across refusals; recordBootOk clears the refusal", (t) => {
|
|
const dataRoot = tmp(t);
|
|
assert.equal(readBootStatus(dataRoot), null);
|
|
assert.equal(bootLine(null), "boot: no start recorded");
|
|
recordBootRefused(dataRoot, { business: "acme", file: "/c/acme.json", reason: "unknown key", at: "2026-10-15T10:00:00.000Z" });
|
|
assert.deepEqual(readBootStatus(dataRoot), { ok: false, at: "2026-10-15T10:00:00.000Z", business: "acme", file: "/c/acme.json", reason: "unknown key", lastGood: null });
|
|
recordBootOk(dataRoot, { business: "acme", file: "/c/acme.json", at: "2026-10-15T11:00:00.000Z" });
|
|
assert.equal(readBootStatus(dataRoot).ok, true);
|
|
recordBootRefused(dataRoot, { business: "acme", file: "/c/acme.json", reason: "x".repeat(5000), at: "2026-10-15T12:00:00.000Z" });
|
|
recordBootRefused(dataRoot, { business: "acme", file: "/c/acme.json", reason: "still bad", at: "2026-10-15T12:30:00.000Z" });
|
|
const s = readBootStatus(dataRoot);
|
|
assert.deepEqual([s.ok, s.reason, s.lastGood], [false, "still bad", "2026-10-15T11:00:00.000Z"]);
|
|
assert.match(bootLine(s), /^boot: REFUSED at 2026-10-15T12:30:00\.000Z: bus down, organization file invalid: still bad \(file \/c\/acme\.json; last good load 2026-10-15T11:00:00\.000Z\)$/);
|
|
const events = readFileSync(bootEventsFile(dataRoot), "utf8").trim().split("\n").map(JSON.parse);
|
|
assert.deepEqual(events.map((e) => e.event), ["bus.boot.refused", "bus.boot.ok", "bus.boot.refused", "bus.boot.refused"]);
|
|
assert.equal(events[2].reason.length, 1000);
|
|
for (const file of [bootStatusFile(dataRoot), bootEventsFile(dataRoot)]) assert.equal(statSync(file).mode & 0o777, 0o600);
|
|
});
|
|
|
|
test("an unknown key and a JSON syntax error each leave the bus down with the reason and path in the status", async (t) => {
|
|
const f = await orgFixture(t);
|
|
const file = businessFile(f);
|
|
const doc = JSON.parse(readFileSync(file, "utf8"));
|
|
const cases = [
|
|
[JSON.stringify({ ...doc, surprise: 1 }), /surprise/],
|
|
['{"businessVersion": 1,', /not valid JSON|JSON/i],
|
|
];
|
|
for (const [text, reason] of cases) {
|
|
write(f, text);
|
|
const r = run(f, "bus", "start", "acme");
|
|
assert.equal(r.status, 3, r.stderr);
|
|
// Fail closed: no host, no lock, no socket, no sessions.
|
|
assert.equal(existsSync(hostFile(f.dataRoot)), false);
|
|
assert.equal(existsSync(join(f.dataRoot, "bus", "broker.sock")), false);
|
|
const s = readBootStatus(f.dataRoot);
|
|
assert.equal(s.ok, false);
|
|
assert.equal(s.business, "acme");
|
|
assert.equal(s.file, file);
|
|
assert.match(s.reason, reason);
|
|
// The reason names the file by label, never by host path (PR #1916); the path stays in `file`.
|
|
assert.ok(!s.reason.includes(file), s.reason);
|
|
assert.ok(/surprise/.test(s.reason) || /business file acme\.json is not valid JSON/.test(s.reason), s.reason);
|
|
// `bus status` shows it, in text and in JSON.
|
|
const out = run(f, "bus", "status", "acme", "--json");
|
|
assert.equal(out.status, 0, out.stderr);
|
|
const json = JSON.parse(out.stdout);
|
|
assert.equal(json.healthy, false);
|
|
assert.equal(json.boot.ok, false);
|
|
assert.equal(json.boot.reason, s.reason);
|
|
}
|
|
});
|
|
|
|
test("a missing file is recorded too, and a valid file clears the status on the next start", async (t) => {
|
|
const f = await orgFixture(t);
|
|
const file = businessFile(f);
|
|
const good = readFileSync(file, "utf8");
|
|
// A good load is recorded before the start stops at the missing notifier config.
|
|
assert.equal(run(f, "bus", "start", "acme").status, 3);
|
|
const first = readBootStatus(f.dataRoot);
|
|
assert.equal(first.ok, true);
|
|
write(f, '{"nope":');
|
|
assert.equal(run(f, "bus", "start", "acme").status, 3);
|
|
const bad = readBootStatus(f.dataRoot);
|
|
assert.equal(bad.ok, false);
|
|
assert.equal(bad.lastGood, first.at, "the time of the last good load survives the refusal");
|
|
write(f, good);
|
|
const r = run(f, "bus", "start", "acme");
|
|
assert.match(readFileSync(join(f.dataRoot, "bus-host", "host.log"), "utf8"), /no notifier config/);
|
|
const cleared = readBootStatus(f.dataRoot);
|
|
assert.deepEqual([cleared.ok, cleared.reason], [true, null]);
|
|
assert.equal(JSON.parse(run(f, "bus", "status", "acme", "--json").stdout).boot.ok, true);
|
|
});
|