// site-sync CORE regression suite (capture/diff/plan/apply/rollback/deletes/rows/ // rowIdentity/rowPayload/layoutFiles). Each check pins a bug found in the // 2026-10-15 re-analysis: it fails on the pre-fix code and passes after. // // ./test/run.sh coreUnit.js // // SS_CORE_LIB may point the lib modules at another copy (e.g. the pre-fix // snapshot) to prove the checks fail there; the plugin itself is bootstrapped from // this checkout either way. 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 File = require("@saltcorn/data/models/file"); const View = require("@saltcorn/data/models/view"); const Page = require("@saltcorn/data/models/page"); const Tag = require("@saltcorn/data/models/tag"); const TableConstraint = require("@saltcorn/data/models/table_constraints"); const { db, ok, section, step, summary, installPlugin, bootstrapPlugin } = require("./harness"); const LIB = process.env.SS_CORE_LIB || path.resolve(__dirname, "..", "lib"); const capture = require(path.join(LIB, "capture")); const diff = require(path.join(LIB, "diff")); const planLib = require(path.join(LIB, "plan")); const applyLib = require(path.join(LIB, "apply")); const rollback = require(path.join(LIB, "rollback")); const rowsLib = require(path.join(LIB, "rows")); const rowId = require(path.join(LIB, "rowIdentity")); const env = require(path.join(LIB, "env")); // Smallest buffer the layout-file sniffer accepts as a PNG. const PNG_BYTES = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(24, 7)]); const mkTable = async (name, fields) => { const t = await Table.create(name, { min_role_read: 1, min_role_write: 1 }); for (const f of fields) { await Field.create(Object.assign({ table: t, label: f.name }, f)); } return Table.findOne({ name }); }; const rowsOf = async (name) => { return await Table.findOne({ name }).getRows({}, {}); }; // Names of the indexes covering a table's _ss_row_uuid column, in the current schema. const uuidIndexes = async (tableName) => { const physical = db.sqlsanitize(tableName); const rs = db.isSQLite ? await db.query(`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = $1 AND sql LIKE '%_ss_row_uuid%'`, [physical]) : await db.query(`SELECT indexname AS name FROM pg_indexes WHERE schemaname = $1 AND tablename = $2 AND indexdef LIKE '%_ss_row_uuid%'`, [db.getTenantSchema(), physical]); return rs.rows.map((r) => r.name); }; const current = async () => { return JSON.parse(JSON.stringify((await capture.buildDesiredState()).pack)); }; const planFor = async (desired, sourceRows) => { return await planLib.buildPlan({ peerRow: null, env: await env.getEnv(), desired, desiredHash: capture.hashPack(desired), sourceEnvId: "src", sourceRows: sourceRows || {} }); }; const outcome = async (fn) => { try { return { value: await fn() }; } catch (e) { return { error: e }; } }; const dropTableFromPack = (pack, name) => { pack.tables = pack.tables.filter((t) => t.name !== name); return pack; }; const rollbackChecks = async () => { section("rollback restores without duplicating, at most once"); await step("retained drop candidate is not duplicated by rollback", async () => { const t = await mkTable("cu_orders", [{ name: "total", type: "Integer" }]); for (const v of [10, 20, 30]) { await t.insertRow({ total: v }); } const desired = dropTableFromPack(await current(), "cu_orders"); desired.config.site_name = "cu-rollback-1"; const { plan_id } = await planFor(desired); await applyLib.applyPlan(plan_id, { prunable: [], confirmDataDrops: false }); ok("gated drop retained the table", (await rowsOf("cu_orders")).length === 3); await rollback.rollbackApply(plan_id, {}); const n = (await rowsOf("cu_orders")).length; ok("rollback leaves exactly the original 3 rows", n === 3, `rows=${n}`); const again = await outcome(() => rollback.rollbackApply(plan_id, {})); ok("a second rollback of the same plan is refused", !!again.error); ok("...and does not duplicate rows", (await rowsOf("cu_orders")).length === 3); }); await step("dropped field data comes back on rollback", async () => { const t = await mkTable("cu_notes", [{ name: "title", type: "String" }, { name: "note", type: "String" }]); await t.insertRow({ title: "a", note: "keep-me" }); const desired = await current(); const spec = desired.tables.find((x) => x.name === "cu_notes"); spec.fields = spec.fields.filter((f) => f.name !== "note"); const { plan_id } = await planFor(desired); await applyLib.applyPlan(plan_id, { prunable: ["cu_notes"], confirmDataDrops: true }); ok("apply dropped the field", !Table.findOne({ name: "cu_notes" }).getField("note")); await rollback.rollbackApply(plan_id, {}); const rs = await rowsOf("cu_notes"); ok("one row after rollback", rs.length === 1, `rows=${rs.length}`); ok("field value restored", rs.length === 1 && rs[0].note === "keep-me", JSON.stringify(rs)); }); await step("dropped table rows return with their original primary keys", async () => { const parent = await mkTable("cu_par", [{ name: "nm", type: "String" }]); const id1 = await parent.insertRow({ nm: "p1" }); const id2 = await parent.insertRow({ nm: "p2" }); await Table.findOne({ name: "cu_par" }).deleteRows({ id: id1 }); const desired = dropTableFromPack(await current(), "cu_par"); const { plan_id } = await planFor(desired); await applyLib.applyPlan(plan_id, { prunable: ["cu_par"], confirmDataDrops: true }); ok("table dropped", !Table.findOne({ name: "cu_par" })); await rollback.rollbackApply(plan_id, {}); const rs = await rowsOf("cu_par"); ok("row restored under its original id", rs.length === 1 && rs[0].id === id2, JSON.stringify(rs)); }); await step("rows restored with explicit primary keys leave the id sequence usable", async () => { const t = await mkTable("cu_seq", [{ name: "v", type: "String" }]); for (const v of ["a", "b", "c"]) { await t.insertRow({ v }); } const { plan_id } = await planFor(dropTableFromPack(await current(), "cu_seq")); await applyLib.applyPlan(plan_id, { prunable: ["cu_seq"], confirmDataDrops: true }); await rollback.rollbackApply(plan_id, {}); ok("three rows back", (await rowsOf("cu_seq")).length === 3); const r = await Table.findOne({ name: "cu_seq" }).tryInsertRow({ v: "d" }); ok("a new row inserts after the restored ids", r.success !== undefined && r.success > 3, JSON.stringify(r)); }); await step("a confirmed table rename keeps rows and data mode, and rolls back", async () => { await mkTable("cu_rn_a", [{ name: "qq", type: "String" }]); await rowsLib.setDataMode("cu_rn_a", "managed"); await Table.findOne({ name: "cu_rn_a" }).insertRow({ qq: "r1" }); const desired = await current(); desired.tables.find((x) => x.name === "cu_rn_a").name = "cu_rn_b"; const { plan_id, plan } = await planFor(desired); ok("rename detected", plan.entities.tables.rename.some((r) => r.from === "cu_rn_a" && r.to === "cu_rn_b")); const res = await applyLib.applyPlan(plan_id, { confirmRenames: ["cu_rn_a"] }); if (db.isSQLite) { ok("SQLite: rename falls back to create + gated drop", res.renames.failed.some((r) => r.from === "cu_rn_a")); ok("SQLite: old table keeps its rows", (await rowsOf("cu_rn_a")).length === 1); return; } ok("PG: renamed", res.renames.renamed.some((r) => r.from === "cu_rn_a"), JSON.stringify(res.renames)); ok("PG: rows moved with the table", !Table.findOne({ name: "cu_rn_a" }) && (await rowsOf("cu_rn_b")).length === 1); ok("PG: data mode moved", (await rowsLib.dataModeOf("cu_rn_b")) === "managed" && (await rowsLib.dataModeOf("cu_rn_a")) === "user"); await rollback.rollbackApply(plan_id, {}); const aRows = Table.findOne({ name: "cu_rn_a" }) ? await rowsOf("cu_rn_a") : []; ok("PG rollback: original name back with its rows", !Table.findOne({ name: "cu_rn_b" }) && aRows.length === 1, JSON.stringify(aRows)); ok("PG rollback: data mode moved back", (await rowsLib.dataModeOf("cu_rn_a")) === "managed"); }); await step("a managed row inserted locally (no identity yet) is not duplicated by rollback", async () => { await mkTable("cu_nouuid", [{ name: "v", type: "String" }]); await rowsLib.setDataMode("cu_nouuid", "managed"); await Table.findOne({ name: "cu_nouuid" }).insertRow({ v: "local" }); const desired = await current(); desired.config.site_name = "cu-nouuid"; const { plan_id } = await planFor(desired); await applyLib.applyPlan(plan_id, {}); await rollback.rollbackApply(plan_id, {}); const rs = await rowsOf("cu_nouuid"); ok("still exactly one row", rs.length === 1, JSON.stringify(rs)); ok("the row has an identity", rs.length === 1 && !!rs[0]._ss_row_uuid); }); await step("rollback under additive sync mode still removes what the apply created", async () => { await env.setEnvFields({ sync_mode: "additive" }); try { const desired = await current(); const live = await mkTable("cu_added", [{ name: "x", type: "String" }]); const withAdded = await current(); await live.delete(); const { plan_id } = await planFor(withAdded); await applyLib.applyPlan(plan_id, {}); ok("apply created the table", !!Table.findOne({ name: "cu_added" })); await rollback.rollbackApply(plan_id, {}); ok("rollback removed it", !Table.findOne({ name: "cu_added" })); ok("desired pack untouched", desired.tables.every((x) => x.name !== "cu_added")); } finally { await env.setEnvFields({ sync_mode: "mirror" }); } }); await step("rollback of a starter seed lets it seed again", async () => { await mkTable("cu_seed", [{ name: "v", type: "String" }]); await rowsLib.setDataMode("cu_seed", "starter"); const manifest = { cu_seed: { mode: "starter", rows: [{ __uuid: "cu-s1", v: "a" }, { __uuid: "cu-s2", v: "b" }] } }; const first = await planFor(await current(), manifest); await applyLib.applyPlan(first.plan_id, {}); ok("seeded", (await rowsOf("cu_seed")).length === 2); await rollback.rollbackApply(first.plan_id, {}); ok("rollback removed the seed", (await rowsOf("cu_seed")).length === 0); const second = await planFor(await current(), manifest); await applyLib.applyPlan(second.plan_id, {}); const n = (await rowsOf("cu_seed")).length; ok("re-apply seeds again", n === 2, `rows=${n}`); }); }; const planApplyChecks = async () => { section("plan and apply agree"); await step("code pages are previewed, applied, and pruned", async () => { const desired = await current(); desired.code_pages = [{ name: "cu_code", code: "const cuFn = () => 1;", tags: [] }]; const { plan_id, plan } = await planFor(desired); ok("plan is not empty", !diff.isEmptyPlan(plan), JSON.stringify(plan.summary)); ok("plan lists the code page", !!(plan.entities.code_pages && plan.entities.code_pages.create.length === 1)); ok("plan warns about server-side code", plan.warnings.some((w) => /code page/.test(w))); await applyLib.applyPlan(plan_id, {}); ok("installed", "cu_code" in getState().getConfig("function_code_pages", {})); const without = await current(); without.code_pages = []; const p2 = await planFor(without); ok("deletion is previewed", !!(p2.plan.entities.code_pages && p2.plan.entities.code_pages.delete.length === 1)); await applyLib.applyPlan(p2.plan_id, {}); ok("pruned by a mirror apply", !("cu_code" in getState().getConfig("function_code_pages", {}))); }); await step("view rename is planned as create + delete and fully applied", async () => { const t = await mkTable("cu_vt", [{ name: "x", type: "String" }]); await View.create({ name: "cu_old_list", viewtemplate: "List", table_id: t.id, configuration: { columns: [] }, min_role: 100 }); const desired = await current(); desired.views.find((v) => v.name === "cu_old_list").name = "cu_new_list"; const { plan_id, plan } = await planFor(desired); ok("old view is a planned delete", plan.entities.views.delete.some((v) => v.name === "cu_old_list")); await applyLib.applyPlan(plan_id, {}); ok("new view exists", !!View.findOne({ name: "cu_new_list" })); ok("old view removed", !View.findOne({ name: "cu_old_list" })); }); await step("a removed constraint is removed", async () => { const t = await mkTable("cu_con", [{ name: "code", type: "String" }]); await TableConstraint.create({ table: t, type: "Unique", configuration: { fields: ["code"] } }); const desired = await current(); desired.tables.find((x) => x.name === "cu_con").constraints = []; const { plan_id } = await planFor(desired); await applyLib.applyPlan(plan_id, {}); const left = await TableConstraint.find({ table_id: t.id }); ok("constraint gone", left.length === 0, `left=${left.length}`); }); await step("a model the source dropped is deleted", async () => { const Model = require("@saltcorn/data/models/model"); const t = await mkTable("cu_mt", [{ name: "x", type: "String" }]); await Model.create({ name: "cu_model", table_id: t.id, modelpattern: "cu_pattern", configuration: {} }); const desired = await current(); desired.models = desired.models.filter((m) => m.name !== "cu_model"); const { plan_id, plan } = await planFor(desired); ok("model delete planned", plan.entities.models.delete.some((m) => m.name === "cu_model")); await applyLib.applyPlan(plan_id, {}); ok("model deleted", !(await Model.findOne({ name: "cu_model", table_id: t.id }))); }); await step("a view moved to another table is re-bound", async () => { const a = await mkTable("cu_va", [{ name: "x", type: "String" }]); const b = await mkTable("cu_vb", [{ name: "x", type: "String" }]); await View.create({ name: "cu_moving", viewtemplate: "List", table_id: a.id, configuration: { columns: [] }, min_role: 100 }); const desired = await current(); desired.views.find((v) => v.name === "cu_moving").table = "cu_vb"; const { plan_id } = await planFor(desired); await applyLib.applyPlan(plan_id, {}); ok("view now bound to cu_vb", View.findOne({ name: "cu_moving" }).table_id === b.id); }); await step("lookup table and its inbound FK field drop in ONE apply", async () => { const cats = await mkTable("cu_cats", [{ name: "label", type: "String" }]); const c1 = await cats.insertRow({ label: "a" }); const items = await mkTable("cu_items", [{ name: "nm", type: "String" }, { name: "cat", type: "Key to cu_cats" }]); await items.insertRow({ nm: "i1", cat: c1 }); const desired = dropTableFromPack(await current(), "cu_cats"); const it = desired.tables.find((x) => x.name === "cu_items"); it.fields = it.fields.filter((f) => f.name !== "cat"); const { plan_id } = await planFor(desired); const res = await applyLib.applyPlan(plan_id, { prunable: ["cu_cats", "cu_items"], confirmDataDrops: true }); ok("cu_cats dropped", !Table.findOne({ name: "cu_cats" }), JSON.stringify(res.deletes.skipped)); ok("cu_items.cat dropped", !Table.findOne({ name: "cu_items" }).getField("cat")); }); await step("unchanged tags are not deleted and re-created", async () => { await Tag.create({ name: "cu_tag", entries: [] }); const { plan_id } = await planFor(await current()); // SQLite reuses a deleted row's id, so count the deletes instead of comparing ids. const realDelete = Tag.prototype.delete; let deletes = 0; Tag.prototype.delete = async function () { deletes += this.name === "cu_tag" ? 1 : 0; return realDelete.apply(this, arguments); }; try { await applyLib.applyPlan(plan_id, {}); } finally { Tag.prototype.delete = realDelete; } ok("unchanged tag never deleted", deletes === 0, `deletes=${deletes}`); ok("tag still present", !!(await Tag.findOne({ name: "cu_tag" }))); }); await step("a failed apply restores the plan status and names the plan", async () => { const desired = await current(); desired.tags.push({ name: "cu_badtag", entries: [{ page_name: "cu_no_such_page" }] }); const { plan_id } = await planFor(desired); const r = await outcome(() => applyLib.applyPlan(plan_id, {})); ok("apply threw", !!r.error); ok("error carries planId", !!r.error && r.error.planId === plan_id); ok("status restored to pending", (await planLib.loadPlan(plan_id)).status === "pending"); }); await step("concurrent applies of one plan: exactly one runs", async () => { const desired = await current(); desired.config.site_name = "cu-concurrent"; const { plan_id } = await planFor(desired); const results = await Promise.all([outcome(() => applyLib.applyPlan(plan_id, {})), outcome(() => applyLib.applyPlan(plan_id, {}))]); const failed = results.filter((x) => x.error); ok("one succeeded, one refused", results.filter((x) => x.value).length === 1 && failed.length === 1); ok("refusal is the site lock or the claim, not a mid-apply error", failed.length === 1 && /another apply is running on this site|cannot be applied/.test(failed[0].error.message), failed.length ? failed[0].error.message : ""); const redo = await outcome(() => applyLib.applyPlan(plan_id, {})); ok("re-applying an applied plan is refused", !!redo.error); }); await step("a structural rename match is not applied without confirmation", async () => { await mkTable("cu_ren_old", [{ name: "zz", type: "String" }]); const desired = await current(); desired.tables.find((x) => x.name === "cu_ren_old").name = "cu_ren_new"; const { plan_id, plan } = await planFor(desired); ok("rename detected", plan.entities.tables.rename.some((r) => r.from === "cu_ren_old")); const res = await applyLib.applyPlan(plan_id, {}); ok("no rename attempted without confirmRenames", res.renames.renamed.length === 0 && res.renames.failed.length === 0, JSON.stringify(res.renames)); }); await step("data-bearing drop form never pre-fills the allow-list", async () => { const t = await mkTable("cu_formdrop", [{ name: "x", type: "String" }]); await t.insertRow({ x: "1" }); const { plan_id, plan } = await planFor(dropTableFromPack(await current(), "cu_formdrop")); const html = planLib.renderApplyForm(plan_id, plan, ""); ok("prunable input has no value", /name="prunable"/.test(html) && !/name="prunable"[^>]*value="[^"]+"/.test(html)); }); }; const rowChecks = async () => { section("reference rows"); await step("managed parent+child prune does not abort the apply", async () => { await mkTable("cu_countries", [{ name: "nm", type: "String" }]); await rowsLib.setDataMode("cu_countries", "managed"); await mkTable("cu_cities", [{ name: "nm", type: "String" }, { name: "country", type: "Key to cu_countries" }]); await rowsLib.setDataMode("cu_cities", "managed"); const cid = await Table.findOne({ name: "cu_countries" }).insertRow({ nm: "X" }); await Table.findOne({ name: "cu_cities" }).insertRow({ nm: "Y", country: cid }); await rowsLib.buildRowManifest(); const manifest = { cu_countries: { mode: "managed", rows: [] }, cu_cities: { mode: "managed", rows: [] } }; const { plan_id } = await planFor(await current(), manifest); const r = await outcome(() => applyLib.applyPlan(plan_id, {})); ok("apply succeeded", !r.error, r.error ? r.error.message : ""); ok("both rows pruned", (await rowsOf("cu_countries")).length === 0 && (await rowsOf("cu_cities")).length === 0); }); await step("a managed row still referenced by user data is kept, not cascaded", async () => { await mkTable("cu_kinds", [{ name: "nm", type: "String" }]); await rowsLib.setDataMode("cu_kinds", "managed"); const kid = await Table.findOne({ name: "cu_kinds" }).insertRow({ nm: "k" }); const orders = await mkTable("cu_userorders", [{ name: "kind", type: "Key to cu_kinds", attributes: { on_delete: "Cascade" } }]); await orders.insertRow({ kind: kid }); await rowsLib.buildRowManifest(); const { plan_id } = await planFor(await current(), { cu_kinds: { mode: "managed", rows: [] } }); const r = await outcome(() => applyLib.applyPlan(plan_id, {})); ok("apply succeeded", !r.error, r.error ? r.error.message : ""); ok("user row survives", (await rowsOf("cu_userorders")).length === 1); ok("referenced managed row kept", (await rowsOf("cu_kinds")).length === 1); ok("reported as a warning", !r.error && r.value.warnings.some((w) => /still referenced/.test(w))); }); await step("an unchanged managed table is in sync and not rewritten", async () => { await mkTable("cu_colors", [{ name: "nm", type: "String" }, { name: "on", type: "Bool" }, { name: "at", type: "Date" }]); await rowsLib.setDataMode("cu_colors", "managed"); await Table.findOne({ name: "cu_colors" }).insertRow({ nm: "red", on: true, at: new Date("2024-01-02T03:04:05Z") }); const { manifest } = await rowsLib.buildRowManifest(); const shipped = JSON.parse(JSON.stringify({ cu_colors: manifest.cu_colors })); const { plan_id, plan } = await planFor(await current(), shipped); ok("preview shows no row updates", plan.rows.perTable.cu_colors.updates === 0, JSON.stringify(plan.rows.perTable.cu_colors)); const res = await applyLib.applyPlan(plan_id, {}); ok("apply rewrote nothing", res.rows.perTable.cu_colors.updated === 0, JSON.stringify(res.rows.perTable.cu_colors)); }); await step("a managed row with a REQUIRED File field is inserted", async () => { const bytes = Buffer.from("cu required file"); const hash = require("../lib/common/hash").sha256Hex(bytes); await mkTable("cu_docs", [{ name: "title", type: "String" }, { name: "scan", type: "File", required: true }]); await rowsLib.setDataMode("cu_docs", "managed"); const manifest = { cu_docs: { mode: "managed", rows: [{ __uuid: "cu-d1", title: "t", scan__file: { hash, filename: "s.txt", mime: "text/plain", min_role_read: 1 } }] } }; const { plan_id } = await planFor(await current(), manifest); // SQLite cannot add a NOT NULL column to an existing table, so emulate the // Postgres NOT NULL on the required File column. const realInsert = Table.prototype.tryInsertRow; Table.prototype.tryInsertRow = async function (v) { if (this.name === "cu_docs" && !v.scan) { return { error: "null value in column \"scan\" violates not-null constraint" }; } return realInsert.apply(this, arguments); }; try { await applyLib.applyPlan(plan_id, { fileFetcher: async () => bytes }); } finally { Table.prototype.tryInsertRow = realInsert; } const rs = await rowsOf("cu_docs"); ok("row inserted with its file", rs.length === 1 && !!rs[0].scan, JSON.stringify(rs)); }); await step("a failed file fetch keeps the existing file reference", async () => { const a = await File.from_contents("cu_a.txt", "text/plain", Buffer.from("cu file A"), 1, 1, "/"); await mkTable("cu_att", [{ name: "doc", type: "File" }]); await rowsLib.setDataMode("cu_att", "managed"); await Table.findOne({ name: "cu_att" }).insertRow({ doc: a.field_value }); const { manifest } = await rowsLib.buildRowManifest(); const shipped = JSON.parse(JSON.stringify({ cu_att: manifest.cu_att })); shipped.cu_att.rows[0].doc__file.hash = "f".repeat(64); const { plan_id } = await planFor(await current(), shipped); await applyLib.applyPlan(plan_id, { fileFetcher: async () => { throw new Error("offline"); } }); ok("reference kept", (await rowsOf("cu_att"))[0].doc === a.field_value); }); await step("a starter seed that is incomplete is not marked shipped", async () => { await mkTable("cu_sp", [{ name: "v", type: "String", required: true }]); await rowsLib.setDataMode("cu_sp", "starter"); const manifest = { cu_sp: { mode: "starter", rows: [{ __uuid: "cu-sp1", v: "ok" }, { __uuid: "cu-sp2", v: null }] } }; const { plan_id } = await planFor(await current(), manifest); await applyLib.applyPlan(plan_id, {}); const mode = await db.selectMaybeOne("_ss_table_modes", { table_name: "cu_sp" }); ok("not marked shipped", !mode.starter_shipped_at); }); await step("duplicate row identities are re-keyed", async () => { const t = await mkTable("cu_dup", [{ name: "v", type: "String" }]); await rowsLib.setDataMode("cu_dup", "managed"); await t.insertRow({ v: "a", _ss_row_uuid: "cu-same" }); await Table.findOne({ name: "cu_dup" }).insertRow({ v: "b", _ss_row_uuid: "cu-same" }); await rowId.ensureManagedSchema("cu_dup"); const uuids = new Set((await rowsOf("cu_dup")).map((r) => r._ss_row_uuid)); ok("two distinct identities", uuids.size === 2); }); await step("identity column never appears in the schema diff", async () => { await mkTable("cu_idcol", [{ name: "v", type: "String" }]); await rowsLib.setDataMode("cu_idcol", "managed"); const desired = await current(); const spec = desired.tables.find((x) => x.name === "cu_idcol"); spec.fields = spec.fields.filter((f) => f.name !== "_ss_row_uuid"); const { plan } = await planFor(desired); const u = plan.entities.tables.update.find((x) => x.name === "cu_idcol"); ok("no drop of _ss_row_uuid planned", !u || !u.fields.delete.some((f) => f.name === "_ss_row_uuid")); }); await step("managed sync works for a table whose name needs sanitizing", async () => { await mkTable("Cu Blog Posts", [{ name: "title", type: "String" }]); const r = await outcome(() => rowsLib.setDataMode("Cu Blog Posts", "managed")); ok("setDataMode succeeded", !r.error, r.error ? r.error.message : ""); await Table.findOne({ name: "Cu Blog Posts" }).insertRow({ title: "p" }); const m = await outcome(() => rowsLib.buildRowManifest()); ok("manifest built", !m.error && m.value.manifest["Cu Blog Posts"].rows.length === 1, m.error ? m.error.message : ""); }); await step("an upgraded install converges to exactly one uuid index", async () => { await mkTable("Cu_Legacy", [{ name: "v", type: "String" }]); await rowsLib.setDataMode("Cu_Legacy", "managed"); // the pre-0.0.2 statement: unquoted index name await db.query(`CREATE INDEX IF NOT EXISTS Cu_Legacy_ss_row_uuid ON ${db.getTenantSchemaPrefix()}"Cu_Legacy" (_ss_row_uuid)`); await rowId.ensureManagedSchema("Cu_Legacy"); const idx = await uuidIndexes("Cu_Legacy"); ok("one uuid index left", idx.length === 1, idx.join(",")); }); await step("setDataMode refuses infrastructure tables", async () => { const r = await outcome(() => rowsLib.setDataMode("_ss_table_modes", "user")); ok("refused", !!r.error); }); await step("preview does not mutate the target", async () => { await mkTable("cu_nomut", [{ name: "v", type: "String" }]); await db.insert("_ss_table_modes", { table_name: "cu_nomut", data_mode: "managed", updated_at: new Date().toISOString() }, { noid: true }); await planFor(await current(), { cu_nomut: { mode: "managed", rows: [{ __uuid: "cu-nm1", v: "x" }] } }); ok("no identity column added by a preview", !Table.findOne({ name: "cu_nomut" }).getField("_ss_row_uuid")); }); await step("a stale mode row is reported by the manifest builder", async () => { const t = await mkTable("cu_gone", [{ name: "v", type: "String" }]); await rowsLib.setDataMode("cu_gone", "managed"); await t.delete(); const { warnings } = await rowsLib.buildRowManifest(); ok("warned", warnings.some((w) => /cu_gone/.test(w))); }); await step("renameModeRow carries the data mode to the new name", async () => { await mkTable("cu_mode_a", [{ name: "v", type: "String" }]); await rowsLib.setDataMode("cu_mode_a", "managed"); await rowsLib.renameModeRow("cu_mode_a", "cu_mode_b"); ok("mode moved", (await rowsLib.dataModeOf("cu_mode_b")) === "managed" && (await rowsLib.dataModeOf("cu_mode_a")) === "user"); }); }; const layoutChecks = async () => { section("layout files"); await step("a fetched layout image keeps its page's visibility", async () => { const f = await File.from_contents("cu_secret.png", "image/png", PNG_BYTES, 1, 1, "/"); await Page.create({ name: "cu_admin_page", title: "a", description: "", min_role: 1, fixed_states: {}, attributes: {}, layout: { above: [{ type: "image", fileid: f.field_value }] } }); const desired = await current(); await Page.findOne({ name: "cu_admin_page" }).delete(); await (await File.findOne(f.field_value)).delete(); const { plan_id } = await planFor(desired); await applyLib.applyPlan(plan_id, { fileFetcher: async () => PNG_BYTES }); const fid = Page.findOne({ name: "cu_admin_page" }).layout.above[0].fileid; const file = await File.findOne(fid); ok("materialized file is admin-only like its page", !!file && file.min_role_read === 1, file ? `min_role_read=${file.min_role_read}` : "no file"); }); }; // Postgres multi-tenant only: identifiers must be sanitized AND schema-qualified. // Tenant setup follows pgGate.js / upstream's tenant test. const tenantChecks = async () => { if (db.isSQLite) { return; } section("tenant schema (Postgres)"); const tenant = "cutenant"; const { add_tenant } = require("@saltcorn/data/db/state"); const { create_tenant, switchToTenant, insertTenant } = require("@saltcorn/admin-models/models/tenant"); const Plugin = require("@saltcorn/data/models/plugin"); await step("managed sync of a sanitized-name table inside a tenant schema", async () => { add_tenant(tenant); await switchToTenant(await insertTenant(tenant, `admin@${tenant}.local`, "ssCore tenant check"), `http://${tenant}.example.com/`); await create_tenant({ t: tenant, loadAndSaveNewPlugin() {}, plugin_loader() {} }); await db.runWithTenant(tenant, async () => { await Plugin.loadAllPlugins(); await db.runWithTenant(db.connectObj.default_schema, () => getState().setConfig("tenants_unsafe_plugins", true)); await installPlugin(); ok("running in the tenant schema", db.getTenantSchema() === tenant); await mkTable("Cu Tenant Posts", [{ name: "title", type: "String" }]); await rowsLib.setDataMode("Cu Tenant Posts", "managed"); await Table.findOne({ name: "Cu Tenant Posts" }).insertRow({ title: "old" }); const { manifest } = await rowsLib.buildRowManifest(); const shipped = JSON.parse(JSON.stringify({ "Cu Tenant Posts": manifest["Cu Tenant Posts"] })); shipped["Cu Tenant Posts"].rows[0].title = "new"; const { plan_id } = await planFor(await current(), shipped); const res = await applyLib.applyPlan(plan_id, {}); ok("row converged in the tenant", (await rowsOf("Cu Tenant Posts"))[0].title === "new"); ok("exactly one row updated", res.rows.perTable["Cu Tenant Posts"].updated === 1, JSON.stringify(res.rows.perTable)); ok("plan claimed and marked applied in the tenant", (await planLib.loadPlan(plan_id)).status === "applied"); ok("tenant table has one uuid index", (await uuidIndexes("Cu Tenant Posts")).length === 1); await mkTable("CuTenLegacy", [{ name: "v", type: "String" }]); await rowsLib.setDataMode("CuTenLegacy", "managed"); await db.query(`CREATE INDEX IF NOT EXISTS CuTenLegacy_ss_row_uuid ON ${db.getTenantSchemaPrefix()}"CuTenLegacy" (_ss_row_uuid)`); await rowId.ensureManagedSchema("CuTenLegacy"); const idx = await uuidIndexes("CuTenLegacy"); ok("legacy index dropped in the tenant schema", idx.length === 1, idx.join(",")); }); ok("nothing leaked into the root schema", !Table.findOne({ name: "Cu Tenant Posts" })); }); }; // The recorded compatibility vectors (test/compatVectors.js) in a child process: // they set their own (fake) session secret. const compatChecks = async () => { section("compatibility vectors"); await step("peer secrets and signatures from earlier versions still work", async () => { const childProcess = require("child_process"); const r = childProcess.spawnSync(process.execPath, [path.join(__dirname, "compatVectors.js")], { encoding: "utf8" }); ok("test/compatVectors.js passes", r.status === 0, r.status === 0 ? "" : (r.stdout + r.stderr).trim()); }); }; const main = async () => { const where = db.isSQLite ? `sqlite ${process.env.SQLITE_FILEPATH}` : `postgres ${db.connectObj.database} schema ${db.getTenantSchema()}`; console.log(`DB = ${where} LIB = ${LIB}`); await bootstrapPlugin(); await rollbackChecks(); await planApplyChecks(); await rowChecks(); await layoutChecks(); await tenantChecks(); await compatChecks(); process.exit(summary() === 0 ? 0 : 1); }; main();