The SP1 listing test also requires a nonempty shipped listing, and a new SP2 case pins that installing a plug-in named after any shipped pack refuses core-name, so an installed pack cannot replace one.
617 lines
35 KiB
JavaScript
617 lines
35 KiB
JavaScript
// #1873 SP1: the starter-pack kind, checkPack, narrowsTemplate and the three readers.
|
|
// The starter-pack module is imported lazily so each test reports its own failure on a tree
|
|
// without it (the red-before run).
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { chmodSync, cpSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { KINDS, PluginError, checkPlugins, namesFor } from "../src/kinds.mjs";
|
|
import { createLoader } from "../src/loader.mjs";
|
|
import { pluginHash } from "../src/pinned.mjs";
|
|
import { installPlugin } from "../src/install.mjs";
|
|
import { templateHash } from "../../organization/src/definitions.mjs";
|
|
import { REFUSALS } from "../../bus/src/refusals.mjs";
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const REPO = resolve(HERE, "../../..");
|
|
const ROLES = join(REPO, "roles");
|
|
const MARK = "MARK-sp1-not-echoed-7c1f";
|
|
let loaded;
|
|
const sp = async () => (loaded ??= await import("../src/starter-pack.mjs"));
|
|
|
|
const code = (expected, message) => (e) => {
|
|
assert.equal(e?.name, "PluginError", e?.stack);
|
|
assert.equal(e.code, expected, e.message);
|
|
if (message) assert.match(e.message, message);
|
|
assert.ok(!e.message.includes(MARK), e.message);
|
|
assert.ok(!e.message.includes(REPO), e.message);
|
|
return true;
|
|
};
|
|
|
|
const H = { coder: templateHash(ROLES, "coder"), reviewer: templateHash(ROLES, "reviewer") };
|
|
|
|
const writer = () => ({
|
|
from: { template: "coder", sha256: H.coder },
|
|
role: {
|
|
tools: ["read", "grep"], network: "none",
|
|
authority: { withinRole: ["record.write"], crossRole: ["task.scope.change"] },
|
|
credentials: [{ service: "gitea", scopes: ["read:user"] }, { service: "vikunja", scopes: { tasks: ["read_one"] } }],
|
|
skills: ["ms-unslop"],
|
|
},
|
|
instructions: "Write the release notes for each merged change.",
|
|
personality: { summary: "Plain and short." },
|
|
});
|
|
|
|
const columns = () => [
|
|
{ state: "todo", label: "Backlog" }, { state: "in-progress", label: "Building" },
|
|
{ state: "in-review", label: "Review" }, { state: "blocked", label: "Blocked" }, { state: "done", label: "Merged" },
|
|
];
|
|
|
|
const pack = (patch = (p) => p) => {
|
|
const p = {
|
|
packVersion: 1, id: "coding", version: "1.0.0", label: "Coding",
|
|
description: "A small software team with review before merge.", scope: "org",
|
|
definitions: { coder: { template: "coder", sha256: H.coder }, reviewer: { template: "reviewer", sha256: H.reviewer }, "release-writer": writer() },
|
|
hires: [
|
|
{ name: "coder-1", definition: "coder", reason: "writes the code" },
|
|
{ name: "reviewer-1", definition: "reviewer", reason: "reviews each change" },
|
|
],
|
|
board: { columns: columns(), layout: "kanban" },
|
|
workflows: {
|
|
change: {
|
|
description: "One change from task to merge.",
|
|
steps: [
|
|
{ id: "plan", title: "Write the task", roles: ["pm"], state: "todo" },
|
|
{ id: "build", title: "Implement", roles: ["coder", "release-writer"], state: "in-progress" },
|
|
{ id: "review", title: "Review", roles: ["reviewer"], state: "in-review" },
|
|
{ id: "merge", title: "Approve the merge", roles: ["human"], state: "done" },
|
|
],
|
|
},
|
|
},
|
|
skills: ["ms-sdlc", "ms-unslop"],
|
|
};
|
|
return patch(p) ?? p;
|
|
};
|
|
|
|
const shipped = (doc) => sp().then((m) => m.checkPack(doc, { origin: "shipped", rolesDir: ROLES }));
|
|
const saved = (doc) => sp().then((m) => m.checkPack(doc, { origin: "saved", rolesDir: ROLES }));
|
|
|
|
// ---- Red before ----
|
|
|
|
test("SP1 red before: a definition carrying copyBindings refuses starter-pack-invalid", async () => {
|
|
const doc = pack((p) => { p.definitions["release-writer"].copyBindings = { gitea: { copyId: MARK } }; });
|
|
await assert.rejects(shipped(doc), code("starter-pack-invalid"));
|
|
});
|
|
|
|
test("SP1 red before: plugins naming starter-pack refuses bad-setting, never switching a pack on", () => {
|
|
assert.throws(() => checkPlugins({ "starter-pack": ["coding"] }, "plugins"), (e) => e instanceof PluginError && e.code === "bad-setting");
|
|
});
|
|
|
|
// ---- The kind ----
|
|
|
|
test("SP1 kind: starter-pack at interface 1, export starterPack, switch none; namesFor refuses every setting", async () => {
|
|
assert.deepEqual({ ...KINDS["starter-pack"], exports: [...KINDS["starter-pack"].exports] },
|
|
{ kind: "starter-pack", interfaceVersion: 1, exports: ["starterPack"], switch: "none" });
|
|
assert.ok(Object.isFrozen(KINDS["starter-pack"]) && Object.isFrozen(KINDS["starter-pack"].exports));
|
|
for (const setting of [[], ["coding"], "coding", null, {}]) {
|
|
assert.throws(() => namesFor(KINDS["starter-pack"], setting, "plugins.starter-pack"), (e) => e instanceof PluginError && e.code === "bad-setting");
|
|
assert.throws(() => checkPlugins({ "starter-pack": setting }, "plugins"), (e) => e instanceof PluginError && e.code === "bad-setting");
|
|
}
|
|
// The loader switches nothing on for the kind: present, the key is refused; absent, it loads [].
|
|
const log = [];
|
|
const loader = createLoader({ log: (e) => log.push(e) });
|
|
const first = await loader.load({ "starter-pack": ["coding"] });
|
|
assert.deepEqual(first.loaded["starter-pack"], []);
|
|
assert.deepEqual(first.refused.map((r) => [r.kind, r.code]), [["starter-pack", "bad-setting"]]);
|
|
const second = await loader.load({});
|
|
assert.deepEqual(second.loaded["starter-pack"], []);
|
|
assert.deepEqual(second.refused, []);
|
|
});
|
|
|
|
test("SP1 refusal table: each new code has a rail and a next step that names no path", () => {
|
|
for (const c of ["starter-pack-invalid", "starter-pack-not-found", "starter-pack-template-changed"]) {
|
|
assert.ok(Object.hasOwn(REFUSALS, c), c);
|
|
assert.ok(REFUSALS[c].rail?.length > 5, c);
|
|
assert.ok(REFUSALS[c].nextStep?.length > 20, c);
|
|
assert.ok(!/(^|[\s("'`])(\/|~\/)/.test(REFUSALS[c].nextStep), c);
|
|
}
|
|
});
|
|
|
|
test("SP1 STORE_STATES matches the bus's task states", async () => {
|
|
const { STORE_STATES } = await sp();
|
|
const broker = readFileSync(join(REPO, "packages/bus/src/broker.mjs"), "utf8");
|
|
const m = /export const STORE_STATES = Object\.freeze\((\[[^\]]*\])\)/.exec(broker);
|
|
assert.ok(m);
|
|
assert.deepEqual([...STORE_STATES], JSON.parse(m[1].replaceAll("'", '"')));
|
|
});
|
|
|
|
// ---- checkPack ----
|
|
|
|
test("SP1 a valid pack loads for every origin and comes back as a checked copy", async () => {
|
|
const { checkPack } = await sp();
|
|
for (const origin of ["shipped", "installed", "saved"]) {
|
|
const doc = pack();
|
|
const out = checkPack(doc, { origin, rolesDir: ROLES });
|
|
assert.deepEqual(out, doc);
|
|
assert.notEqual(out, doc);
|
|
assert.notEqual(out.definitions, doc.definitions);
|
|
}
|
|
// The shipped roles are the default.
|
|
assert.deepEqual(checkPack(pack(), { origin: "shipped" }), pack());
|
|
// A project pack, empty lists and a list layout load too.
|
|
const small = pack((p) => { p.scope = "project"; p.hires = []; p.workflows = {}; p.skills = []; p.board.layout = "list"; p.board.columns.reverse(); });
|
|
assert.deepEqual(checkPack(small, { origin: "shipped", rolesDir: ROLES }), small);
|
|
});
|
|
|
|
test("SP1 the origin is a closed set and a module's pack id must equal its name", async () => {
|
|
const { checkPack } = await sp();
|
|
for (const origin of [undefined, "core", "Saved", MARK]) {
|
|
assert.throws(() => checkPack(pack(), { origin, rolesDir: ROLES }), code("starter-pack-invalid"));
|
|
}
|
|
assert.throws(() => checkPack(pack(), { origin: "shipped", rolesDir: ROLES, name: "other" }), code("starter-pack-invalid"));
|
|
assert.ok(checkPack(pack(), { origin: "shipped", rolesDir: ROLES, name: "coding" }));
|
|
});
|
|
|
|
test("SP1 a forbidden key refuses, at the top, in a definition, a role, a hire, the board and a step", async () => {
|
|
const forbidden = [
|
|
(p) => { p.plugins = { "task-tracker": MARK }; },
|
|
(p) => { p.launch = { model: MARK }; },
|
|
(p) => { p.members = [MARK]; },
|
|
(p) => { p.credentials = [MARK]; },
|
|
(p) => { p.definitions["release-writer"].credentials = { token: MARK }; },
|
|
(p) => { p.definitions["release-writer"].rev = 1; },
|
|
(p) => { p.definitions["release-writer"].role.copyBindings = MARK; },
|
|
(p) => { p.definitions.coder.copyId = MARK; },
|
|
(p) => { p.hires[0].route = MARK; },
|
|
(p) => { p.board.theme = MARK; },
|
|
(p) => { p.board.columns[0].color = MARK; },
|
|
(p) => { p.workflows.change.title = MARK; },
|
|
(p) => { p.workflows.change.steps[0].assignee = MARK; },
|
|
];
|
|
for (const mutate of forbidden) await assert.rejects(shipped(pack(mutate)), code("starter-pack-invalid"));
|
|
});
|
|
|
|
test("SP1 a stale template hash refuses starter-pack-template-changed, for a reference and a from", async () => {
|
|
const stale = "0".repeat(64);
|
|
await assert.rejects(shipped(pack((p) => { p.definitions.coder.sha256 = stale; })), code("starter-pack-template-changed"));
|
|
await assert.rejects(shipped(pack((p) => { p.definitions["release-writer"].from.sha256 = stale; })), code("starter-pack-template-changed"));
|
|
await assert.rejects(saved(pack((p) => { p.definitions["release-writer"].from.sha256 = stale; })), code("starter-pack-template-changed"));
|
|
// A name that is no version 2 template, and a bad hash shape, are invalid, not changed.
|
|
await assert.rejects(shipped(pack((p) => { p.definitions.coder.template = "no-such-role"; })), code("starter-pack-invalid"));
|
|
await assert.rejects(shipped(pack((p) => { p.definitions.coder.template = "conductor-policy"; })), code("starter-pack-invalid"));
|
|
await assert.rejects(shipped(pack((p) => { p.definitions.coder.sha256 = MARK; })), code("starter-pack-invalid"));
|
|
await assert.rejects(shipped(pack((p) => { p.definitions.coder.template = `../${MARK}`; })), code("starter-pack-invalid"));
|
|
});
|
|
|
|
test("SP1 a role wider than its template refuses: a tool, an action, the network, a skill, within for cross", async () => {
|
|
const role = (f) => pack((p) => { f(p.definitions["release-writer"].role); });
|
|
for (const f of [
|
|
(r) => { r.network = "open"; },
|
|
(r) => { r.skills.push("ms-git-operations"); },
|
|
(r) => { r.authority.withinRole = ["record.write", "task.scope.change"]; r.authority.crossRole = []; },
|
|
]) await assert.rejects(shipped(role(f)), code("starter-pack-invalid"));
|
|
// An action the template does not hold. pm's within-role actions include one coder lacks.
|
|
const pm = JSON.parse(readFileSync(join(ROLES, "pm.json"), "utf8"));
|
|
const coder = JSON.parse(readFileSync(join(ROLES, "coder.json"), "utf8"));
|
|
const extra = pm.authority.withinRole.find((a) => ![...coder.authority.withinRole, ...coder.authority.crossRole].includes(a)
|
|
&& !a.startsWith("decision.resolve.") && !["role.hire", "role.fire", "role.change"].includes(a));
|
|
assert.ok(extra, "pm holds a within-role action coder lacks");
|
|
await assert.rejects(shipped(role((r) => { r.authority.withinRole.push(extra); })), code("starter-pack-invalid"));
|
|
// A tool coder lacks.
|
|
await assert.rejects(shipped(role((r) => { r.tools.push("web"); r.network = "api-only"; })), code("starter-pack-invalid"));
|
|
// A saved role with a from narrows too.
|
|
await assert.rejects(saved(role((r) => { r.network = "open"; })), code("starter-pack-invalid"));
|
|
// A cross-role action of the template may be held cross-role; a within-role one may move to cross.
|
|
const ok = role((r) => { r.authority.withinRole = []; r.authority.crossRole = ["task.scope.change", "record.write"]; });
|
|
assert.ok(await shipped(ok));
|
|
});
|
|
|
|
test("SP1 a credential line wider than its template refuses", async () => {
|
|
const cred = (credentials) => pack((p) => { p.definitions["release-writer"].role.credentials = credentials; });
|
|
for (const credentials of [
|
|
[{ service: "gitea", scopes: ["write:user"] }],
|
|
[{ service: "gitea", scopes: ["read:user", "write:organization"] }],
|
|
[{ service: "vikunja", scopes: { tasks: ["create"] } }],
|
|
[{ service: "vikunja", scopes: { labels: ["read_all"] } }],
|
|
]) await assert.rejects(shipped(cred(credentials)), code("starter-pack-invalid"));
|
|
assert.ok(await shipped(cred([])));
|
|
assert.ok(await shipped(cred([{ service: "vikunja", scopes: { tasks: ["read_one", "update"], projects: ["views_buckets_tasks"] } }])));
|
|
});
|
|
|
|
test("SP1 narrowsTemplate and widerLines read every line the way step 7 does", async () => {
|
|
const { narrowsTemplate, widerLines } = await sp();
|
|
const template = {
|
|
tools: ["read", "grep"], network: "api-only",
|
|
authority: { withinRole: ["record.write"], crossRole: ["task.scope.change"] },
|
|
credentials: [{ service: "gitea", scopes: ["read:user"] }, { service: "vikunja", scopes: { tasks: ["read_one"] } }, { service: "host.box" }],
|
|
messaging: { sendTo: ["pm"], receiveFrom: ["pm", "coder"] }, skills: ["ms-unslop"],
|
|
};
|
|
const base = { tools: ["read"], network: "none", authority: { withinRole: [], crossRole: ["record.write"] },
|
|
credentials: [{ service: "host.box" }, { service: "vikunja", scopes: { tasks: ["read_one"] } }],
|
|
messaging: { sendTo: [], receiveFrom: ["coder"] }, skills: [] };
|
|
assert.equal(narrowsTemplate(base, template), true);
|
|
assert.deepEqual(widerLines({ ...base, messaging: null }, template), [
|
|
{ field: "messaging.sendTo", value: "*" }, { field: "messaging.receiveFrom", value: "*" }]);
|
|
assert.deepEqual(widerLines({ ...base, messaging: { sendTo: ["coder"], receiveFrom: [] } }, template), [{ field: "messaging.sendTo", value: "coder" }]);
|
|
// A template with no messaging lets the role message anyone.
|
|
assert.equal(narrowsTemplate({ ...base, messaging: { sendTo: ["x"], receiveFrom: ["y"] } }, { ...template, messaging: null }), true);
|
|
assert.deepEqual(widerLines({ ...base, network: "open", tools: ["bash"], skills: ["s"],
|
|
authority: { withinRole: ["task.scope.change"], crossRole: ["x.y"] },
|
|
credentials: [{ service: "gitea" }, { service: "gitea", scopes: ["write:user"] }, { service: "vikunja", scopes: { tasks: ["update"] } }] }, template), [
|
|
{ field: "authority.withinRole", value: "task.scope.change" }, { field: "authority.crossRole", value: "x.y" },
|
|
{ field: "tools", value: "bash" }, { field: "network", value: "open" },
|
|
{ field: "credentials.gitea", value: "gitea" }, { field: "credentials.gitea", value: "write:user" },
|
|
{ field: "credentials.vikunja", value: "tasks.update" }, { field: "skills", value: "s" },
|
|
]);
|
|
assert.equal(narrowsTemplate({ ...base, network: "bogus" }, template), false);
|
|
});
|
|
|
|
test("SP1 a root hire, a ninth hire, a hire naming no pack definition and other bad hires refuse", async () => {
|
|
const hire = (i) => ({ name: `coder-${i}`, definition: "coder", reason: "writes code" });
|
|
for (const mutate of [
|
|
(p) => { p.hires[0].level = "root"; },
|
|
(p) => { p.hires[0].level = "org"; },
|
|
(p) => { p.hires = Array.from({ length: 9 }, (_, i) => hire(i + 1)); },
|
|
(p) => { p.hires[0].definition = "pm"; },
|
|
(p) => { p.hires[0].definition = "__proto__"; },
|
|
(p) => { p.hires[1].name = p.hires[0].name; },
|
|
(p) => { p.hires[0].name = "Root/../x"; },
|
|
(p) => { p.hires[0].reason = " "; },
|
|
(p) => { p.hires[0].reason = "x".repeat(501); },
|
|
(p) => { delete p.hires[0].reason; },
|
|
(p) => { p.hires = {}; },
|
|
]) await assert.rejects(shipped(pack(mutate)), code("starter-pack-invalid"));
|
|
assert.ok(await shipped(pack((p) => { p.hires = Array.from({ length: 8 }, (_, i) => hire(i + 1)); })));
|
|
});
|
|
|
|
test("SP1 a bad state and a duplicate column refuse, and the board keeps its bounds", async () => {
|
|
for (const mutate of [
|
|
(p) => { p.board.columns[0].state = "doing"; },
|
|
(p) => { p.workflows.change.steps[1].state = "doing"; },
|
|
(p) => { p.board.columns[1] = { state: "todo", label: "Again" }; },
|
|
(p) => { p.board.columns.pop(); },
|
|
(p) => { p.board.columns.push({ state: "done", label: "More" }); },
|
|
(p) => { p.board.columns[0].label = ""; },
|
|
(p) => { p.board.columns[0].label = "x".repeat(33); },
|
|
(p) => { p.board.columns[0].label = "two\nlines"; },
|
|
(p) => { p.board.layout = "grid"; },
|
|
(p) => { delete p.board.layout; },
|
|
]) await assert.rejects(shipped(pack(mutate)), code("starter-pack-invalid"));
|
|
assert.ok(await shipped(pack((p) => { p.board.columns[0].label = "x".repeat(32); })));
|
|
});
|
|
|
|
test("SP1 workflows: 1 to 20 steps, 1 to 8 known roles, bounded titles and descriptions", async () => {
|
|
const step = (i) => ({ id: `s${i}`, title: "Step", roles: ["human"], state: "todo" });
|
|
for (const mutate of [
|
|
(p) => { p.workflows.change.steps = []; },
|
|
(p) => { p.workflows.change.steps = Array.from({ length: 21 }, (_, i) => step(i)); },
|
|
(p) => { p.workflows.change.steps[0].roles = []; },
|
|
(p) => { p.workflows.change.steps[0].roles = ["pm", "coder", "reviewer", "human", "cto", "assistant", "research", "ideas", "companion"]; },
|
|
(p) => { p.workflows.change.steps[0].roles = ["no-such-role"]; },
|
|
(p) => { p.workflows.change.steps[0].roles = ["conductor-policy"]; },
|
|
(p) => { p.workflows.change.steps[0].roles = ["pm", "pm"]; },
|
|
(p) => { p.workflows.change.steps[0].title = "x".repeat(121); },
|
|
(p) => { p.workflows.change.steps[1].id = "plan"; },
|
|
(p) => { p.workflows.change.description = "x".repeat(1001); },
|
|
(p) => { p.workflows.change.description = ""; },
|
|
(p) => { p.workflows.Change = p.workflows.change; },
|
|
]) await assert.rejects(shipped(pack(mutate)), code("starter-pack-invalid"));
|
|
assert.ok(await shipped(pack((p) => {
|
|
p.workflows.change.steps = Array.from({ length: 20 }, (_, i) => step(i));
|
|
p.workflows.change.steps[0].roles = ["pm", "coder", "reviewer", "human", "cto", "assistant", "research", "ideas"];
|
|
p.workflows.change.steps[0].title = "x".repeat(120);
|
|
p.workflows.change.description = "x".repeat(1000);
|
|
})));
|
|
});
|
|
|
|
test("SP1 top-level fields: packVersion, id, semver version, label, description, scope and skills", async () => {
|
|
for (const mutate of [
|
|
(p) => { p.packVersion = 2; },
|
|
(p) => { delete p.packVersion; },
|
|
(p) => { p.id = "Coding"; },
|
|
(p) => { p.id = "../x"; },
|
|
(p) => { p.version = "1.0"; },
|
|
(p) => { p.version = "v1.0.0"; },
|
|
(p) => { p.version = "01.0.0"; },
|
|
(p) => { p.label = ""; },
|
|
(p) => { p.label = "x".repeat(65); },
|
|
(p) => { p.description = "a\nb"; },
|
|
(p) => { p.scope = "root"; },
|
|
(p) => { delete p.skills; },
|
|
(p) => { p.skills = ["ms_sdlc"]; },
|
|
(p) => { p.skills = ["ms-sdlc", "ms-sdlc"]; },
|
|
(p) => { p.definitions = []; },
|
|
(p) => { p.definitions = { "Bad Name": { template: "coder", sha256: H.coder } }; },
|
|
]) await assert.rejects(shipped(pack(mutate)), code("starter-pack-invalid"));
|
|
for (const version of ["1.0.0-rc.1", "2.10.3+build.5", "0.0.1"]) assert.ok(await shipped(pack((p) => { p.version = version; })));
|
|
const { checkPack } = await sp();
|
|
for (const doc of [null, [], "x", 1, { toJSON() { throw new Error(MARK); } }]) {
|
|
assert.throws(() => checkPack(doc, { origin: "shipped", rolesDir: ROLES }), code("starter-pack-invalid"));
|
|
}
|
|
});
|
|
|
|
test("SP1 a definition that fails checkDefinition refuses without quoting the document", async () => {
|
|
for (const mutate of [
|
|
(p) => { p.definitions["release-writer"].instructions = ""; },
|
|
(p) => { p.definitions["release-writer"].role.authority.withinRole.push("role.hire"); },
|
|
(p) => { p.definitions["release-writer"].role.tools.push(MARK); },
|
|
(p) => { p.definitions["release-writer"].from.template = MARK.toLowerCase(); },
|
|
]) await assert.rejects(shipped(pack(mutate)), code("starter-pack-invalid"));
|
|
});
|
|
|
|
test("SP1 a shipped or installed pack with packs or a role without from refuses; a saved one with either passes", async () => {
|
|
const withPacks = pack((p) => { p.packs = [{ id: "marketing", version: "1.2.0" }]; });
|
|
const noFrom = pack((p) => { delete p.definitions["release-writer"].from; });
|
|
const { checkPack } = await sp();
|
|
for (const origin of ["shipped", "installed"]) {
|
|
assert.throws(() => checkPack(withPacks, { origin, rolesDir: ROLES }), code("starter-pack-invalid"));
|
|
assert.throws(() => checkPack(noFrom, { origin, rolesDir: ROLES }), code("starter-pack-invalid"));
|
|
}
|
|
assert.deepEqual(await saved(withPacks), withPacks);
|
|
assert.deepEqual(await saved(noFrom), noFrom);
|
|
// A saved role without from is still checked by checkDefinition.
|
|
await assert.rejects(saved(pack((p) => { delete p.definitions["release-writer"].from; p.definitions["release-writer"].role.authority.withinRole.push("role.fire"); })), code("starter-pack-invalid"));
|
|
});
|
|
|
|
test("SP1 a saved packs list: a 33rd entry, a key other than id and version, or a non-semver version refuses", async () => {
|
|
const entry = (i) => ({ id: `pack-${i}`, version: "1.0.0" });
|
|
for (const packs of [
|
|
Array.from({ length: 33 }, (_, i) => entry(i)),
|
|
[{ id: "coding", version: "1.0.0", sha256: "0".repeat(64) }],
|
|
[{ id: "coding", version: "1.0.0", origin: "shipped" }],
|
|
[{ id: "coding", version: "1.0" }],
|
|
[{ id: "coding", version: MARK }],
|
|
[{ id: "coding" }],
|
|
[{ id: "Coding", version: "1.0.0" }],
|
|
{},
|
|
]) await assert.rejects(saved(pack((p) => { p.packs = packs; })), code("starter-pack-invalid"));
|
|
const ok = pack((p) => { p.packs = Array.from({ length: 32 }, (_, i) => entry(i)); });
|
|
assert.deepEqual(await saved(ok), ok);
|
|
});
|
|
|
|
// ---- readSavedTemplate ----
|
|
|
|
function savedFixture(t, account = "alice.b") {
|
|
const root = mkdtempSync(join(tmpdir(), "sp1-"));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const configDir = join(root, "cfg");
|
|
const dir = join(configDir, "templates", account);
|
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
chmodSync(join(configDir, "templates"), 0o700);
|
|
chmodSync(dir, 0o700);
|
|
const put = (name, text, mode = 0o600) => {
|
|
const file = join(dir, `${name}.json`);
|
|
writeFileSync(file, text, { mode });
|
|
chmodSync(file, mode);
|
|
return file;
|
|
};
|
|
return { root, configDir, dir, put };
|
|
}
|
|
|
|
test("SP1 readSavedTemplate reads a saved template as origin saved; an account with a dot passes", async (t) => {
|
|
const { readSavedTemplate } = await sp();
|
|
const f = savedFixture(t, "alice.b");
|
|
const doc = pack((p) => { p.packs = [{ id: "coding", version: "1.0.0" }]; delete p.definitions["release-writer"].from; });
|
|
f.put("my-team", JSON.stringify(doc));
|
|
const out = readSavedTemplate({ configDir: f.configDir, account: "alice.b", name: "my-team", rolesDir: ROLES });
|
|
assert.deepEqual(out, { name: "my-team", origin: "saved", pack: doc });
|
|
// Owner ids may hold an underscore or start with a digit.
|
|
for (const account of ["bob_c", "9lives"]) {
|
|
const g = savedFixture(t, account);
|
|
g.put("t", JSON.stringify(pack()));
|
|
assert.equal(readSavedTemplate({ configDir: g.configDir, account, name: "t", rolesDir: ROLES }).pack.id, "coding");
|
|
}
|
|
});
|
|
|
|
test("SP1 readSavedTemplate refuses .., a name or account holding /, a symlink, a 0644 file, a 300 KiB file; a missing one is not found", async (t) => {
|
|
const { readSavedTemplate } = await sp();
|
|
const f = savedFixture(t, "alice.b");
|
|
const read = (name, account = "alice.b", extra = {}) => () => readSavedTemplate({ configDir: f.configDir, account, name, rolesDir: ROLES, ...extra });
|
|
f.put("good", JSON.stringify(pack()));
|
|
assert.ok(read("good")());
|
|
for (const name of ["..", "a/b", "../alice.b/good", "/etc/passwd", "Good", "", null, 3]) assert.throws(read(name), code("starter-pack-invalid"));
|
|
for (const account of ["..", "a/b", ".", ".alice", "_alice", "-alice", "../alice.b", "Alice", "", null]) assert.throws(read("good", account), code("starter-pack-invalid"));
|
|
// A symlink to a good 0600 file refuses.
|
|
const real = join(f.root, "real.json");
|
|
writeFileSync(real, JSON.stringify(pack()), { mode: 0o600 });
|
|
symlinkSync(real, join(f.dir, "link.json"));
|
|
assert.throws(read("link"), code("starter-pack-invalid", /regular file, not a symlink/));
|
|
// A 0644 file and a 0640 file refuse.
|
|
f.put("wide", JSON.stringify(pack()), 0o644);
|
|
assert.throws(read("wide"), code("starter-pack-invalid", /mode 0600 or narrower/));
|
|
f.put("group", JSON.stringify(pack()), 0o640);
|
|
assert.throws(read("group"), code("starter-pack-invalid", /mode 0600 or narrower/));
|
|
// A hard link to a file outside the account's directory refuses.
|
|
const solo = f.put("solo", JSON.stringify(pack()));
|
|
linkSync(solo, join(f.root, "alias.json"));
|
|
assert.throws(read("solo"), code("starter-pack-invalid", /hard link/));
|
|
f.put("narrow", JSON.stringify(pack()), 0o400);
|
|
assert.ok(read("narrow")());
|
|
// A file its owner can't open refuses as unreadable, not as a symlink.
|
|
if (process.getuid() !== 0) {
|
|
f.put("shut", JSON.stringify(pack()), 0o000);
|
|
assert.throws(read("shut"), code("starter-pack-invalid", /can't be read/));
|
|
}
|
|
// 300 KiB refuses before the read; JSON padding keeps the file otherwise valid.
|
|
const big = JSON.stringify(pack());
|
|
f.put("big", big + " ".repeat(300 * 1024 - big.length));
|
|
assert.equal(lstatSync(join(f.dir, "big.json")).size, 300 * 1024);
|
|
assert.throws(read("big"), code("starter-pack-invalid", /must be at most 262144 bytes/));
|
|
// A missing file, and a missing account directory, are not found.
|
|
assert.throws(read("missing"), code("starter-pack-not-found"));
|
|
assert.throws(read("good", "carol"), code("starter-pack-not-found"));
|
|
// A directory where the file should be, bad JSON and bad UTF-8 refuse invalid.
|
|
mkdirSync(join(f.dir, "dir.json"));
|
|
assert.throws(read("dir"), code("starter-pack-invalid"));
|
|
f.put("bad", `{"id": "${MARK}"`);
|
|
assert.throws(read("bad"), code("starter-pack-invalid"));
|
|
writeFileSync(join(f.dir, "utf.json"), Buffer.from([0x7b, 0xff, 0x7d]), { mode: 0o600 });
|
|
assert.throws(read("utf"), code("starter-pack-invalid"));
|
|
// A file the pack checks refuse is invalid too.
|
|
f.put("forbidden", JSON.stringify(pack((p) => { p.definitions.coder.copyBindings = MARK; })));
|
|
assert.throws(read("forbidden"), code("starter-pack-invalid"));
|
|
// Another user's file, and a relative configDir, refuse.
|
|
assert.throws(read("good", "alice.b", { uid: process.getuid() + 1 }), code("starter-pack-invalid", /directory must belong to the instance's user/));
|
|
assert.throws(() => readSavedTemplate({ configDir: "cfg", account: "alice.b", name: "good", rolesDir: ROLES }), code("starter-pack-invalid"));
|
|
});
|
|
|
|
test("SP1 readSavedTemplate refuses a symlinked or group-writable templates directory", async (t) => {
|
|
const { readSavedTemplate } = await sp();
|
|
const f = savedFixture(t, "alice.b");
|
|
f.put("good", JSON.stringify(pack()));
|
|
const read = () => readSavedTemplate({ configDir: f.configDir, account: "alice.b", name: "good", rolesDir: ROLES });
|
|
chmodSync(f.dir, 0o770);
|
|
assert.throws(read, code("starter-pack-invalid", /not be writable by group or others/));
|
|
// The account's directory is 0700 or narrower: group or other read or list refuses too.
|
|
for (const mode of [0o750, 0o705, 0o755]) {
|
|
chmodSync(f.dir, mode);
|
|
assert.throws(read, code("starter-pack-invalid", /mode 0700 or narrower/));
|
|
}
|
|
chmodSync(f.dir, 0o700);
|
|
assert.ok(read());
|
|
// The templates directory itself may be listed by others; it only must not be writable.
|
|
chmodSync(join(f.configDir, "templates"), 0o755);
|
|
assert.ok(read());
|
|
chmodSync(join(f.configDir, "templates"), 0o775);
|
|
assert.throws(read, code("starter-pack-invalid", /not be writable by group or others/));
|
|
chmodSync(join(f.configDir, "templates"), 0o700);
|
|
// The account directory as a symlink to a real one refuses.
|
|
const g = savedFixture(t, "other");
|
|
g.put("good", JSON.stringify(pack()));
|
|
symlinkSync(g.dir, join(f.configDir, "templates", "linked"));
|
|
assert.throws(() => readSavedTemplate({ configDir: f.configDir, account: "linked", name: "good", rolesDir: ROLES }), code("starter-pack-invalid", /must be a real directory/));
|
|
// The templates directory as a symlink refuses.
|
|
const h = mkdtempSync(join(tmpdir(), "sp1-"));
|
|
t.after(() => rmSync(h, { recursive: true, force: true }));
|
|
mkdirSync(join(h, "cfg"), { mode: 0o700 });
|
|
symlinkSync(join(f.configDir, "templates"), join(h, "cfg", "templates"));
|
|
assert.throws(() => readSavedTemplate({ configDir: join(h, "cfg"), account: "alice.b", name: "good", rolesDir: ROLES }), code("starter-pack-invalid", /must be a real directory/));
|
|
});
|
|
|
|
// ---- installedStarterPacks ----
|
|
|
|
// A scratch copy of the plugin and organization sources, so a core pack module can be added
|
|
// without touching the repository tree.
|
|
function tree(t) {
|
|
const root = mkdtempSync(join(tmpdir(), "sp1t-"));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
for (const dir of ["packages/plugins/src", "packages/organization/src"]) cpSync(join(REPO, dir), join(root, dir), { recursive: true });
|
|
const core = (name, body) => {
|
|
mkdirSync(join(root, "packages/plugins/starter-pack", name), { recursive: true });
|
|
writeFileSync(join(root, "packages/plugins/starter-pack", name, "index.mjs"), body);
|
|
};
|
|
const load = () => import(pathToFileURL(join(root, "packages/plugins/src/starter-pack.mjs")).href);
|
|
return { root, core, load };
|
|
}
|
|
|
|
const moduleText = (doc) => `export const interfaceVersion = 1;\nexport function starterPack() { return ${JSON.stringify(doc)}; }\n`;
|
|
|
|
// SP2 ships five core packs; starter-pack-shipped.test.mjs checks them by name. Assertions about
|
|
// installed packs below read only the installed ones.
|
|
const installedOnly = (res) => res.packs.filter((p) => p.origin !== "shipped");
|
|
|
|
test("SP1 installedStarterPacks lists shipped packs with origin shipped", async (t) => {
|
|
const { installedStarterPacks } = await sp();
|
|
const real = await installedStarterPacks({ rolesDir: ROLES });
|
|
assert.deepEqual(real.refused, []);
|
|
assert.ok(real.packs.length > 0);
|
|
assert.ok(real.packs.every((p) => p.origin === "shipped"));
|
|
const f = tree(t);
|
|
f.core("coding", moduleText(pack()));
|
|
f.core("tiny", moduleText(pack((p) => { p.id = "tiny"; p.hires = []; })));
|
|
const m = await f.load();
|
|
const out = await m.installedStarterPacks({ rolesDir: ROLES });
|
|
assert.deepEqual(out.refused, []);
|
|
assert.deepEqual(out.packs.map((p) => [p.id, p.origin]), [["coding", "shipped"], ["tiny", "shipped"]]);
|
|
assert.deepEqual(out.packs[0].pack, pack());
|
|
});
|
|
|
|
test("SP1 a refused shipped pack fails the listing", async (t) => {
|
|
for (const body of [
|
|
moduleText(pack((p) => { p.packs = []; })),
|
|
moduleText(pack((p) => { p.id = "other"; })),
|
|
`export const interfaceVersion = 1;\nexport function starterPack() { throw new Error(${JSON.stringify(MARK)}); }\n`,
|
|
"export const interfaceVersion = 2;\nexport function starterPack() { return {}; }\n",
|
|
"export const interfaceVersion = 1;\n",
|
|
]) {
|
|
const f = tree(t);
|
|
f.core("coding", body);
|
|
const m = await f.load();
|
|
await assert.rejects(m.installedStarterPacks({ rolesDir: ROLES }), code("starter-pack-invalid"));
|
|
}
|
|
});
|
|
|
|
function installed(t, name, body) {
|
|
const root = mkdtempSync(join(tmpdir(), "sp1i-"));
|
|
t.after(() => {
|
|
const thaw = (p) => { const st = lstatSync(p); if (st.isSymbolicLink()) return; if (st.isDirectory()) { chmodSync(p, 0o700); for (const n of readdirSync(p)) thaw(join(p, n)); } };
|
|
thaw(root);
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
const src = join(root, "src"), dataRoot = join(root, "data");
|
|
mkdirSync(join(src, "starter-pack"), { recursive: true });
|
|
mkdirSync(dataRoot);
|
|
writeFileSync(join(src, "starter-pack", "index.mjs"), body);
|
|
const manifest = { name, version: "1.0.0", kinds: ["starter-pack"], interfaceVersions: { "starter-pack": 1 }, sha256: "0".repeat(64),
|
|
source: { path: "fixture", version: "1.0.0" }, signature: null, tools: [], actions: [], services: [], roleTemplates: [] };
|
|
writeFileSync(join(src, "plugin.json"), JSON.stringify(manifest));
|
|
manifest.sha256 = pluginHash(src);
|
|
writeFileSync(join(src, "plugin.json"), JSON.stringify(manifest));
|
|
installPlugin({ dataRoot, source: src, expectedHash: manifest.sha256 });
|
|
return { root, dataRoot };
|
|
}
|
|
|
|
test("SP1 an installed pack lists with origin installed; a refused one is omitted and logged", async (t) => {
|
|
const { installedStarterPacks } = await sp();
|
|
const good = installed(t, "acmepack", moduleText(pack((p) => { p.id = "acmepack"; })));
|
|
const out = await installedStarterPacks({ dataRoot: good.dataRoot, rolesDir: ROLES });
|
|
assert.deepEqual(out.refused, []);
|
|
assert.deepEqual(installedOnly(out).map((p) => [p.id, p.origin]), [["acmepack", "installed"]]);
|
|
// An installed pack carrying packs, or a role without from, is omitted and logged.
|
|
for (const mutate of [(p) => { p.packs = []; }, (p) => { delete p.definitions["release-writer"].from; }, (p) => { p.hires[0].level = "root"; }]) {
|
|
const bad = installed(t, "badpack", moduleText(pack((p) => { p.id = "badpack"; mutate(p); })));
|
|
const log = [];
|
|
const res = await installedStarterPacks({ dataRoot: bad.dataRoot, rolesDir: ROLES, log: (e) => log.push(e) });
|
|
assert.deepEqual(installedOnly(res), []);
|
|
assert.equal(res.refused.length, 1);
|
|
assert.deepEqual(log, res.refused);
|
|
assert.deepEqual([res.refused[0].event, res.refused[0].kind, res.refused[0].name, res.refused[0].code],
|
|
["starter-pack-refused", "starter-pack", "badpack", "starter-pack-invalid"]);
|
|
}
|
|
// An installed module that fails to load is omitted and logged too.
|
|
const broken = installed(t, "brokenpack", "export const interfaceVersion = 1;\nexport const starterPack = 3;\n");
|
|
const log = [];
|
|
const res = await installedStarterPacks({ dataRoot: broken.dataRoot, rolesDir: ROLES, log: (e) => log.push(e) });
|
|
assert.deepEqual(installedOnly(res), []);
|
|
assert.deepEqual(log.map((e) => [e.name, e.code]), [["brokenpack", "missing-export"]]);
|
|
// An async starterPack is refused, and its later rejection never reaches the process.
|
|
const unhandled = [];
|
|
const onUnhandled = (reason) => unhandled.push(reason);
|
|
process.on("unhandledRejection", onUnhandled);
|
|
t.after(() => process.off("unhandledRejection", onUnhandled));
|
|
const later = installed(t, "asyncpack", "export const interfaceVersion = 1;\nexport async function starterPack() { await new Promise((r) => setTimeout(r, 20)); throw new Error('late'); }\n");
|
|
const res2 = await installedStarterPacks({ dataRoot: later.dataRoot, rolesDir: ROLES, log() {} });
|
|
assert.deepEqual(installedOnly(res2), []);
|
|
assert.deepEqual(res2.refused.map((e) => [e.name, e.code]), [["asyncpack", "starter-pack-invalid"]]);
|
|
assert.match(res2.refused[0].message, /not a promise/);
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
assert.deepEqual(unhandled, []);
|
|
// A thenable that is not a promise never has its then read or called: a throwing then getter
|
|
// and a then that resolves with itself are both omitted and logged, and a shipped pack beside
|
|
// them is unaffected.
|
|
const thenables = {
|
|
getterpack: "export const interfaceVersion = 1;\nexport function starterPack() { return { get then() { throw new Error('getter'); } }; }\n",
|
|
selfpack: "export const interfaceVersion = 1;\nlet calls = 0;\nexport function starterPack() { const o = { then(r) { calls += 1; if (calls > 3) throw new Error('then called'); r(o); } }; return o; }\n",
|
|
};
|
|
for (const [name, text] of Object.entries(thenables)) {
|
|
const odd = installed(t, name, text);
|
|
const res3 = await installedStarterPacks({ dataRoot: odd.dataRoot, rolesDir: ROLES, log() {} });
|
|
assert.deepEqual(installedOnly(res3), []);
|
|
assert.deepEqual(res3.refused.map((e) => [e.name, e.code]), [[name, "starter-pack-invalid"]]);
|
|
}
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
assert.deepEqual(unhandled, []);
|
|
});
|