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.
170 lines
11 KiB
JavaScript
170 lines
11 KiB
JavaScript
// REQ-PLUG-1 / REQ-ROLE-3 (step 5 write path): the `plugins` key in the organization
|
|
// file, written only through the host setter and logged like a limit change.
|
|
import test, { after } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { businessDoc, rolesCopy, tmp as makeRoot, writeJson } from "./helpers.mjs";
|
|
import { validateBusinessDocument } from "../src/organization-config.mjs";
|
|
import { pluginSettings } from "../src/plugin-settings.mjs";
|
|
import { OrganizationError } from "../src/organization.mjs";
|
|
import { BusinessError } from "../src/errors.mjs";
|
|
|
|
const roots = [];
|
|
after(() => roots.forEach(root => rmSync(root, { recursive: true, force: true })));
|
|
|
|
function fixture() {
|
|
const root = makeRoot();
|
|
roots.push(root);
|
|
const rolesDir = rolesCopy(root);
|
|
const doc = businessDoc(root, "acme");
|
|
const file = writeJson(join(root, "businesses", "acme.json"), doc);
|
|
return { root, rolesDir, doc, file, settings: pluginSettings({ dir: root, rolesDir }) };
|
|
}
|
|
|
|
const events = f => existsSync(f.settings.writer.logFile("acme"))
|
|
? readFileSync(f.settings.writer.logFile("acme"), "utf8").trim().split("\n").map(JSON.parse) : [];
|
|
|
|
test("REQ-PLUG-1 schema: plugins is optional, names known kinds and checked module names", () => {
|
|
const f = fixture();
|
|
const check = d => validateBusinessDocument(d, f.file, { rolesDir: f.rolesDir });
|
|
assert.deepEqual(check(f.doc).plugins, {});
|
|
const on = { ...structuredClone(f.doc), plugins: { "usage-source": ["meter"], "limit-check": [] } };
|
|
const business = check(on);
|
|
assert.deepEqual(business.plugins, on.plugins);
|
|
assert.ok(Object.isFrozen(business.plugins));
|
|
assert.ok(!Object.isFrozen(on.plugins), "the document itself stays mutable");
|
|
for (const plugins of [{ "made-up": [] }, { "usage-source": "meter" }, { "usage-source": ["Meter"] }, { "usage-source": ["a", "a"] }, []]) {
|
|
assert.throws(() => check({ ...structuredClone(f.doc), plugins }), BusinessError, JSON.stringify(plugins));
|
|
}
|
|
});
|
|
|
|
test("REQ-PLUG-1 REQ-ROLE-3 the setter writes plugins through the organization writer and logs old and new names", () => {
|
|
const f = fixture();
|
|
const view = f.settings.read("acme");
|
|
assert.deepEqual(view.plugins, {});
|
|
const done = f.settings.set("acme", { revision: view.revision, plugins: { "usage-source": ["meter"] } }, "jason");
|
|
assert.deepEqual(JSON.parse(readFileSync(f.file, "utf8")).plugins, { "usage-source": ["meter"] });
|
|
assert.deepEqual(done.plugins, { "usage-source": ["meter"] });
|
|
assert.notEqual(done.revision, view.revision);
|
|
let last = events(f).at(-1);
|
|
assert.equal(last.state, "committed");
|
|
assert.equal(last.actor, "jason");
|
|
assert.equal(last.transaction, done.transaction);
|
|
assert.deepEqual(last.changes, [{ path: "plugins", old: null, new: { "usage-source": ["meter"] }, oldPresent: false, newPresent: true }]);
|
|
f.settings.set("acme", { revision: done.revision, plugins: { "usage-source": ["meter", "other"], "limit-check": ["cap"] } }, "jason");
|
|
last = events(f).at(-1);
|
|
assert.deepEqual(last.changes.map(c => [c.path, c.old, c.new]), [
|
|
["plugins.usage-source", ["meter"], ["meter", "other"]],
|
|
["plugins.limit-check", null, ["cap"]],
|
|
]);
|
|
// A null-prototype object is still a plain JSON object.
|
|
const bare = Object.assign(Object.create(null), { "limit-check": ["cap"] });
|
|
f.settings.set("acme", { revision: f.settings.read("acme").revision, plugins: bare }, "jason");
|
|
// The patch names limit-check only, so usage-source keeps its setting (vikunja:19/309).
|
|
assert.deepEqual(JSON.parse(readFileSync(f.file, "utf8")).plugins, { "usage-source": ["meter", "other"], "limit-check": ["cap"] });
|
|
});
|
|
|
|
test("vikunja:19/309 a patch changes only the kinds it names; null removes one kind, explicitly and in the trail", () => {
|
|
const f = fixture();
|
|
let view = f.settings.read("acme");
|
|
view = f.settings.set("acme", { revision: view.revision, plugins: { "usage-source": ["meter"], "limit-check": ["cap"] } }, "jason");
|
|
view = f.settings.set("acme", { revision: view.revision, plugins: { "limit-check": ["cap", "other"] } }, "jason");
|
|
assert.deepEqual(view.plugins, { "usage-source": ["meter"], "limit-check": ["cap", "other"] }, "usage-source untouched");
|
|
assert.deepEqual(events(f).at(-1).changes.map(c => c.path), ["plugins.limit-check"], "only the named kind is in the receipt");
|
|
view = f.settings.set("acme", { revision: view.revision, plugins: { "usage-source": null } }, "jason");
|
|
assert.deepEqual(view.plugins, { "limit-check": ["cap", "other"] }, "only usage-source removed");
|
|
assert.deepEqual(events(f).at(-1).changes, [{ path: "plugins.usage-source", old: ["meter"], new: null, oldPresent: true, newPresent: false }]);
|
|
// Removing a kind that isn't set changes nothing in the file's plugins and is not an error.
|
|
view = f.settings.set("acme", { revision: view.revision, plugins: { "usage-source": null } }, "jason");
|
|
assert.deepEqual(view.plugins, { "limit-check": ["cap", "other"] });
|
|
});
|
|
|
|
test("vikunja:19/309 an unknown kind (set or removed), a bad module, an empty patch and null on the wrong shape are refused, nothing written", () => {
|
|
const f = fixture();
|
|
const { revision } = f.settings.read("acme");
|
|
const bytes = readFileSync(f.file, "utf8");
|
|
for (const plugins of [{ "made-up": null }, { "made-up": ["x"] }, {}, { "usage-source": null, "made-up": ["x"] }, { "usage-source": ["Bad"], "limit-check": null }]) {
|
|
assert.throws(() => f.settings.set("acme", { revision, plugins }, "jason"), e => e instanceof OrganizationError && e.code === "invalid-plugins", JSON.stringify(plugins));
|
|
}
|
|
const strict = pluginSettings({ dir: f.root, rolesDir: f.rolesDir, exists: () => false });
|
|
assert.throws(() => strict.set("acme", { revision, plugins: { "usage-source": ["ghost"], "limit-check": null } }, "jason"), e => e.code === "unknown-plugin");
|
|
assert.equal(readFileSync(f.file, "utf8"), bytes);
|
|
assert.deepEqual(events(f), []);
|
|
});
|
|
|
|
test("REQ-PLUG-1 REQ-ROLE-3 the writer checks the bytes it writes: a Date in plugins is refused there too", () => {
|
|
// Without the setter's own check, the organization writer still refuses:
|
|
// it validates JSON.parse of the bytes, where the Date is a string.
|
|
const f = fixture();
|
|
const { revision } = f.settings.read("acme");
|
|
const bytes = readFileSync(f.file, "utf8");
|
|
assert.throws(() => f.settings.writer.write("acme", { ...structuredClone(f.doc), plugins: new Date(0) }, { revision, actor: "jason" }),
|
|
BusinessError);
|
|
assert.equal(readFileSync(f.file, "utf8"), bytes);
|
|
assert.deepEqual(events(f), []);
|
|
});
|
|
|
|
test("REQ-PLUG-1 REQ-ROLE-3 a refused setter write throws a code, changes nothing and logs nothing", () => {
|
|
const f = fixture();
|
|
const { revision } = f.settings.read("acme");
|
|
const bytes = readFileSync(f.file, "utf8");
|
|
const refusals = [
|
|
[{ revision, plugins: { "made-up": ["x"] } }, "invalid-plugins"],
|
|
[{ revision, plugins: { "usage-source": ["Bad"] } }, "invalid-plugins"],
|
|
[{ revision, plugins: { "usage-source": "meter" } }, "invalid-plugins"],
|
|
[{ revision, plugins: { "limit-check": ["cap", "cap"] } }, "invalid-plugins"],
|
|
[{ revision }, "invalid-plugins"],
|
|
[{ revision, plugins: [] }, "invalid-plugins"],
|
|
// No own keys, so checkPlugins alone passes them; JSON would write something else.
|
|
[{ revision, plugins: new Date(0) }, "invalid-plugins"],
|
|
[{ revision, plugins: new Map([["usage-source", ["meter"]]]) }, "invalid-plugins"],
|
|
[{ revision, plugins: {}, limits: {} }, "invalid-request"],
|
|
[{ revision: "nope", plugins: {} }, "invalid-request"],
|
|
[{ plugins: {} }, "invalid-request"],
|
|
[null, "invalid-request"],
|
|
[["x"], "invalid-request"],
|
|
];
|
|
for (const [args, expected] of refusals) {
|
|
assert.throws(() => f.settings.set("acme", args, "jason"),
|
|
e => e instanceof OrganizationError && e.code === expected, JSON.stringify(args));
|
|
}
|
|
assert.equal(readFileSync(f.file, "utf8"), bytes);
|
|
assert.deepEqual(events(f), []);
|
|
});
|
|
|
|
test("vikunja:19/305 with an exists check, the setter refuses a module no tree file answers to, before anything is written", () => {
|
|
const f = fixture();
|
|
const settings = pluginSettings({ dir: f.root, rolesDir: f.rolesDir, exists: (kind, name) => kind === "usage-source" && name === "meter" });
|
|
const view = settings.read("acme");
|
|
const before = events(f).length;
|
|
for (const plugins of [{ "usage-source": ["ghost"] }, { "limit-check": ["cap"] }, { "usage-source": ["meter", "ghost"] }]) {
|
|
assert.throws(() => settings.set("acme", { revision: view.revision, plugins }, "jason"), e => e instanceof OrganizationError && e.code === "unknown-plugin", JSON.stringify(plugins));
|
|
}
|
|
assert.equal(events(f).length, before);
|
|
assert.deepEqual(settings.set("acme", { revision: view.revision, plugins: { "usage-source": ["meter"] } }, "jason").plugins, { "usage-source": ["meter"] });
|
|
});
|
|
|
|
test('P5 optional usage kind and pollSeconds configure through plugins.set; default stays absent and retention bound is strict', () => {
|
|
const f=fixture();let v=f.settings.read('acme');assert.equal(v.plugins.usage,undefined);assert.equal(v.usage,undefined);
|
|
v=f.settings.set('acme',{revision:v.revision,plugins:{usage:'cliproxy-queue'},usage:{cliproxyQueue:{pollSeconds:7}}},'jason');
|
|
assert.equal(v.plugins.usage,'cliproxy-queue');assert.equal(v.usage.cliproxyQueue.pollSeconds,7);
|
|
for(const pollSeconds of [0,60,61,-1,1.5,'15',null]) {
|
|
const before=readFileSync(f.file,'utf8');
|
|
assert.throws(()=>f.settings.set('acme',{revision:v.revision,plugins:{usage:'cliproxy-queue'},usage:{cliproxyQueue:{pollSeconds}}},'jason'));
|
|
assert.equal(readFileSync(f.file,'utf8'),before);
|
|
}
|
|
v=f.settings.set('acme',{revision:v.revision,plugins:{usage:null},usage:null},'jason');
|
|
assert.equal(v.plugins.usage,undefined);assert.equal(v.usage,undefined);
|
|
});
|
|
|
|
test('P5 only human can enable/disable telemetry or change polling, including direct organization writes', () => {
|
|
const f=fixture();let v=f.settings.read('acme');const patch={plugins:{usage:'cliproxy-queue'},usage:{cliproxyQueue:{pollSeconds:15}}};
|
|
for(const actor of ['tester','chief','coder']) assert.throws(()=>f.settings.set('acme',{revision:v.revision,...patch},actor),{code:'organization-usage-human-only'});
|
|
v=f.settings.set('acme',{revision:v.revision,...patch},'jason');
|
|
for(const actor of ['tester','chief','coder']) {
|
|
assert.throws(()=>f.settings.set('acme',{revision:v.revision,plugins:{usage:null}},actor),{code:'organization-usage-human-only'});
|
|
assert.throws(()=>f.settings.set('acme',{revision:v.revision,plugins:{usage:'cliproxy-queue'},usage:{cliproxyQueue:{pollSeconds:1}}},actor),{code:'organization-usage-human-only'});
|
|
}
|
|
});
|