614 lines
34 KiB
JavaScript
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();
|