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

614 lines
34 KiB
JavaScript

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