584 lines
29 KiB
JavaScript
584 lines
29 KiB
JavaScript
// Case 2 row C2-13b (#1727): scripts/case2-install.sh installs a hub as <user>, and
|
||
// scripts/case2-precheck.sh's library checks and --library-status mode. Brief:
|
||
// docs/plans/2026-10-20_case2-rows.md "C2-13b". Every case runs in a scratch home with its own
|
||
// MOSAIC_CONFIG, a stub stack-copy and fake getent, sudo, id, node, bwrap and systemctl. The library
|
||
// sync runs in-process against a local bare repo, as the bus would. Nothing here reads or writes
|
||
// ~/.mosaic-dev, ~/.config/mosaic-dev, /etc or a network host, and no fixture value is a credential.
|
||
import { test } from "node:test";
|
||
import assert from "node:assert/strict";
|
||
import { spawnSync } from "node:child_process";
|
||
import { createHash } from "node:crypto";
|
||
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
||
import { dirname, join } from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import { businessDoc, writeJson } from "../../business/tests/helpers.mjs";
|
||
import { BASE, MARKER, git, mappingEntry, repo } from "../../library/tests/helpers.mjs";
|
||
import { ensureMirror, mirrorPath, sync } from "../../library/src/index.mjs";
|
||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||
import { SAFE } from "../../project/src/git.mjs";
|
||
import { tmp } from "./helpers.mjs";
|
||
|
||
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
|
||
const INSTALL = join(REPO, "scripts", "case2-install.sh");
|
||
const PRECHECK = join(REPO, "scripts", "case2-precheck.sh");
|
||
const COMMIT = "c".repeat(40);
|
||
const UID = process.getuid();
|
||
const GID = process.getgid();
|
||
// Made-up values; no output may hold them.
|
||
const FIXTURE_KEY = "fixture-zai-key-8d41a7";
|
||
const PERSONA_TEXT = { bean: "Bean is warm and brief. PERSONA-TEXT-bean-61f0\n", chet: "Chet is dry and exact. PERSONA-TEXT-chet-2b9e\n" };
|
||
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
||
const mode = (p) => statSync(p).mode & 0o777;
|
||
|
||
function script(file, text) {
|
||
mkdirSync(dirname(file), { recursive: true });
|
||
writeFileSync(file, `#!/usr/bin/env bash\n${text}\n`);
|
||
chmodSync(file, 0o755);
|
||
return file;
|
||
}
|
||
|
||
// A scratch <user>: a home, an input business file, a persona staging dir and fakes.
|
||
function world(t, { profile = "library" } = {}) {
|
||
const root = tmp(t, "case2-install-");
|
||
const home = join(root, "home");
|
||
const configDir = join(home, ".config", "mosaic-dev");
|
||
// claude-session.env is in place before the install, as 2d would leave it on a re-run.
|
||
const secrets = join(configDir, "secrets");
|
||
mkdirSync(secrets, { recursive: true, mode: 0o700 });
|
||
chmodSync(configDir, 0o700);
|
||
writeFileSync(join(secrets, "claude-session.env"), `ZAI_API_KEY=${FIXTURE_KEY}\n`, { mode: 0o600 });
|
||
const doc = businessDoc(root);
|
||
for (const r of Object.values(doc.roles)) r.vars = { ...r.vars, harness: "pi" };
|
||
if (profile === "library") {
|
||
doc.projects.books = { ...mappingEntry(), user: "writer" };
|
||
doc.roles.chief.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } };
|
||
doc.roles.bean = { definition: "companion", vars: { harness: "pi" } };
|
||
doc.roles.bean.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } };
|
||
doc.roles.chet = { definition: "companion", vars: { harness: "pi" } };
|
||
doc.roles.chet.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } };
|
||
} else doc.vars["bus.tester"] = true;
|
||
const input = writeJson(join(root, "input", "acme.json"), doc);
|
||
const staging = join(root, "staging");
|
||
mkdirSync(staging, { mode: 0o700 });
|
||
const personas = [];
|
||
for (const [instance, name] of [["bean", "Bean"], ["chet", "Chet"]]) {
|
||
writeFileSync(join(staging, `${instance}.md`), PERSONA_TEXT[instance], { mode: 0o600 });
|
||
personas.push({ instance, name, bytes: Buffer.byteLength(PERSONA_TEXT[instance]), sha256: sha256(PERSONA_TEXT[instance]) });
|
||
}
|
||
writeFileSync(join(staging, "manifest.json"), `${JSON.stringify({ version: 1, personas }, null, 2)}\n`, { mode: 0o600 });
|
||
const bin = join(root, "bin");
|
||
const log = join(root, "calls.log");
|
||
script(join(bin, "stack-copy"), `echo "stack-copy $*" >>'${log}'
|
||
[ "$1" = copy ] && [ "$3" = --copies ] || exit 4
|
||
mkdir -p "$4/$2/scripts" && : >"$4/$2/scripts/mosaic" && echo "copied: $4/$2"
|
||
for file in scripts/case2-persona-import.mjs packages/harness/src/bundle.mjs packages/harness/src/tools.mjs packages/organization/src/rails.mjs packages/organization/src/autonomy.mjs packages/bus/src/refusals.mjs packages/business/src/vocabulary.mjs packages/business/src/selector.mjs packages/organization/src/vocabulary.mjs packages/organization/src/selector.mjs packages/plugins/model-provider/cliproxy/index.mjs; do
|
||
mkdir -p "$4/$2/$(dirname "$file")" && cp '${REPO}'"$file" "$4/$2/$file" || exit 1
|
||
done
|
||
mkdir -p "$4/$2/node_modules" && cp -R '${REPO}node_modules/yaml' "$4/$2/node_modules/yaml"`);
|
||
script(join(bin, "systemctl"), `echo "systemctl $*" >>'${log}'`);
|
||
const me = spawnSync("id", ["-un"], { encoding: "utf8" }).stdout.trim();
|
||
script(join(bin, "getent"), `[ "$1" = passwd ] && [ "$2" = '${me}' ] && echo '${me}:x:${UID}:${GID}::${home}:/bin/bash'`);
|
||
script(join(bin, "sudo"), "exit 1");
|
||
script(join(bin, "id"), `if [ "$1" = -G ]; then echo '${GID}'; elif [ "$1" = -u ] && [ -n "\${CASE2_TEST_UID:-}" ]; then echo "$CASE2_TEST_UID"; else exec /usr/bin/id "$@"; fi`);
|
||
script(join(bin, "node"), `if [ "$1" = --version ]; then echo v24.3.0; exit 0; fi\nexec '${process.execPath}' "$@"`);
|
||
script(join(bin, "bwrap"), "exit 0");
|
||
const units = join(root, "units");
|
||
mkdirSync(units);
|
||
writeFileSync(join(units, "mosaic-webui.service"), "[Service]\nExecStart=/usr/bin/node /x/cli.mjs serve --port 443 --business acme\n");
|
||
writeFileSync(join(units, "[email protected]"), "[Service]\nEnvironment=PATH=/usr/bin\n");
|
||
const sys = join(root, "sys");
|
||
mkdirSync(join(sys, "net", "ipv4"), { recursive: true });
|
||
mkdirSync(join(sys, "kernel"), { recursive: true });
|
||
writeFileSync(join(sys, "net", "ipv4", "ip_unprivileged_port_start"), "1024\n");
|
||
writeFileSync(join(sys, "kernel", "apparmor_restrict_unprivileged_userns"), "1\n");
|
||
const notify = join(home, ".mosaic-dev", "notify", "acme");
|
||
mkdirSync(notify, { recursive: true, mode: 0o700 });
|
||
writeFileSync(join(notify, "notify.json"), '{"notifyVersion":1,"binding":null}\n', { mode: 0o600 });
|
||
return {
|
||
root, home, configDir, secrets, input, staging, bin, log, units, sys, doc,
|
||
config: join(configDir, "config.json"),
|
||
business: join(configDir, "businesses", "acme.json"),
|
||
personaDir: join(configDir, "personas", "acme"),
|
||
slot: join(secrets, "acme"),
|
||
dataRoot: join(home, ".mosaic-dev"),
|
||
};
|
||
}
|
||
|
||
function env(x, extra = {}) {
|
||
return { PATH: `${x.bin}:/usr/bin:/bin`, HOME: x.home, MOSAIC_CONFIG: x.config, CASE2_STACK_COPY: join(x.bin, "stack-copy"), ...extra };
|
||
}
|
||
|
||
function clean(out) {
|
||
assert.doesNotMatch(out, new RegExp(FIXTURE_KEY), "the output holds the fixture key");
|
||
assert.doesNotMatch(out, /PERSONA-TEXT/, "the output holds persona text");
|
||
assert.doesNotMatch(out, new RegExp(MARKER), "the output holds her text");
|
||
}
|
||
|
||
function install(x, { profile = "library", args = null, extra = {} } = {}) {
|
||
const argv = args ?? ["--business", "acme", "--business-file", x.input, "--commit", COMMIT, "--profile", profile,
|
||
...(profile === "library" ? ["--personas", x.staging] : [])];
|
||
const r = spawnSync(INSTALL, argv, { encoding: "utf8", timeout: 120000, env: env(x, extra) });
|
||
const out = r.stdout + r.stderr;
|
||
clean(out);
|
||
return { code: r.status, out };
|
||
}
|
||
|
||
function precheck(x, { args = ["--business", "acme", "--profile", "library"] } = {}) {
|
||
const r = spawnSync(PRECHECK, args, {
|
||
encoding: "utf8",
|
||
timeout: 60000,
|
||
env: env(x, { CASE2_UNIT_DIR: x.units, CASE2_PROC_SYS: x.sys, CASE2_GETENT: join(x.bin, "getent") }),
|
||
});
|
||
const out = r.stdout + r.stderr;
|
||
clean(out);
|
||
return { code: r.status, out };
|
||
}
|
||
const status = (x) => precheck(x, { args: ["--business", "acme", "--library-status"] });
|
||
|
||
function refused(r, pattern) {
|
||
assert.equal(r.code, 1, r.out);
|
||
assert.match(r.out, pattern);
|
||
}
|
||
|
||
const calls = (x) => (existsSync(x.log) ? readFileSync(x.log, "utf8") : "");
|
||
|
||
// ---- case2-install.sh ----
|
||
|
||
test("the library install writes the business file, personas and slot dir, makes the copy and starts nothing", (t) => {
|
||
const x = world(t);
|
||
const r = install(x);
|
||
assert.equal(r.code, 0, r.out);
|
||
assert.equal(mode(join(x.dataRoot, '.deployed')), 0o444);
|
||
assert.deepEqual(JSON.parse(readFileSync(join(x.dataRoot, '.deployed'), 'utf8')), { instance: 'dev' });
|
||
assert.equal(mode(x.business), 0o600);
|
||
const doc = JSON.parse(readFileSync(x.business, "utf8"));
|
||
for (const [instance, name] of [["bean", "Bean"], ["chet", "Chet"]]) {
|
||
assert.equal(doc.roles[instance].vars["persona.name"], name);
|
||
assert.equal(doc.roles[instance].vars["persona.sha256"], sha256(PERSONA_TEXT[instance]));
|
||
assert.equal(doc.roles[instance].vars.harness, "pi");
|
||
const file = join(x.personaDir, `${instance}.md`);
|
||
assert.equal(readFileSync(file, "utf8"), PERSONA_TEXT[instance]);
|
||
assert.equal(mode(file), 0o600);
|
||
}
|
||
assert.equal(mode(join(x.configDir, "personas")), 0o700);
|
||
assert.equal(mode(x.personaDir), 0o700);
|
||
assert.equal(mode(x.slot), 0o700);
|
||
assert.deepEqual(readdirSync(x.slot), []);
|
||
assert.equal(calls(x), `stack-copy copy ${COMMIT} --copies ${join(x.home, ".local", "share", "mosaic-stack", "copies")}\n`);
|
||
assert.doesNotMatch(r.out, /release/);
|
||
assert.match(r.out, /business acme validates/);
|
||
assert.match(r.out, /--library-status/);
|
||
assert.ok(existsSync(x.config));
|
||
});
|
||
|
||
test("a library re-run passes and leaves every file as it was", (t) => {
|
||
const x = world(t);
|
||
assert.equal(install(x).code, 0);
|
||
const before = readFileSync(x.business);
|
||
const r = install(x);
|
||
assert.equal(r.code, 0, r.out);
|
||
assert.match(r.out, /already matches/);
|
||
assert.match(r.out, /personas already installed/);
|
||
assert.deepEqual(readFileSync(x.business), before);
|
||
});
|
||
|
||
test("a re-run refuses business-file-differs and leaves the file unchanged", (t) => {
|
||
const x = world(t);
|
||
assert.equal(install(x).code, 0);
|
||
const doc = JSON.parse(readFileSync(x.business, "utf8"));
|
||
doc.vars["tracker.pollSeconds"] = 120;
|
||
writeJson(x.business, doc);
|
||
const before = readFileSync(x.business);
|
||
refused(install(x), /business-file-differs/);
|
||
assert.deepEqual(readFileSync(x.business), before);
|
||
});
|
||
|
||
test("a re-run refuses persona-differs when an installed persona isn't the staged one, and writes nothing", (t) => {
|
||
const x = world(t);
|
||
assert.equal(install(x).code, 0);
|
||
rmSync(x.business);
|
||
writeFileSync(join(x.personaDir, "bean.md"), "Someone else. PERSONA-TEXT-other\n");
|
||
refused(install(x), /persona-differs/);
|
||
assert.equal(existsSync(x.business), false);
|
||
});
|
||
|
||
test("the factory install writes no library mapping, persona or slot dir", (t) => {
|
||
const x = world(t, { profile: "factory" });
|
||
const r = install(x, { profile: "factory" });
|
||
assert.equal(r.code, 0, r.out);
|
||
const doc = JSON.parse(readFileSync(x.business, "utf8"));
|
||
assert.equal(Object.values(doc.projects).some((p) => p.kind === "library"), false);
|
||
assert.equal(existsSync(join(x.configDir, "personas")), false);
|
||
assert.equal(existsSync(x.slot), false);
|
||
assert.doesNotMatch(r.out, /--library-status/);
|
||
});
|
||
|
||
test("the factory install refuses a library project and bus.tester off, before anything is written", (t) => {
|
||
const x = world(t, { profile: "factory" });
|
||
const doc = JSON.parse(readFileSync(x.input, "utf8"));
|
||
doc.projects.books = { ...mappingEntry(), user: "writer" };
|
||
doc.roles.chief.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } };
|
||
writeJson(x.input, doc);
|
||
refused(install(x, { profile: "factory" }), /profile-library-business/);
|
||
delete doc.projects.books;
|
||
doc.vars["bus.tester"] = false;
|
||
writeJson(x.input, doc);
|
||
refused(install(x, { profile: "factory" }), /bus-tester-off/);
|
||
assert.equal(calls(x), "");
|
||
assert.equal(existsSync(x.business), false);
|
||
});
|
||
|
||
test("the library install refuses bus.tester on and a persona var in the input", (t) => {
|
||
const x = world(t);
|
||
const doc = JSON.parse(readFileSync(x.input, "utf8"));
|
||
doc.vars["bus.tester"] = true;
|
||
writeJson(x.input, doc);
|
||
refused(install(x), /bus-tester-on/);
|
||
doc.vars["bus.tester"] = false;
|
||
doc.roles.bean.vars["persona.name"] = "Bean";
|
||
writeJson(x.input, doc);
|
||
refused(install(x), /persona-var-input/);
|
||
assert.equal(calls(x), "");
|
||
});
|
||
|
||
test("the install refuses root, a business id mismatch, another --home and a config dir with group bits", (t) => {
|
||
const x = world(t);
|
||
refused(install(x, { extra: { CASE2_TEST_UID: "0" } }), /run-as-root/);
|
||
refused(install(x, { args: ["--business", "other", "--business-file", x.input, "--commit", COMMIT, "--personas", x.staging] }), /business-id-mismatch/);
|
||
refused(install(x, { args: ["--business", "acme", "--business-file", x.input, "--commit", COMMIT, "--personas", x.staging, "--home", x.root] }), /home-mismatch/);
|
||
chmodSync(x.configDir, 0o750);
|
||
refused(install(x), /config-dir-mode/);
|
||
assert.equal(calls(x), "");
|
||
assert.equal(existsSync(x.business), false);
|
||
});
|
||
|
||
// The selectors (rename design r2): --org/--business and --org-file/--business-file are two
|
||
// families, each its own scope. A bad selector refuses with exit 4 before anything is read or written.
|
||
const rest = (x) => ["--commit", COMMIT, "--profile", "library", "--personas", x.staging];
|
||
|
||
test("the install takes --org and --org-file, alone or matching their old spelling in either order", (t) => {
|
||
for (const sel of [
|
||
(f) => ["--org", "acme", "--org-file", f],
|
||
(f) => ["--business", "acme", "--org", "acme", "--business-file", f, "--org-file", f],
|
||
(f) => ["--org", "acme", "--business", "acme", "--org-file", f, "--business-file", f],
|
||
]) {
|
||
const x = world(t);
|
||
const r = install(x, { args: [...sel(x.input), ...rest(x)] });
|
||
assert.equal(r.code, 0, r.out);
|
||
assert.equal(JSON.parse(readFileSync(x.business, "utf8")).id, "acme");
|
||
assert.equal(calls(x), `stack-copy copy ${COMMIT} --copies ${join(x.home, ".local", "share", "mosaic-stack", "copies")}\n`);
|
||
}
|
||
});
|
||
|
||
test("the install refuses a bad selector in either family before it reads or writes anything", (t) => {
|
||
const x = world(t);
|
||
const f = x.input, g = `${x.root}/input/../input/acme.json`; // join() would normalize it to f
|
||
assert.notEqual(g, f);
|
||
const usage = "usage: case2-install.sh --business ID --business-file FILE --commit SHA [--profile factory|library] [--personas DIR] [--home DIR]\n";
|
||
const notId = (v) => `case2-install: --business must be a business id: ${v}\n`;
|
||
for (const [args, out] of [
|
||
[["--business", "acme", "--org", "beta", "--business-file", f], "case2-install: --business acme and --org beta conflict\n"],
|
||
[["--org", "acme", "--business", "beta", "--business-file", f], "case2-install: --org acme and --business beta conflict\n"],
|
||
[["--business", "acme", "--business", "acme", "--business-file", f], "case2-install: --business given twice\n"],
|
||
[["--org", "acme", "--org", "acme", "--business-file", f], "case2-install: --org given twice\n"],
|
||
[["--business-file", f, "--org"], usage],
|
||
[["--business-file", f, "--business"], usage],
|
||
[["--org", "", "--business-file", f], usage],
|
||
[["--org", "--business-file", f], notId("--business-file")],
|
||
[["--business", "-x", "--business-file", f], notId("-x")],
|
||
[["--org", "Acme", "--business-file", f], notId("Acme")],
|
||
[["--org", "a.b", "--business-file", f], notId("a.b")],
|
||
// The file family compares the text given: the same file named another way still conflicts.
|
||
[["--org", "acme", "--business-file", f, "--org-file", g], `case2-install: --business-file ${f} and --org-file ${g} conflict\n`],
|
||
[["--org", "acme", "--org-file", g, "--business-file", f], `case2-install: --org-file ${g} and --business-file ${f} conflict\n`],
|
||
[["--org", "acme", "--org-file", f, "--org-file", f], "case2-install: --org-file given twice\n"],
|
||
[["--org", "acme", "--business-file", f, "--business-file", f], "case2-install: --business-file given twice\n"],
|
||
[["--org", "acme", "--org-file"], usage],
|
||
[["--org", "acme", "--org-file", ""], usage],
|
||
[["--org", "acme", "--org-file", "--commit", COMMIT], "case2-install: --org-file needs one value, not --commit\n"],
|
||
]) {
|
||
const last = ["--business-file", "--business", "--org", "--org-file"].includes(args.at(-1));
|
||
const r = install(x, { args: last ? args : [...args, ...rest(x)] });
|
||
assert.equal(r.code, 4, args.join(" "));
|
||
assert.equal(r.out, out, args.join(" "));
|
||
}
|
||
// A selector spelling taken as another option's value is that option's text.
|
||
const r = install(x, { args: ["--org", "acme", "--business-file", f, "--commit", "--org", "--profile", "library", "--personas", x.staging] });
|
||
assert.equal(r.code, 4, r.out);
|
||
assert.equal(r.out, "case2-install: --commit must be a full 40-character commit id: --org\n");
|
||
assert.equal(calls(x), "");
|
||
assert.equal(existsSync(x.business), false);
|
||
});
|
||
|
||
test("the library install needs --personas", (t) => {
|
||
const x = world(t);
|
||
const r = install(x, { args: ["--business", "acme", "--business-file", x.input, "--commit", COMMIT] });
|
||
assert.equal(r.code, 4, r.out);
|
||
assert.match(r.out, /needs --personas/);
|
||
});
|
||
|
||
test("a validate refusal removes the business file this run wrote", (t) => {
|
||
const x = world(t);
|
||
unlinkSync(x.doc.roles.pm.credentials.gitea.file);
|
||
const r = install(x);
|
||
refused(r, /validate: .*the business file this run wrote is removed/);
|
||
assert.equal(existsSync(x.business), false);
|
||
});
|
||
|
||
test("N4: a validate refusal on a re-run leaves the matching business file in place, byte for byte", (t) => {
|
||
const x = world(t);
|
||
assert.equal(install(x).code, 0);
|
||
const before = readFileSync(x.business);
|
||
unlinkSync(x.doc.roles.pm.credentials.gitea.file);
|
||
const r = install(x);
|
||
refused(r, /validate: /);
|
||
assert.doesNotMatch(r.out, /is removed/);
|
||
assert.deepEqual(readFileSync(x.business), before);
|
||
});
|
||
|
||
// ---- case2-precheck.sh, the library profile (D6a) ----
|
||
|
||
test("a clean library host passes the precheck with no mirror yet", (t) => {
|
||
const x = world(t);
|
||
assert.equal(install(x).code, 0);
|
||
const r = precheck(x);
|
||
assert.equal(r.code, 0, r.out);
|
||
assert.match(r.out, /ok {6}business file .* with no group or other bit/);
|
||
assert.match(r.out, /ok {6}no history for acme yet/);
|
||
assert.match(r.out, new RegExp(`ok {6}persona bean sha256 ${sha256(PERSONA_TEXT.bean)}`));
|
||
assert.match(r.out, new RegExp(`ok {6}persona chet sha256 ${sha256(PERSONA_TEXT.chet)}`));
|
||
assert.match(r.out, /ok {6}no Discord binding/);
|
||
assert.equal(existsSync(mirrorPath({ business: { id: "acme", projects: { books: x.doc.projects.books } }, project: "books", dataRoot: x.dataRoot })), false);
|
||
});
|
||
|
||
test("the library precheck refuses a persona file at 0644", (t) => {
|
||
const x = world(t);
|
||
assert.equal(install(x).code, 0);
|
||
chmodSync(join(x.personaDir, "bean.md"), 0o644);
|
||
refused(precheck(x), /REFUSED persona-mode: .*bean\.md must be .* 0600 file \(mode 644\)/);
|
||
});
|
||
|
||
test("the library precheck refuses a persona whose digest isn't persona.sha256", (t) => {
|
||
const x = world(t);
|
||
assert.equal(install(x).code, 0);
|
||
writeFileSync(join(x.personaDir, "chet.md"), "Not Chet. PERSONA-TEXT-other\n");
|
||
refused(precheck(x), /REFUSED persona-mismatch: persona chet has sha256 [0-9a-f]{64}, and persona.sha256 is /);
|
||
});
|
||
|
||
test("the library precheck refuses a persona dir with group bits", (t) => {
|
||
const x = world(t);
|
||
assert.equal(install(x).code, 0);
|
||
chmodSync(x.personaDir, 0o750);
|
||
refused(precheck(x), /REFUSED persona-mode: .*personas\/acme must be .* 0700 directory/);
|
||
});
|
||
|
||
test("the library precheck refuses a business file at 0644", (t) => {
|
||
const x = world(t);
|
||
assert.equal(install(x).code, 0);
|
||
chmodSync(x.business, 0o644);
|
||
refused(precheck(x), /REFUSED business-file-mode: .*acme\.json .*\(mode 644\)/);
|
||
});
|
||
|
||
test("the library precheck refuses a changes.jsonl at 0644", (t) => {
|
||
const x = world(t);
|
||
assert.equal(install(x).code, 0);
|
||
const history = join(x.configDir, "businesses", ".history", "acme");
|
||
mkdirSync(history, { recursive: true, mode: 0o700 });
|
||
writeFileSync(join(history, "changes.jsonl"), "", { mode: 0o644 });
|
||
chmodSync(join(history, "changes.jsonl"), 0o644);
|
||
refused(precheck(x), /REFUSED history-mode: .*changes\.jsonl must be .* 0600 file \(mode 644\)/);
|
||
chmodSync(join(history, "changes.jsonl"), 0o600);
|
||
assert.equal(precheck(x).code, 0);
|
||
});
|
||
|
||
test("the library precheck refuses a Discord binding", (t) => {
|
||
const x = world(t);
|
||
assert.equal(install(x).code, 0);
|
||
mkdirSync(join(x.dataRoot, "discord"), { recursive: true });
|
||
writeFileSync(join(x.dataRoot, "discord", "her.json"), "{}\n", { mode: 0o600 });
|
||
refused(precheck(x), /REFUSED discord-binding: .*her\.json exists/);
|
||
});
|
||
|
||
// ---- case2-precheck.sh --library-status (D6b) ----
|
||
|
||
// The context the bus builds for books (runtime.mjs:161-164), and a sync the way the bus's timer
|
||
// runs it, against a local bare clone through the command parameter.
|
||
function library(x) {
|
||
const context = { business: { id: "acme", projects: { books: x.doc.projects.books } }, project: "books", dataRoot: x.dataRoot };
|
||
mkdirSync(x.dataRoot, { recursive: true, mode: 0o700 });
|
||
chmodSync(x.dataRoot, 0o700);
|
||
const run = (bare, slot) => sync(context, {
|
||
slot,
|
||
command: (kind, url, { mirror, branch }) => ({
|
||
argv: [...SAFE, "-c", "protocol.file.allow=always", "-c", "core.createObject=rename", "-C", mirror, "fetch", "--no-tags",
|
||
"--no-write-fetch-head", "--end-of-options", bare, `+refs/heads/${branch}:refs/remotes/library/${branch}`],
|
||
env: { ...gitEnv(), LC_ALL: "C", GIT_CEILING_DIRECTORIES: dirname(mirror) },
|
||
}),
|
||
});
|
||
return { context, run };
|
||
}
|
||
|
||
test("--library-status refuses library-mirror-missing before the bus has made the mirror", (t) => {
|
||
const x = world(t);
|
||
assert.equal(install(x).code, 0);
|
||
refused(status(x), /REFUSED library-mirror-missing: library books has no mirror at .*the bus makes it when it starts/);
|
||
});
|
||
|
||
test("--library-status refuses library-never-synced on a fresh mirror", async (t) => {
|
||
const x = world(t);
|
||
assert.equal(install(x).code, 0);
|
||
await ensureMirror(library(x).context);
|
||
const r = status(x);
|
||
refused(r, /REFUSED library-never-synced: library books has no fetch yet/);
|
||
assert.doesNotMatch(r.out, /library-last-error/);
|
||
});
|
||
|
||
test("--library-status names a recorded lastError before the null lastFetch it leaves (N3)", async (t) => {
|
||
const x = world(t);
|
||
assert.equal(install(x).code, 0);
|
||
const lib = library(x);
|
||
await lib.run("/nonexistent", join(x.slot, "library-gitea"));
|
||
const r = status(x);
|
||
refused(r, /REFUSED library-last-error: library books: library-credential-missing at \d{4}-\d\d-\d\dT/);
|
||
assert.doesNotMatch(r.out, /library-never-synced/);
|
||
});
|
||
|
||
test("--library-status prints each R-problem's path as an escaped JSON string, then the count, and passes", async (t) => {
|
||
const x = world(t);
|
||
assert.equal(install(x).code, 0);
|
||
const odd = "OddName\u0085.md";
|
||
const f = repo(t, { ...BASE, [odd]: `${MARKER}\n` });
|
||
const bare = join(f.root, "remote.git");
|
||
git(f.root, "clone", "-q", "--bare", "--no-local", f.repo, bare);
|
||
writeFileSync(join(x.slot, "library-gitea"), "placeholder-not-a-token\n", { mode: 0o600 });
|
||
const { state } = await library(x).run(bare, join(x.slot, "library-gitea"));
|
||
assert.equal(state.lastError, null);
|
||
const r = status(x);
|
||
assert.equal(r.code, 0, r.out);
|
||
assert.match(r.out, new RegExp(`ok {6}library books last fetched \\S+, serving ${f.commit}`));
|
||
assert.match(r.out, /^problem {3}R2 "Inbox Note\.md"$/m);
|
||
assert.match(r.out, /^problem {3}R\d+ "Odd\\u202eName\\u0085\.md"$/m);
|
||
assert.doesNotMatch(r.out, /[\u0085]/);
|
||
const lines = r.out.split("\n").filter((l) => l.startsWith("problem "));
|
||
assert.equal(lines.length, state.problems.length);
|
||
assert.match(r.out, new RegExp(`^problems ${state.problems.length}$`, "m"));
|
||
assert.match(r.out, /case2-precheck: library status passed/);
|
||
});
|
||
|
||
test("--library-status takes no --profile", (t) => {
|
||
const x = world(t);
|
||
const r = precheck(x, { args: ["--business", "acme", "--library-status", "--profile", "library"] });
|
||
assert.equal(r.code, 4, r.out);
|
||
});
|
||
|
||
test("Vikunja 614: install preserves the explicit notifier decision bytes and mode and creates none when absent", (t) => {
|
||
const x = world(t);
|
||
const file = join(x.dataRoot, "notify", "acme", "notify.json");
|
||
const before = readFileSync(file), beforeMode = mode(file);
|
||
const r = install(x);
|
||
assert.equal(r.code, 0, r.out);
|
||
assert.deepEqual(readFileSync(file), before);
|
||
assert.equal(mode(file), beforeMode);
|
||
const absent = world(t), missing = join(absent.dataRoot, "notify", "acme", "notify.json");
|
||
rmSync(missing);
|
||
const fresh = install(absent);
|
||
assert.equal(fresh.code, 0, fresh.out);
|
||
assert.equal(existsSync(missing), false);
|
||
});
|
||
|
||
// Row 322 (#1818), F8/F9: a clone without dependencies and a dependency-equipped copy.
|
||
import { cpSync } from "node:fs";
|
||
|
||
function dependencyFreeClone(t) {
|
||
const x = world(t);
|
||
const clone = join(x.root, "clone");
|
||
mkdirSync(join(clone, "scripts"), { recursive: true });
|
||
for (const file of ["case2-install.sh", "case2-persona-import.mjs", "selector.sh"]) {
|
||
cpSync(join(REPO, "scripts", file), join(clone, "scripts", file));
|
||
}
|
||
mkdirSync(join(clone, 'packages/cli/src'), { recursive: true });
|
||
cpSync(join(REPO, 'packages/cli/src/deployment.mjs'), join(clone, 'packages/cli/src/deployment.mjs'));
|
||
writeJson(x.config, { dataRoot: x.dataRoot });
|
||
// Isolate the persona step; bootstrap and business validation are covered above.
|
||
script(join(clone, "scripts", "bootstrap.sh"), "exit 0");
|
||
script(join(clone, "scripts", "mosaic"), "exit 0");
|
||
assert.equal(existsSync(join(clone, "node_modules")), false);
|
||
const run = () => {
|
||
const r = spawnSync(join(clone, "scripts", "case2-install.sh"), [
|
||
"--business", "acme", "--business-file", x.input, "--commit", COMMIT,
|
||
"--profile", "library", "--personas", x.staging,
|
||
], { env: env(x), encoding: "utf8", timeout: 30000 });
|
||
const out = r.stdout + r.stderr;
|
||
clean(out);
|
||
return { code: r.status, out };
|
||
};
|
||
return { ...x, clone, run };
|
||
}
|
||
|
||
test("row 322 F8: library personas install from the copy with no clone node_modules", (t) => {
|
||
const x = dependencyFreeClone(t);
|
||
const r = x.run();
|
||
assert.equal(r.code, 0, r.out);
|
||
for (const instance of ["bean", "chet"]) {
|
||
assert.equal(readFileSync(join(x.personaDir, `${instance}.md`), "utf8"), PERSONA_TEXT[instance]);
|
||
assert.equal(mode(join(x.personaDir, `${instance}.md`)), 0o600);
|
||
}
|
||
assert.equal(existsSync(join(x.clone, "node_modules")), false);
|
||
});
|
||
|
||
// Without selector.sh beside it the install refuses before parsing, rather than skip the selector rules.
|
||
test("the install refuses when its selector.sh is missing, before it reads or writes anything", (t) => {
|
||
const x = dependencyFreeClone(t);
|
||
rmSync(join(x.clone, "scripts", "selector.sh"));
|
||
const r = x.run();
|
||
assert.equal(r.code, 4, r.out);
|
||
assert.ok(r.out.endsWith(`case2-install: cannot read ${join(x.clone, "scripts", "selector.sh")}\n`), r.out);
|
||
assert.equal(existsSync(x.business), false);
|
||
});
|
||
|
||
test("row 322 F9: importer refusal keeps the error line rather than the Node banner", (t) => {
|
||
const x = dependencyFreeClone(t);
|
||
const copy = join(x.home, ".local", "share", "mosaic-stack", "copies", COMMIT);
|
||
// Real Node module-load error: the error precedes its stack and version banner.
|
||
const fake = join(x.bin, "stack-copy");
|
||
writeFileSync(fake, `${readFileSync(fake, "utf8")}\nrm -rf '${copy}/node_modules/yaml'\n`);
|
||
const r = x.run();
|
||
refused(r, /persona-import: Error \[ERR_MODULE_NOT_FOUND\]: Cannot find package 'yaml'/);
|
||
const refusal = r.out.split("\n").filter((line) => line.includes("refused:"));
|
||
assert.equal(refusal.length, 1, r.out);
|
||
assert.doesNotMatch(r.out, /Node\.js v|\n\s+at /);
|
||
assert.equal(existsSync(x.business), false);
|
||
assert.equal(existsSync(x.personaDir), false);
|
||
});
|
||
|
||
test('C2-21: library install supplies chief identity and books publish for its real read grant', (t) => {
|
||
const x = world(t);
|
||
const out = install(x);
|
||
assert.equal(out.code, 0, out.out);
|
||
const doc = JSON.parse(readFileSync(x.business, 'utf8'));
|
||
assert.deepEqual(doc.roles.chief.publish, ['books']);
|
||
assert.ok(doc.roles.chief.identity?.name);
|
||
assert.ok(doc.roles.chief.identity?.email);
|
||
assert.equal(doc.roles.chief.identity?.gitea?.url, new URL(doc.projects.books.remote).origin);
|
||
assert.ok(doc.roles.chief.identity?.gitea?.user);
|
||
assert.notEqual(doc.roles.chief.identity?.gitea?.user, 'writer');
|
||
});
|
||
|
||
|
||
test('row334 install Next tells root to select proxy only when configured', t => {
|
||
const x = world(t), r = install(x);
|
||
assert.equal(r.code, 0, r.out);
|
||
assert.match(r.out, /Next: root runs case2-unit.sh[^\n]+add --proxy when the config has a proxy section/);
|
||
});
|
||
|
||
test('M3 install Next names the SSO, accounts-init and dedicated-uid steps before root re-pins with --sso', t => {
|
||
const x = world(t), r = install(x);
|
||
assert.equal(r.code, 0, r.out);
|
||
const done = r.out.split('\n').findIndex(l => l.startsWith('case2-install: done. .deployed names this instance'));
|
||
assert.ok(done >= 0, r.out);
|
||
assert.match(r.out.split('\n')[done], /in order \(run sheet section 7\): prepare console\.sso and its client secret slots in .+; a human runs `mosaic accounts init` as [^;]+; [^ ]+ stays a dedicated uid with a non-login shell, the only member of its primary group\.$/);
|
||
assert.match(r.out.split('\n')[done + 1], /^case2-install: Next: root runs case2-unit\.sh --copy [0-9a-f]{40} --business \S+ with --sso in the serve flags \(factory: --sso --send;/);
|
||
// The installer names the human step; it never runs it.
|
||
assert.equal(existsSync(join(x.configDir, 'accounts.json')), false);
|
||
});
|