1. Stale bus host lock refusals from bus status, start, stop and restart name mosaic bus recover ORG. 2. org definitions: unknown org, unknown or malformed name and bad id give one refusal or usage line, no stack trace, no host path. 3. Pack, board and workflow refusals print their hint as template save does; tested with the real CliError transport shape. 4. console start and stop clear a console/start.lock whose named supervisor (pid and start time) is gone; a live or unnamed holder refuses. 5. Template save keeps free text verbatim and writes no placeholders; the preview lists each field that mentions an org-specific name. 6. A request the shim never sent (no socket, connect refused) exits 5 and the CLI reports bus-not-running with the start and recover line. 7. autonomy-precondition carries the check that failed, including on the hire paths. 8. Saving under an existing template name refuses template-exists with a next step; refusal table entry added, channel-proof pins moved.
323 lines
22 KiB
JavaScript
323 lines
22 KiB
JavaScript
// Starter packs SP8 (#1873): saving an organization or one of its projects as a template. The saved
|
|
// file carries no credential material and no member, keeps each role's credential request lines,
|
|
// keeps free text verbatim (E2E6 fix 5) while the preview lists the fields that mention the
|
|
// organization's own names, and reads back as an origin-saved pack.
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { businessDoc, rolesCopy } from "./helpers.mjs";
|
|
import { entryFromTemplate, readTemplate } from "../src/definitions.mjs";
|
|
import {
|
|
DEFAULT_BOARD, TemplateError, templateList, templatePreview, templateSave,
|
|
} from "../src/template-save.mjs";
|
|
import { checkPack, readSavedTemplate } from "../../plugins/src/starter-pack.mjs";
|
|
|
|
const TOKEN = "fixture-held-token-sp8-91c4e7";
|
|
// The credential store's check, as the bus passes it: a held value anywhere in the text refuses.
|
|
const assertClean = (value) => {
|
|
if (JSON.stringify(value).includes(TOKEN)) throw Object.assign(new Error("credential-leak"), { name: "BusError", code: "credential-leak" });
|
|
};
|
|
const STRIPPED = ["copyBindings", "copyId", "orgIncarnation", "generation", "holder", "tracker", "vars", "launch", "members", "rev", "hireDecision"];
|
|
|
|
function fixture(t) {
|
|
const root = mkdtempSync(join(tmpdir(), "tsave-"));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const rolesDir = rolesCopy(root);
|
|
const doc = businessDoc(root, "acme");
|
|
const coder = readTemplate(rolesDir, "coder");
|
|
const writer = entryFromTemplate(coder);
|
|
writer.instructions = `Write code for acme in ${doc.projects.stack.root}/src. Ask jason; pair with ana on stack.`;
|
|
writer.personality = { summary: "Careful acme engineer.", traits: ["works with ana", "plain"], voice: "short" };
|
|
const hired = entryFromTemplate(readTemplate(rolesDir, "reviewer"));
|
|
hired.instructions = "Review what ana writes.";
|
|
doc.definitions = { writer, "hire-0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0": hired };
|
|
doc.roles.ana = { definition: "org:writer", credentials: { gitea: { file: join(root, "secrets", "ana.token"), rotateBy: "2099-01-01" } } };
|
|
doc.roles.bo = { definition: "org:hire-0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0" };
|
|
doc.retired = ["cy"];
|
|
// Credential material the save must never copy.
|
|
doc.copyBindings = { ana: { copyId: "copy-1", orgIncarnation: "inc-1", generation: 3, state: "bound", source: { level: "root", name: "ana", project: null } } };
|
|
doc.board = { columns: [
|
|
{ state: "todo", label: "acme backlog" }, { state: "in-progress", label: "Doing" }, { state: "in-review", label: "Review" },
|
|
{ state: "blocked", label: "Stuck" }, { state: "done", label: "Done" },
|
|
], layout: "list" };
|
|
doc.workflows = { change: { title: "Change for stack", steps: [
|
|
{ title: "ana writes it", state: "in-progress", roles: ["writer"] },
|
|
{ title: "Review it", state: "in-review", roles: ["hire-0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0", "human"] },
|
|
] } };
|
|
doc.packs = [
|
|
{ id: "coding", version: "1.0.0", origin: "shipped", scope: "org", appliedAt: "2026-11-01T00:00:00Z", revision: "r1", added: ["board"], proposedHires: [] },
|
|
{ id: "coding", version: "1.0.0", origin: "shipped", scope: "org", appliedAt: "2026-11-02T00:00:00Z", revision: "r2", added: [], proposedHires: [] },
|
|
{ id: "intake", version: "2.1.0", origin: "installed", project: "stack", appliedAt: "2026-11-03T00:00:00Z", revision: "r3", added: [], proposedHires: [] },
|
|
];
|
|
const configDir = join(root, "config");
|
|
mkdirSync(configDir, { mode: 0o700 });
|
|
return { root, rolesDir, doc, configDir };
|
|
}
|
|
|
|
const keysDeep = (value, out = []) => {
|
|
if (Array.isArray(value)) value.forEach((v) => keysDeep(v, out));
|
|
else if (value && typeof value === "object") for (const [k, v] of Object.entries(value)) { out.push(k); keysDeep(v, out); }
|
|
return out;
|
|
};
|
|
const refused = (fn, code, reason) => assert.throws(fn, (e) => {
|
|
assert.ok(e instanceof TemplateError, `${e?.name}: ${e?.message}`);
|
|
assert.equal(e.code, code);
|
|
if (reason !== undefined) assert.equal(e.reason, reason);
|
|
return true;
|
|
});
|
|
|
|
test("SP8 red before: an instance's credentials and copyBindings never reach the saved file", (t) => {
|
|
const { doc, rolesDir, configDir, root } = fixture(t);
|
|
const preview = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean });
|
|
templateSave({ document: doc, name: "acme-shape", digest: preview.digest, rolesDir, assertClean, configDir, account: "jason" });
|
|
const file = join(configDir, "templates", "jason", "acme-shape.json");
|
|
const text = readFileSync(file, "utf8");
|
|
const saved = JSON.parse(text);
|
|
const keys = keysDeep(saved);
|
|
for (const k of STRIPPED) assert.ok(!keys.includes(k), `the saved file holds ${k}`);
|
|
assert.ok(!text.includes(join(root, "secrets")), "the saved file names a token file");
|
|
assert.ok(!text.includes("copy-1") && !text.includes("inc-1"));
|
|
// credentials appears only as a definition's request lines: {service, scopes?}, never a file.
|
|
for (const [name, entry] of Object.entries(saved.definitions)) {
|
|
if (!entry.role) continue;
|
|
assert.ok(Array.isArray(entry.role.credentials), name);
|
|
for (const line of entry.role.credentials) assert.deepEqual(Object.keys(line).filter((k) => k !== "scopes"), ["service"]);
|
|
}
|
|
assert.equal(text, preview.json);
|
|
});
|
|
|
|
test("SP8 the saved file keeps each role's credential request lines", (t) => {
|
|
const { doc, rolesDir } = fixture(t);
|
|
const { template } = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean });
|
|
assert.deepEqual(template.definitions.writer.role.credentials, doc.definitions.writer.role.credentials);
|
|
assert.ok(template.definitions.writer.role.credentials.length > 0);
|
|
});
|
|
|
|
// E2E6 fix 5, red before: the old save rewrote prose word by word, so a project named "work" turned
|
|
// "hand finished work to review" into "hand finished {project} to review", and apply never resolves
|
|
// a placeholder. Save now keeps every free-text field as it stands.
|
|
test("E2E6 red before: save never rewrites prose; a project named work leaves \"hand finished work to review\" as it is", (t) => {
|
|
const { doc, rolesDir } = fixture(t);
|
|
doc.projects.work = { ...structuredClone(doc.projects.stack), root: join(doc.projects.stack.root, "..", "work-root") };
|
|
doc.workflows.change.steps[1].title = "hand finished work to review";
|
|
const preview = templatePreview({ document: doc, name: "team-shape", rolesDir, assertClean });
|
|
assert.equal(preview.template.workflows.change.steps[1].title, "hand finished work to review");
|
|
assert.ok(!preview.json.includes("{project}"));
|
|
// The owner sees the mention and may edit the text before saving.
|
|
assert.deepEqual(preview.mentions.find((m) => m.path === "workflows.change.steps[1].title"), { path: "workflows.change.steps[1].title", names: ["project work"] });
|
|
});
|
|
|
|
test("E2E6 free text is kept verbatim, no placeholder is written, and the preview lists each field that mentions an org-specific name", (t) => {
|
|
const { doc, rolesDir } = fixture(t);
|
|
const preview = templatePreview({ document: doc, name: "team-shape", rolesDir, assertClean });
|
|
const { template } = preview;
|
|
assert.equal(template.definitions.writer.instructions, doc.definitions.writer.instructions);
|
|
assert.equal(template.definitions.writer.personality.summary, "Careful acme engineer.");
|
|
assert.deepEqual(template.definitions.writer.personality.traits, ["works with ana", "plain"]);
|
|
assert.equal(template.board.columns[0].label, "acme backlog");
|
|
assert.equal(template.workflows.change.description, "Change for stack");
|
|
assert.equal(template.workflows.change.steps[0].title, "ana writes it");
|
|
for (const placeholder of ["{org}", "{project}", "{agent}", "{owner}", "{root}"]) assert.ok(!preview.json.includes(placeholder), placeholder);
|
|
assert.equal(template.description, "Saved from an organization.");
|
|
assert.ok(!Object.hasOwn(preview, "replacements"));
|
|
const at = (path) => preview.mentions.find((m) => m.path === path)?.names;
|
|
// A root path is one mention, labelled by kind alone, not the ids inside it.
|
|
assert.deepEqual(at("definitions.writer.instructions"), ["organization acme", "project root", "owner jason", "agent ana", "project stack"]);
|
|
assert.deepEqual(at("definitions.writer.personality.traits[1]"), undefined);
|
|
assert.deepEqual(at("board.columns[0].label"), ["organization acme"]);
|
|
// Role names (writer, coder) are not mentions; a longer word holding a name is not one either.
|
|
assert.equal(at("board.columns[1].label"), undefined);
|
|
doc.board.columns[1].label = "acmes and anas";
|
|
assert.equal(templatePreview({ document: doc, name: "team-shape", rolesDir, assertClean }).mentions.find((m) => m.path === "board.columns[1].label"), undefined);
|
|
// Every mention is a kept free-text field, in path order.
|
|
const paths = preview.mentions.map((m) => m.path);
|
|
assert.deepEqual(paths, [...paths].sort());
|
|
for (const p of paths) assert.ok(preview.freeText.some((f) => f.path === p), p);
|
|
});
|
|
|
|
test("E2E6 text that looks like a placeholder, or an instance named root, org or agent, stays verbatim", (t) => {
|
|
const { doc, rolesDir } = fixture(t);
|
|
doc.roles.root = { definition: "org:writer" };
|
|
doc.roles.org = { definition: "org:writer" };
|
|
doc.definitions.writer.instructions = "Work for acme with root on stack. Ask org, then jason. Keep {org} and {agent} literal.";
|
|
const preview = templatePreview({ document: doc, name: "team-shape", rolesDir, assertClean });
|
|
assert.equal(preview.template.definitions.writer.instructions, doc.definitions.writer.instructions);
|
|
assert.deepEqual(preview.mentions.find((m) => m.path === "definitions.writer.instructions").names, ["organization acme", "agent root", "project stack", "agent org", "owner jason"]);
|
|
});
|
|
|
|
test("SP8 the preview lists every kept free-text field with its saved text", (t) => {
|
|
const { doc, rolesDir } = fixture(t);
|
|
const preview = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean });
|
|
const { template } = preview;
|
|
const expected = [];
|
|
for (const [name, entry] of Object.entries(template.definitions)) {
|
|
if (typeof entry.instructions === "string") expected.push([`definitions.${name}.instructions`, entry.instructions]);
|
|
if (entry.personality) {
|
|
if (entry.personality.summary !== undefined) expected.push([`definitions.${name}.personality.summary`, entry.personality.summary]);
|
|
if (entry.personality.voice !== undefined) expected.push([`definitions.${name}.personality.voice`, entry.personality.voice]);
|
|
(entry.personality.traits ?? []).forEach((v, i) => expected.push([`definitions.${name}.personality.traits[${i}]`, v]));
|
|
}
|
|
}
|
|
template.board.columns.forEach((c, i) => expected.push([`board.columns[${i}].label`, c.label]));
|
|
for (const [w, wf] of Object.entries(template.workflows)) {
|
|
expected.push([`workflows.${w}.description`, wf.description]);
|
|
wf.steps.forEach((s, i) => expected.push([`workflows.${w}.steps[${i}].title`, s.title]));
|
|
}
|
|
expected.sort(([a], [b]) => (a < b ? -1 : 1));
|
|
assert.deepEqual(preview.freeText.map((f) => [f.path, f.text]), expected);
|
|
});
|
|
|
|
test("SP8 a held credential in kept text refuses template-secret-suspected naming the field path, never the text", (t) => {
|
|
const { doc, rolesDir } = fixture(t);
|
|
doc.definitions.writer.personality.voice = `short, key ${TOKEN}`;
|
|
refused(() => templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }), "template-secret-suspected", "definitions.writer.personality.voice");
|
|
try { templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }); } catch (e) { assert.ok(!e.message.includes(TOKEN)); }
|
|
// A held value inside other text still refuses: the scan reads the text save keeps.
|
|
const second = fixture(t);
|
|
second.doc.workflows.change.steps[1].title = `acme${TOKEN}`;
|
|
refused(() => templatePreview({ document: second.doc, name: "acme-shape", rolesDir: second.rolesDir, assertClean }), "template-secret-suspected", "workflows.change.steps[1].title");
|
|
// A workflow key that holds one is named by its parent and [key].
|
|
const third = fixture(t);
|
|
third.doc.workflows[TOKEN.replace(/-/g, "")] = third.doc.workflows.change;
|
|
const keyed = (v) => { if (JSON.stringify(v).includes(TOKEN.replace(/-/g, ""))) throw Object.assign(new Error("x"), { code: "credential-leak" }); };
|
|
refused(() => templatePreview({ document: third.doc, name: "acme-shape", rolesDir: third.rolesDir, assertClean: keyed }), "template-secret-suspected", "workflows[key]");
|
|
});
|
|
|
|
test("SP8 a second save to the same name refuses and leaves the first file", (t) => {
|
|
const { doc, rolesDir, configDir } = fixture(t);
|
|
const { digest } = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean });
|
|
templateSave({ document: doc, name: "acme-shape", digest, rolesDir, assertClean, configDir, account: "jason" });
|
|
const file = join(configDir, "templates", "jason", "acme-shape.json");
|
|
const first = readFileSync(file);
|
|
doc.definitions.writer.instructions = "Changed.";
|
|
const next = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean });
|
|
refused(() => templateSave({ document: doc, name: "acme-shape", digest: next.digest, rolesDir, assertClean, configDir, account: "jason" }), "template-exists", "a template of this name is already saved; save under another name");
|
|
// E2E6 fix 8: the name is checked first, so a stale or malformed-but-valid digest still says the name is taken.
|
|
refused(() => templateSave({ document: doc, name: "acme-shape", digest: "0".repeat(64), rolesDir, assertClean, configDir, account: "jason" }), "template-exists");
|
|
assert.deepEqual(readFileSync(file), first);
|
|
assert.equal(lstatSync(file).mode & 0o777, 0o600);
|
|
assert.equal(lstatSync(join(configDir, "templates", "jason")).mode & 0o777, 0o700);
|
|
});
|
|
|
|
test("SP8 a name that is no pack id refuses before any path is built", (t) => {
|
|
const { doc, rolesDir, configDir } = fixture(t);
|
|
for (const name of ["..", ".", "a/b", "../x", ".hidden", "", "A", "x".repeat(65), 7]) {
|
|
refused(() => templatePreview({ document: doc, name, rolesDir, assertClean }), "invalid-request");
|
|
refused(() => templateSave({ document: doc, name, digest: "0".repeat(64), rolesDir, assertClean, configDir, account: "jason" }), "invalid-request");
|
|
}
|
|
for (const account of ["..", "a/b", ""]) refused(() => templateList({ configDir, account, rolesDir }), "human-required");
|
|
assert.ok(!existsSync(join(configDir, "templates")));
|
|
});
|
|
|
|
test("SP8 a ninth hire refuses", (t) => {
|
|
const { doc, rolesDir } = fixture(t);
|
|
// pm, cto, coder, reviewer, ana and bo are six; three more make nine.
|
|
doc.roles.dee = { definition: "coder" };
|
|
doc.roles.eve = { definition: "coder" };
|
|
assert.equal(templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }).template.hires.length, 8);
|
|
doc.roles.fay = { definition: "reviewer" };
|
|
refused(() => templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }), "starter-pack-invalid", "a template holds at most 8 hires; this one would hold 9");
|
|
});
|
|
|
|
test("SP8 each packs entry holds only id and version, once, at the saved scope", (t) => {
|
|
const { doc, rolesDir } = fixture(t);
|
|
const org = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }).template;
|
|
assert.deepEqual(org.packs, [{ id: "coding", version: "1.0.0" }]);
|
|
const project = templatePreview({ document: doc, name: "stack-shape", project: "stack", rolesDir, assertClean }).template;
|
|
assert.deepEqual(project.packs, [{ id: "intake", version: "2.1.0" }]);
|
|
for (const entry of [...org.packs, ...project.packs]) assert.deepEqual(Object.keys(entry).sort(), ["id", "version"]);
|
|
});
|
|
|
|
test("SP8 hires and names: no chief, no assistant, no decision id; hire-<decision> becomes <template>-<n>", (t) => {
|
|
const { doc, rolesDir } = fixture(t);
|
|
doc.roles.helper = { definition: "assistant" };
|
|
const preview = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean });
|
|
const { template } = preview;
|
|
assert.ok(!preview.json.includes("0f1e2d3c"), "a decision id left the organization");
|
|
assert.ok(Object.hasOwn(template.definitions, "reviewer-1"));
|
|
assert.deepEqual(template.workflows.change.steps[1].roles, ["reviewer-1", "human"]);
|
|
assert.deepEqual(template.workflows.change.steps.map((s) => s.id), ["step-1", "step-2"]);
|
|
const hires = Object.fromEntries(template.hires.map((h) => [h.name, h.definition]));
|
|
assert.deepEqual(hires, { "coder-1": "writer", "coder-2": "coder", "cto-1": "cto", "pm-1": "pm", "reviewer-1": "reviewer-1", "reviewer-2": "reviewer" });
|
|
for (const h of template.hires) assert.deepEqual(Object.keys(h).sort(), ["definition", "name", "reason"]);
|
|
assert.ok(!Object.values(hires).some((d) => /chief|assistant/.test(d)));
|
|
assert.deepEqual(template.definitions.coder, { template: "coder", sha256: readTemplate(rolesDir, "coder").sha256 });
|
|
assert.equal(template.board.layout, "list");
|
|
});
|
|
|
|
test("SP8 the saved file reads back as an origin-saved pack, and list shows it", (t) => {
|
|
const { doc, rolesDir, configDir } = fixture(t);
|
|
assert.deepEqual(templateList({ configDir, account: "jason", rolesDir }), { templates: [] });
|
|
const { digest } = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean });
|
|
templateSave({ document: doc, name: "acme-shape", digest, rolesDir, assertClean, configDir, account: "jason" });
|
|
const { pack } = readSavedTemplate({ configDir, account: "jason", name: "acme-shape", rolesDir, uid: process.getuid() });
|
|
assert.equal(pack.id, "acme-shape");
|
|
writeFileSync(join(configDir, "templates", "jason", "broken.json"), "{", { mode: 0o600 });
|
|
const listed = templateList({ configDir, account: "jason", rolesDir });
|
|
assert.deepEqual(listed.templates.map((x) => x.name), ["acme-shape", "broken"]);
|
|
assert.equal(listed.templates[0].hires, 6);
|
|
assert.equal(listed.templates[1].readable, false);
|
|
// Another account's list is its own.
|
|
assert.deepEqual(templateList({ configDir, account: "alex", rolesDir }), { templates: [] });
|
|
});
|
|
|
|
test("SP8 a project template carries the project's agents, the definitions it names and the default board", (t) => {
|
|
const { doc, rolesDir } = fixture(t);
|
|
doc.projects.stack.agents = { ana: { definition: "org:writer" }, rev: { definition: "reviewer" }, chief: { exclude: true } };
|
|
const { template, scope } = templatePreview({ document: doc, name: "stack-shape", project: "stack", rolesDir, assertClean });
|
|
assert.equal(scope, "project");
|
|
assert.deepEqual(Object.keys(template.definitions).sort(), ["reviewer", "writer"]);
|
|
assert.deepEqual(template.board, JSON.parse(JSON.stringify(DEFAULT_BOARD)));
|
|
assert.deepEqual(template.workflows, {});
|
|
assert.equal(template.description, "Saved from one project of an organization.");
|
|
refused(() => templatePreview({ document: doc, name: "x", project: "nope", rolesDir, assertClean }), "invalid-request");
|
|
});
|
|
|
|
test("SP8 a from the template moved under is re-pinned when the role still narrows it, and refused when it widens", (t) => {
|
|
const { doc, rolesDir } = fixture(t);
|
|
doc.definitions.writer.from.sha256 = "1".repeat(64);
|
|
const preview = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean });
|
|
assert.deepEqual(preview.repinned, ["definitions.writer.from"]);
|
|
assert.equal(preview.template.definitions.writer.from.sha256, readTemplate(rolesDir, "coder").sha256);
|
|
doc.definitions.writer.role.tools = [...doc.definitions.writer.role.tools, "not-a-coder-tool"];
|
|
assert.throws(() => templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean }), (e) => e instanceof TemplateError && /^starter-pack-invalid$/.test(e.code));
|
|
});
|
|
|
|
test("SP8 save takes the preview's digest; a changed organization refuses pack-plan-stale and writes nothing", (t) => {
|
|
const { doc, rolesDir, configDir } = fixture(t);
|
|
const { digest } = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean });
|
|
doc.board.columns[1].label = "Now";
|
|
refused(() => templateSave({ document: doc, name: "acme-shape", digest, rolesDir, assertClean, configDir, account: "jason" }), "pack-plan-stale");
|
|
refused(() => templateSave({ document: doc, name: "acme-shape", digest: "zz", rolesDir, assertClean, configDir, account: "jason" }), "invalid-request");
|
|
assert.ok(!existsSync(join(configDir, "templates", "jason", "acme-shape.json")));
|
|
});
|
|
|
|
test("SP8 save refuses a templates directory another user can write, or an account directory that is a link", (t) => {
|
|
const { doc, rolesDir, configDir, root } = fixture(t);
|
|
const { digest } = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean });
|
|
mkdirSync(join(configDir, "templates"), { mode: 0o700 });
|
|
chmodSync(join(configDir, "templates"), 0o777);
|
|
refused(() => templateSave({ document: doc, name: "acme-shape", digest, rolesDir, assertClean, configDir, account: "jason" }), "not-configured");
|
|
chmodSync(join(configDir, "templates"), 0o700);
|
|
const elsewhere = join(root, "elsewhere");
|
|
mkdirSync(elsewhere, { mode: 0o700 });
|
|
symlinkSync(elsewhere, join(configDir, "templates", "jason"));
|
|
refused(() => templateSave({ document: doc, name: "acme-shape", digest, rolesDir, assertClean, configDir, account: "jason" }), "not-configured");
|
|
refused(() => templateList({ configDir, account: "jason", rolesDir }), "not-configured");
|
|
assert.ok(!existsSync(join(elsewhere, "acme-shape.json")));
|
|
// A file planted as a link at the name is not followed.
|
|
rmSync(join(configDir, "templates", "jason"));
|
|
mkdirSync(join(configDir, "templates", "jason"), { mode: 0o700 });
|
|
symlinkSync(join(elsewhere, "target.json"), join(configDir, "templates", "jason", "acme-shape.json"));
|
|
assert.throws(() => templateSave({ document: doc, name: "acme-shape", digest, rolesDir, assertClean, configDir, account: "jason" }), TemplateError);
|
|
assert.ok(!existsSync(join(elsewhere, "target.json")));
|
|
});
|
|
|
|
test("SP8 the saved template is a pack checkPack accepts as saved", (t) => {
|
|
const { doc, rolesDir } = fixture(t);
|
|
const { template } = templatePreview({ document: doc, name: "acme-shape", rolesDir, assertClean });
|
|
assert.deepEqual(checkPack(structuredClone(template), { origin: "saved", rolesDir }), template);
|
|
});
|