sc-site-sync/test/mcpTools.js
2026-10-19 01:25:16 -05:00

575 lines
35 KiB
JavaScript

// site-sync MCP tools suite. Every definition is checked against saltcorn-mcp's
// contract (its lib/schemaCheck.js) and every tool is driven through its handler
// with a ctx stub, as the MCP server calls it: the read tools (which must change
// nothing), settings, table modes, peers (a generated secret is returned once and
// never again), a pull from a SIGNED MOCK PEER over real HTTP, the trimmed and
// redacted plan views, the drift refusal, apply -> rollback, the per-site
// apply/rollback guard, plan retention, unpairing that keeps rollback possible,
// and errors reported through ctx.fail.
//
// ./test/run.sh mcpTools.js
// ./test/runPg.sh mcpTools.js
const fs = require("fs");
const http = require("http");
const path = require("path");
const { getState } = require("@saltcorn/data/db/state");
const Table = require("@saltcorn/data/models/table");
const Field = require("@saltcorn/data/models/field");
const Page = require("@saltcorn/data/models/page");
const Trigger = require("@saltcorn/data/models/trigger");
const { db, ok, section, step, summary, bootstrapPlugin } = require("./harness");
const protocol = require("../lib/common/peerProtocol");
const { localPluginList } = require("../lib/common/pluginParity");
const capture = require("../lib/capture");
const rowsLib = require("../lib/rows");
const planLib = require("../lib/plan");
const siteLock = require("../lib/siteLock");
const peers = require("../lib/peers");
const env = require("../lib/env");
const { PREFIX } = require("../lib/mcpTools");
const { API_PATHS } = require("../lib/transport");
const { randomUuid } = require("../lib/ids");
const { HEADERS, PLUGIN_NAME, PLUGIN_VERSION } = require("../lib/constants");
const MCP_LIB = path.resolve(__dirname, "..", "..", "mcp", "lib");
const HAS_MCP = fs.existsSync(path.join(MCP_LIB, "schemaCheck.js"));
// saltcorn-mcp's mask for a redacted value.
const REDACTED = "[redacted]";
// What the fallback redaction masks when the sibling mcp plugin is absent.
const SECRET_KEY = /pass(word)?|secret|token|api_?key|credential/i;
// Values that must never appear in a plan view.
const TRIGGER_SECRET = "hunter2-mcp-trigger-secret";
const SOURCE_ROW_SECRET = "tok-mcp-source-row-secret";
const CODE_MARK = "mcpCodeMark";
const MOCK_ONLY_PLUGIN = "mcp-mock-only-plugin";
// The scope each tool is meant to have.
const SCOPES = {
read: ["status", "list_peers", "peer_health", "list_tables", "list_plans", "get_plan", "local_state"],
write: ["plan_pull"],
admin: ["apply_plan", "rollback", "update_settings", "add_peer", "rotate_peer_secret", "delete_peer", "set_table_mode"]
};
const DESTRUCTIVE = ["apply_plan", "rollback", "rotate_peer_secret", "delete_peer", "set_table_mode"];
const OPEN_WORLD = ["peer_health", "plan_pull"];
// saltcorn-mcp's result redaction (its lib/audit.js) when present, else a local
// mask of credential-named keys.
const fallbackRedact = (value) => {
if (Array.isArray(value)) {
return value.map(fallbackRedact);
}
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, SECRET_KEY.test(k) ? REDACTED : fallbackRedact(v)]));
}
return value;
};
const redact = HAS_MCP ? require(path.join(MCP_LIB, "audit")).redact : fallbackRedact;
class ToolFail extends Error {}
const defs = require("../index").mcp_tools();
const byName = Object.fromEntries(defs.map((d) => [d.name, d]));
// Every peer secret (hex) this suite has seen, and the tool results that carried one.
const knownSecrets = new Set();
const leaks = [];
const ctxFor = (scope) => ({
scope: scope,
user: { id: 1, role_id: 1, email: "admin@test" },
req: {},
tenant: db.getTenantSchema(),
isRoot: true,
fail: (m) => {
throw new ToolFail(m);
},
redact: redact
});
// Call a tool's handler as the MCP server does, with a token of the tool's own
// scope: { ok: true, value } or { ok: false, error } for a ctx.fail (anything else
// thrown is a bug and propagates). A result may carry a peer secret only as the
// shared_secret this very call generated; any other is recorded as a leak.
const call = async (name, args) => {
const def = byName[PREFIX + name];
let out;
try {
out = { ok: true, value: await def.handler(args || {}, ctxFor(def.scope)) };
} catch (e) {
if (!(e instanceof ToolFail)) {
throw e;
}
out = { ok: false, error: e.message };
}
const text = JSON.stringify(out);
for (const s of knownSecrets) {
if (text.includes(s)) {
leaks.push(`${name} returned a known secret`);
}
}
if (/peer_secret_/.test(text)) {
leaks.push(`${name} returned a sealed secret column`);
}
if (out.ok && out.value && out.value.shared_secret) {
knownSecrets.add(out.value.shared_secret);
}
return out;
};
// The value of a call that must succeed (a failure throws, failing the step).
const value = async (name, args) => {
const r = await call(name, args);
if (!r.ok) {
throw new Error(`${name} failed: ${r.error}`);
}
return r.value;
};
const mkTable = async (name, fieldNames) => {
const t = await Table.create(name, { min_role_read: 1, min_role_write: 1 });
for (const f of fieldNames) {
await Field.create({ table: t, name: f, label: f, type: "String" });
}
return Table.findOne({ name });
};
const columnOf = async (name, field) => {
return (await Table.findOne({ name }).getRows({}, {})).map((r) => r[field]).sort();
};
const planCount = async () => {
return Number((await db.query(`SELECT COUNT(*) AS c FROM ${db.getTenantSchemaPrefix()}_ss_plans`)).rows[0].c);
};
// A signed mock site-sync peer over real HTTP: it verifies every request's
// signature against mock.secret (the pairing secret as the peer knows it) and signs
// every reply with it, answering /api/health with mock.plugins and /api/state with
// mock.state ({ hash, pack, rows }).
const startMockPeer = async (mock) => {
const server = http.createServer((req, res) => {
const nonce = req.headers[HEADERS.NONCE.toLowerCase()];
const reply = (status, obj, signed) => {
const body = Buffer.from(JSON.stringify(obj));
res.statusCode = status;
res.setHeader("content-type", "application/json");
if (signed) {
res.setHeader(HEADERS.RESPONSE_SIGNATURE, protocol.signResponse(mock.secret, { nonce, status, body }));
}
res.end(body);
};
const canonical = protocol.buildCanonical({
timestamp: req.headers[HEADERS.TIMESTAMP.toLowerCase()],
nonce: nonce,
method: req.method,
path: req.url,
targetHost: protocol.normalizeHost(req.headers.host),
body: ""
});
if (!mock.secret || !protocol.verifySignature(mock.secret, canonical, req.headers[HEADERS.SIGNATURE.toLowerCase()])) {
reply(401, { error: "bad signature" }, false);
return;
}
mock.calls.push(req.url);
const who = { plugin: PLUGIN_NAME, version: PLUGIN_VERSION, env_id: mock.envId, label: "mock source" };
if (req.url === API_PATHS.HEALTH) {
reply(200, { ...who, plugins: mock.plugins }, true);
} else if (req.url === API_PATHS.STATE) {
reply(200, { ...who, ...mock.state }, true);
} else {
reply(404, { error: "not found" }, true);
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
mock.server = server;
mock.base = `http://127.0.0.1:${server.address().port}`;
};
const contract = async () => {
section("definitions follow saltcorn-mcp's contract");
await step("schemas, names, texts, scopes, flags", async () => {
const problems = HAS_MCP ? defs.flatMap((d) => require(path.join(MCP_LIB, "schemaCheck")).checkSchemaSubset(d.inputSchema, d.name)) : [];
ok(`schemas in MCP's supported subset${HAS_MCP ? "" : " (mcp absent: not checked)"}`, problems.length === 0, problems.join("; "));
ok("every tool prefixed sitesync_ with a valid name, a title and a description",
defs.every((d) => d.name.startsWith(PREFIX) && /^[a-z][a-z0-9_]{0,63}$/.test(d.name) && d.title && d.description && typeof d.handler === "function"));
ok("texts are ASCII", defs.every((d) => !/[^\x00-\x7f]/.test(JSON.stringify({ t: d.title, d: d.description, s: d.inputSchema }))));
const undescribed = (schema) => Object.values(schema.properties || {}).some((p) => !p.description || undescribed(p));
ok("every argument (nested ones too) is described", defs.every((d) => !undescribed(d.inputSchema)));
ok("object schemas refuse unknown arguments", defs.every((d) => d.inputSchema.type === "object" && d.inputSchema.additionalProperties === false));
const expected = Object.values(SCOPES).flat().map((n) => PREFIX + n).sort();
ok("exactly the planned tools", JSON.stringify(defs.map((d) => d.name).sort()) === JSON.stringify(expected), defs.map((d) => d.name).join(","));
ok("each tool has its scope", Object.entries(SCOPES).every(([scope, names]) => names.every((n) => byName[PREFIX + n].scope === scope)));
ok("destructive exactly on apply / rollback / rotate / delete peer / table mode", defs.every((d) => !!d.destructive === DESTRUCTIVE.includes(d.name.slice(PREFIX.length))));
ok("openWorld exactly on peer_health and plan_pull", defs.every((d) => !!d.openWorld === OPEN_WORLD.includes(d.name.slice(PREFIX.length))));
ok("no read tool is destructive", defs.filter((d) => d.scope === "read").every((d) => !d.destructive));
});
};
const readTools = async () => {
section("read tools on a fresh site");
await step("status, list_peers, list_tables, list_plans, local_state", async () => {
const me = await env.getEnv();
const st = await value("status");
ok("status: identity, default policies, version, no peers", st.env_id === me.env_id && st.sync_mode === "mirror" && st.on_delete === "confirm" && st.on_apply === "confirm" && st.version === PLUGIN_VERSION && st.peer_count === 0);
ok("status: session secret configured, no synced tables", st.session_secret_configured === true && st.synced_tables.managed === 0 && st.synced_tables.starter === 0, JSON.stringify(st.synced_tables));
const lp = await value("list_peers");
ok("list_peers: none, with this site's env_id", lp.peers.length === 0 && lp.this_env_id === me.env_id);
const users = (await value("list_tables")).tables.find((t) => t.name === "users");
ok("list_tables: users is locked to user mode", !!users && users.locked === true && users.mode === "user");
ok("list_plans: none yet", (await value("list_plans")).plans.length === 0);
const bad = await call("list_plans", { status: "bogus" });
ok("list_plans: an invalid status is a tool error", !bad.ok && /invalid plan status/.test(bad.error));
const ls = await value("local_state");
ok("local_state: the hash a peer would pull, counts per kind", ls.hash === (await capture.buildDesiredState()).hash && typeof ls.counts.tables === "number" && Array.isArray(ls.config_settings));
});
};
const settings = async () => {
section("update_settings (admin): partial, validated");
await step("patch, refuse, clear, restore", async () => {
const s1 = await value("update_settings", { sync_mode: "additive", env_label: " mcp-target " });
ok("a partial patch changes only what it names", s1.sync_mode === "additive" && s1.label === "mcp-target" && s1.on_delete === "confirm" && s1.on_apply === "confirm");
const none = await call("update_settings", {});
ok("an empty patch is refused", !none.ok && /no settings given/.test(none.error));
const bad = await call("update_settings", { on_delete: "bogus" });
ok("an invalid value is refused by the operation (not only the schema)", !bad.ok && /invalid delete policy/.test(bad.error) && (await env.getEnv()).on_delete === "confirm");
const s2 = await value("update_settings", { env_label: null, sync_mode: "mirror" });
ok("null clears the label; mirror restored", s2.label === null && s2.sync_mode === "mirror");
});
};
// Local tables of the scenario: mcp_ref (managed reference rows a / b),
// mcp_doomed (user data the source does not have), mcp_old (empty; the source
// has it as mcp_new).
const tableModes = async () => {
section("set_table_mode (admin) + the data-mode validation fix");
await step("managed opt-in, locked / infra / unknown / invalid refused", async () => {
const ref = await mkTable("mcp_ref", ["name", "api_token"]);
await ref.insertRow({ name: "a", api_token: "tok-a" });
await ref.insertRow({ name: "b", api_token: "tok-b" });
const doomed = await mkTable("mcp_doomed", ["note"]);
await doomed.insertRow({ note: "row 1" });
await doomed.insertRow({ note: "row 2" });
await mkTable("mcp_old", ["legacy_code"]);
const set = await value("set_table_mode", { table: "mcp_ref", mode: "managed" });
ok("mcp_ref managed (was user)", set.mode === "managed" && set.previous_mode === "user");
const listed = (await value("list_tables")).tables.find((t) => t.name === "mcp_ref");
ok("list_tables shows it managed with its rows", listed.mode === "managed" && listed.rows === 2);
ok("status counts it", (await value("status")).synced_tables.managed === 1);
const users = await call("set_table_mode", { table: "users", mode: "managed" });
ok("users cannot be managed", !users.ok && /locked/.test(users.error));
const infra = await call("set_table_mode", { table: "_ss_peers", mode: "managed" });
ok("an infrastructure table has no data mode", !infra.ok && /infrastructure/.test(infra.error));
const missing = await call("set_table_mode", { table: "mcp_no_such_table", mode: "starter" });
ok("an unknown table is refused", !missing.ok && /no such table/.test(missing.error));
const invalid = await call("set_table_mode", { table: "mcp_ref", mode: "bogus" });
ok("an invalid mode is refused by the operation", !invalid.ok && /invalid data mode/.test(invalid.error));
let direct = "";
try {
await rowsLib.setDataMode("mcp_ref", "bogus");
} catch (e) {
direct = e.message;
}
ok("rows.setDataMode itself refuses an invalid mode (was stored verbatim)", /invalid data mode/.test(direct) && (await rowsLib.dataModeOf("mcp_ref")) === "managed");
});
};
const peerTools = async (mock) => {
section("peers (admin): a generated secret is returned once, never again");
await step("add_peer generates; list never shows secrets; bad input refused", async () => {
const added = await value("add_peer", { env_id: mock.envId, base_url: mock.base, label: "mock" });
ok("generated shared secret returned once (64 hex) with this site's env_id", /^[0-9a-f]{64}$/.test(added.shared_secret || "") && added.this_env_id === (await env.getEnv()).env_id && added.peer.label === "mock");
ok("it is the sealed secret", (await peers.peerSecret(added.peer.peer_id)).toString("hex") === added.shared_secret);
mock.secret = Buffer.from(added.shared_secret, "hex");
mock.peerId = added.peer.peer_id;
const lp = await value("list_peers");
ok("list_peers lists it without any secret", lp.peers.length === 1 && lp.peers[0].env_id === mock.envId && !("shared_secret" in lp.peers[0]));
const dup = await call("add_peer", { env_id: mock.envId, base_url: mock.base });
ok("a duplicate env_id is refused", !dup.ok && /already exists/.test(dup.error));
const scheme = await call("add_peer", { env_id: randomUuid(), base_url: "file:///etc/passwd" });
ok("a non-http(s) base_url is refused", !scheme.ok && /http or https/.test(scheme.error));
const hex = await call("add_peer", { env_id: randomUuid(), base_url: mock.base, existing_secret: "zz" });
ok("a malformed secret is refused", !hex.ok && /64 hex/.test(hex.error));
});
await step("add_peer with an existing secret returns none; labels resolve, ambiguity refused", async () => {
const pasted = protocol.randomSecret().toString("hex");
knownSecrets.add(pasted);
const spare = await value("add_peer", { env_id: randomUuid(), base_url: "http://localhost:3999", label: "spare", existing_secret: pasted });
ok("no shared_secret when the secret was given", !("shared_secret" in spare) && (await peers.peerSecret(spare.peer.peer_id)).toString("hex") === pasted);
const twin = await value("add_peer", { env_id: randomUuid(), base_url: "http://localhost:3998", label: "spare" });
const amb = await call("peer_health", { peer: "spare" });
ok("an ambiguous label is refused", !amb.ok && /several peers/.test(amb.error));
const gone = await value("delete_peer", { peer: twin.peer.env_id });
ok("delete_peer by env_id", gone.deleted.env_id === twin.peer.env_id && gone.plans_kept_for_rollback === 0);
const unknown = await call("peer_health", { peer: "nobody" });
ok("an unknown peer is a tool error", !unknown.ok && /unknown peer/.test(unknown.error));
});
await step("peer_health over signed HTTP; rotate breaks the pairing, pasting the peer's secret restores it", async () => {
const h = await value("peer_health", { peer: "mock" });
ok("reachable: version, env_id, parity warning", h.reachable && h.version === PLUGIN_VERSION && h.env_id === mock.envId && h.parity_warnings.some((w) => w.includes(MOCK_ONLY_PLUGIN)), JSON.stringify(h));
ok("by id and by env_id too", (await value("peer_health", { peer: mock.peerId })).reachable && (await value("peer_health", { peer: mock.envId })).reachable);
const rotated = await value("rotate_peer_secret", { peer: "mock" });
ok("rotate generates a new secret, returned once", /^[0-9a-f]{64}$/.test(rotated.shared_secret || "") && rotated.shared_secret !== mock.secret.toString("hex"));
const broken = await value("peer_health", { peer: "mock" });
ok("the peer (old secret) now refuses: unreachable with the reason", broken.reachable === false && /401/.test(broken.error), broken.error);
const replaced = await value("rotate_peer_secret", { peer: mock.envId, secret: mock.secret.toString("hex") });
ok("installing the peer's secret returns no secret", !("shared_secret" in replaced));
ok("the pairing works again", (await value("peer_health", { peer: "mock" })).reachable);
});
};
// The source site: this site plus a config change, a trigger whose configuration
// holds a password, a page, a code page and the table mcp_new; minus mcp_doomed
// and mcp_old. Its managed rows: a changed (to a credential-looking value), b
// gone, c new. Built for real, captured, then reverted here.
const buildSource = async (mock) => {
const prevName = getState().getConfig("site_name") || "";
const prevCode = getState().getConfig("function_code_pages", {});
await getState().setConfig("site_name", "MCP Source");
await Trigger.create({ name: "mcp_hook", action: "run_js_code", when_trigger: "Never", description: "", configuration: { code: "return 1;", password: TRIGGER_SECRET } });
await Page.create({ name: "mcp_page", title: "MCP page", description: "", min_role: 1, layout: { type: "blank", contents: "hello" }, fixed_states: {} });
await getState().setConfig("function_code_pages", { ...prevCode, mcp_cp: `const ${CODE_MARK} = 1;` });
await mkTable("mcp_new", ["legacy_code"]);
const cap = await capture.buildDesiredState();
const pack = cap.pack;
pack.tables = pack.tables.filter((t) => t.name !== "mcp_doomed" && t.name !== "mcp_old");
await Table.findOne({ name: "mcp_new" }).delete();
await getState().setConfig("function_code_pages", prevCode);
await (await Page.findOne({ name: "mcp_page" })).delete();
await (await Trigger.findOne({ name: "mcp_hook" })).delete();
await getState().setConfig("site_name", prevName);
const { manifest } = await rowsLib.buildRowManifest();
const rows = manifest.mcp_ref.rows.filter((r) => r.name !== "b").map((r) => (r.name === "a" ? { ...r, api_token: SOURCE_ROW_SECRET } : r));
rows.push({ name: "c", api_token: "tok-c", __uuid: randomUuid() });
mock.state = { hash: capture.hashPack(pack), pack, rows: { ...manifest, mcp_ref: { ...manifest.mcp_ref, rows } } };
return prevName;
};
// The before-apply state of this site (what rollback must bring back).
const assertBefore = async (label, siteName) => {
ok(`${label}: config, trigger, page, code page as before`,
(getState().getConfig("site_name") || "") === siteName && !Trigger.findOne({ name: "mcp_hook" }) && !(await Page.findOne({ name: "mcp_page" })) &&
!("mcp_cp" in getState().getConfig("function_code_pages", {})));
ok(`${label}: mcp_doomed back with its rows, mcp_old back, mcp_new gone`,
JSON.stringify(await columnOf("mcp_doomed", "note")) === JSON.stringify(["row 1", "row 2"]) && !!Table.findOne({ name: "mcp_old" }) && !Table.findOne({ name: "mcp_new" }));
ok(`${label}: managed rows as before`, JSON.stringify(await columnOf("mcp_ref", "api_token")) === JSON.stringify(["tok-a", "tok-b"]), (await columnOf("mcp_ref", "api_token")).join(","));
};
const assertApplied = async (label) => {
ok(`${label}: config, trigger, page, code page installed`,
getState().getConfig("site_name") === "MCP Source" && !!Trigger.findOne({ name: "mcp_hook" }) && !!(await Page.findOne({ name: "mcp_page" })) &&
"mcp_cp" in getState().getConfig("function_code_pages", {}));
ok(`${label}: mcp_doomed dropped (named in prunable), mcp_old -> mcp_new`, !Table.findOne({ name: "mcp_doomed" }) && !Table.findOne({ name: "mcp_old" }) && !!Table.findOne({ name: "mcp_new" }));
ok(`${label}: managed rows mirrored (a changed, b removed, c added)`, JSON.stringify(await columnOf("mcp_ref", "api_token")) === JSON.stringify(["tok-c", SOURCE_ROW_SECRET].sort()), (await columnOf("mcp_ref", "api_token")).join(","));
};
const APPLY_GATES = { prunable: ["mcp_doomed"], confirm_data_drops: true, confirm_renames: ["mcp_old"] };
const pullReviewApplyRollback = async (mock) => {
let planId;
let siteName;
section("plan_pull (write) from the signed mock peer; trimmed + redacted views");
await step("pull records a plan and returns its trimmed view", async () => {
siteName = await buildSource(mock);
const n0 = await planCount();
mock.calls = [];
const v = await value("plan_pull", { peer: "mock" });
planId = v.plan_id;
ok("one plan recorded; health + state pulled over signed HTTP", (await planCount()) === n0 + 1 && mock.calls.includes(API_PATHS.HEALTH) && mock.calls.includes(API_PATHS.STATE));
ok("standing: pending, appliable, not stale, no restore point", v.status === "pending" && v.can_apply === true && v.stale === false && v.has_restore_point === false && v.can_rollback === false && v.in_sync === false);
ok("peer and direction", v.direction === "pull" && v.peer.label === "mock" && v.source_env_id === mock.envId);
ok("names per kind", v.changes.triggers.create.includes("mcp_hook") && v.changes.pages.create.includes("mcp_page") && v.changes.code_pages.create.includes("mcp_cp"), JSON.stringify(v.changes));
ok("the data-bearing drop and the gates it needs", v.changes.tables.delete.some((d) => d.name === "mcp_doomed" && d.row_count === 2) &&
JSON.stringify(v.data_drops.tables) === JSON.stringify(["mcp_doomed"]) && v.data_drops.confirm_data_drops_needed === true && v.data_drops.refused_by_policy === false &&
v.destructive.some((d) => d.data_bearing && /mcp_doomed/.test(d.text)));
ok("the rename candidate", v.rename_candidates.length === 1 && v.rename_candidates[0].from === "mcp_old" && v.rename_candidates[0].to === "mcp_new");
ok("config change, row counts", v.config_changes.includes("site_name") && JSON.stringify(v.rows.mcp_ref) === JSON.stringify({ mode: "managed", inserts: 1, updates: 1, deletes: 1 }), JSON.stringify(v.rows));
ok("warnings: code page (server-side JS) and plugin parity", v.warnings.some((w) => /code page/.test(w) && w.includes("mcp_cp")) && v.warnings.some((w) => w.includes(MOCK_ONLY_PLUGIN)));
const text = JSON.stringify(v);
ok("trimmed: no definitions, no source rows, no credentials", !text.includes(TRIGGER_SECRET) && !text.includes(SOURCE_ROW_SECRET) && !text.includes(CODE_MARK) && !/sourceRows|desired_pack|"desired"/.test(text));
});
await step("get_plan with entity: one redacted entity at a time", async () => {
const trig = await value("get_plan", { plan_id: planId, entity: { kind: "triggers", name: "mcp_hook" } });
ok("a trigger create: the source's definition, password masked", trig.action === "create" && trig.to.name === "mcp_hook" && trig.current === null &&
trig.to.configuration.password === REDACTED && !JSON.stringify(trig).includes(TRIGGER_SECRET), JSON.stringify(trig.to.configuration));
const cfg = await value("get_plan", { plan_id: planId, entity: { kind: "config", name: "site_name" } });
const here = (await capture.buildDesiredState()).pack.config;
ok("a config setting: to and current", cfg.to === "MCP Source" && cfg.current === (here.site_name === undefined ? null : here.site_name), JSON.stringify(cfg));
const drop = await value("get_plan", { plan_id: planId, entity: { kind: "tables", name: "mcp_doomed" } });
ok("a table drop: row count and the current table", drop.action === "delete" && drop.row_count === 2 && drop.current.name === "mcp_doomed" && drop.to === null);
const ren = await value("get_plan", { plan_id: planId, entity: { kind: "tables", name: "mcp_old" } });
ok("a rename: from, and the source's table", ren.action === "rename" && ren.from === "mcp_old" && ren.to.name === "mcp_new");
const cp = await value("get_plan", { plan_id: planId, entity: { kind: "code_pages", name: "mcp_cp" } });
ok("a code page: its code, for review", cp.action === "create" && cp.to.code.includes(CODE_MARK));
const nope = await call("get_plan", { plan_id: planId, entity: { kind: "views", name: "no_such_view" } });
ok("an entity the plan does not change is a tool error", !nope.ok && /does not change views/.test(nope.error));
const unknown = await call("get_plan", { plan_id: "no-such-plan" });
ok("an unknown plan is a tool error", !unknown.ok && /unknown plan/.test(unknown.error));
const listed = (await value("list_plans")).plans[0];
ok("list_plans: newest first, with peer and standing", listed.plan_id === planId && listed.status === "pending" && listed.peer === "mock" && listed.can_apply && !listed.has_restore_point);
});
await step("read tools changed nothing", async () => {
const ref = Table.findOne({ name: "mcp_ref" });
const id = await ref.insertRow({ name: "d", api_token: "tok-d" });
const h0 = (await capture.buildDesiredState()).hash;
const n0 = await planCount();
for (const [name, args] of [["status"], ["list_peers"], ["list_tables"], ["list_plans"], ["get_plan", { plan_id: planId }], ["local_state"]]) {
await value(name, args);
}
const rowD = (await ref.getRows({ id }, {}))[0];
ok("same desired state, same plans", (await capture.buildDesiredState()).hash === h0 && (await planCount()) === n0);
ok("local_state did not backfill a row identity (read-only)", !rowD._ss_row_uuid);
await ref.deleteRows({ id });
});
section("one apply or rollback per site at a time");
await step("an apply / a rollback while another holds the site lock is refused, then allowed", async () => {
const lease = await siteLock.acquire({ operation: "apply", planId: "held-elsewhere" });
let held = true;
try {
ok("status: apply_lock running, naming the holder", (await value("status")).apply_lock.holder.plan_id === "held-elsewhere");
const second = await call("apply_plan", { plan_id: planId });
const rb = await call("rollback", { plan_id: planId });
ok("a concurrent apply is refused, naming what runs", !second.ok && /another apply is running on this site \(plan held-elsewhere,/.test(second.error), second.error);
ok("without pointing at a rollback (nothing changed)", !second.ok && !/sitesync_rollback/.test(second.error));
ok("a concurrent rollback is refused", !rb.ok && /another apply is running on this site/.test(rb.error), rb.error);
await lease.release();
held = false;
ok("status: apply_lock idle again", (await value("status")).apply_lock.state === "idle");
const free = await call("apply_plan", { plan_id: "no-such-plan" });
ok("free again afterwards (the next refusal is about the plan, not the lock)", !free.ok && /unknown plan/.test(free.error), free.error);
} finally {
if (held) {
await lease.release();
}
}
});
section("drift: a plan whose target changed is stale and refused");
await step("stale flag + apply refusal, then fresh again", async () => {
await getState().setConfig("site_name", "drifted meanwhile");
const v = await value("get_plan", { plan_id: planId });
ok("get_plan: stale, not appliable", v.stale === true && v.can_apply === false);
const refused = await call("apply_plan", { plan_id: planId, ...APPLY_GATES });
ok("apply refused by the drift check, no restore point offered", !refused.ok && /changed since this plan was previewed/.test(refused.error) && !/restore point/.test(refused.error), refused.error);
await getState().setConfig("site_name", siteName);
ok("fresh again once the drift is undone", (await value("get_plan", { plan_id: planId })).stale === false);
});
section("apply_plan (admin) -> rollback (admin)");
await step("apply with the typed gates", async () => {
const r = await value("apply_plan", { plan_id: planId, ...APPLY_GATES });
ok("applied; rollback offered; no credentials in the result", r.applied && r.can_rollback && r.deletes_skipped.length === 0 && !JSON.stringify(r).includes(SOURCE_ROW_SECRET) && !JSON.stringify(r).includes(TRIGGER_SECRET), JSON.stringify(r.deletes_skipped));
await assertApplied("after apply");
const listed = (await value("list_plans", { status: "applied" })).plans.find((p) => p.plan_id === planId);
ok("list_plans: applied, restore point, can roll back", !!listed && listed.has_restore_point && listed.can_rollback && !listed.can_apply && listed.stale === null);
const again = await call("apply_plan", { plan_id: planId });
ok("applying it again is refused, pointing at the rollback", !again.ok && /cannot be applied: it is applied/.test(again.error) && /sitesync_rollback/.test(again.error));
const repull = await value("plan_pull", { peer: "mock" });
ok("a fresh pull is in sync (converged)", repull.in_sync === true, JSON.stringify(repull.summary));
});
await step("rollback restores the pre-apply state, once", async () => {
const r = await value("rollback", { plan_id: planId });
ok("rolled back", r.rolled_back === true && r.plan_id === planId);
await assertBefore("after rollback", siteName);
const again = await call("rollback", { plan_id: planId });
ok("a second rollback is refused", !again.ok && /cannot be rolled back/.test(again.error));
ok("list_plans: rolled_back", (await value("list_plans", { status: "rolled_back" })).plans.some((p) => p.plan_id === planId));
});
return { firstPlanId: planId, siteName };
};
// Retention keeps the newest PLAN_RETENTION plans -- but never one that still has a
// restore point (a burst of pulls must not cost a recent apply its rollback).
const retention = async (firstPlanId) => {
section("plan retention keeps plans that have a restore point");
await step("a burst of newer plans prunes the oldest, not a plan with a restore point", async () => {
const schema = db.getTenantSchemaPrefix();
const since = Date.parse((await planLib.loadPlan(firstPlanId)).created_at);
const span = Date.now() - since;
const burst = planLib.PLAN_RETENTION + 5;
for (let i = 1; i <= burst; i++) {
await db.query(
`INSERT INTO ${schema}_ss_plans (plan_id, peer_id, source_env_id, direction, desired_pack, plan_json, precondition_hash, source_hash, status, created_at) VALUES ($1,NULL,'burst','pull','{}','{}','h','h','pending',$2)`,
[`burst-${i}`, new Date(since + Math.floor((span * i) / (burst + 1))).toISOString()]
);
}
await value("plan_pull", { peer: "mock" });
ok("the oldest burst plans were pruned", !(await planLib.loadPlan("burst-1")) && !!(await planLib.loadPlan(`burst-${burst}`)));
ok("the older plan with a restore point was kept", !!(await planLib.loadPlan(firstPlanId)));
await db.query(`DELETE FROM ${schema}_ss_plans WHERE source_env_id = 'burst'`);
});
};
const unpairKeepsRollback = async (mock, siteName) => {
section("delete_peer (admin) keeps applied plans rollbackable");
await step("apply, unpair, roll back without the peer", async () => {
const applied = await value("plan_pull", { peer: "mock" });
await value("apply_plan", { plan_id: applied.plan_id, ...APPLY_GATES });
await assertApplied("second apply");
const pending = await value("plan_pull", { peer: "mock" });
const del = await value("delete_peer", { peer: "mock" });
ok("one applied plan kept for rollback", del.deleted.env_id === mock.envId && del.plans_kept_for_rollback === 1);
ok("the peer's other plans went with it", !(await planLib.loadPlan(pending.plan_id)));
const kept = (await value("list_plans")).plans.find((p) => p.plan_id === applied.plan_id);
ok("the kept plan belongs to no peer and can still roll back", !!kept && kept.peer === null && kept.can_rollback);
ok("no peer of this pairing is left", !(await value("list_peers")).peers.some((p) => p.env_id === mock.envId));
ok("rollback works after unpairing", (await value("rollback", { plan_id: applied.plan_id })).rolled_back === true);
await assertBefore("after unpaired rollback", siteName);
});
};
const main = async () => {
console.log(`DB = ${db.isSQLite ? process.env.SQLITE_FILEPATH : `postgres ${db.connectObj.database}`}${HAS_MCP ? "" : " (saltcorn-mcp absent: fallback schema/redaction checks)"}`);
await bootstrapPlugin();
const mock = { envId: randomUuid(), secret: null, calls: [], state: null, plugins: (await localPluginList()).concat([{ name: MOCK_ONLY_PLUGIN, source: "npm", version: "1.0.0" }]) };
await startMockPeer(mock);
try {
await contract();
await readTools();
await settings();
await tableModes();
await peerTools(mock);
const { firstPlanId, siteName } = await pullReviewApplyRollback(mock);
await retention(firstPlanId);
await unpairKeepsRollback(mock, siteName);
section("secrets");
ok("no tool result carried a peer secret except the one it generated", leaks.length === 0, leaks.join("; "));
} finally {
mock.server.close();
}
process.exit(summary());
};
main().catch((err) => {
console.error(err && err.stack ? err.stack : err);
process.exit(1);
});