// 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); });