1040 lines
50 KiB
JavaScript
1040 lines
50 KiB
JavaScript
// dev-deploy integration test suite.
|
|
//
|
|
// Prereqs: both saltcorn instances running and reachable, plugin installed +
|
|
// bootstrapped, admin@local / AdminP@ss1 present on both. Run from the
|
|
// dev-deploy/ directory:
|
|
//
|
|
// cd ~/claude/saltcorn && ./startServer.sh & ./startServerTest.sh &
|
|
// node test/e2e.js
|
|
//
|
|
// Each test resets only what it needs to. Tests run in order; assertion
|
|
// failures are caught and counted -- one failure does not stop the suite.
|
|
|
|
const { execFileSync, spawn } = require("node:child_process");
|
|
const assert = require("node:assert/strict");
|
|
const path = require("node:path");
|
|
|
|
const MAIN = { url: "http://localhost:3000", db: "/home/scott/claude/saltcorn/.dev-state/saltcorn.sqlite", env: "/home/scott/claude/saltcorn/.dev-state/env.sh" };
|
|
const TEST = { url: "http://localhost:3001", db: "/home/scott/claude/saltcorn/.dev-state-test/saltcorn.sqlite", env: "/home/scott/claude/saltcorn/.dev-state-test/env.sh" };
|
|
|
|
const MAIN_COOKIES = "/tmp/dd-test-jar-main.txt";
|
|
const TEST_COOKIES = "/tmp/dd-test-jar-test.txt";
|
|
|
|
|
|
// --- helpers ---
|
|
|
|
const sql = (dbPath, query) => {
|
|
const out = execFileSync("sqlite3", [dbPath, query], { encoding: "utf8" });
|
|
return out.trim();
|
|
};
|
|
|
|
|
|
const sqlRows = (dbPath, query) => {
|
|
const out = sql(dbPath, query);
|
|
if (!out) return [];
|
|
return out.split("\n");
|
|
};
|
|
|
|
|
|
const resetInstanceDb = (dbPath) => {
|
|
sql(dbPath, `
|
|
DELETE FROM _dd_ops;
|
|
DELETE FROM _dd_peers;
|
|
DELETE FROM _dd_anchors;
|
|
DELETE FROM _dd_table_modes;
|
|
DELETE FROM _sc_table_constraints;
|
|
DELETE FROM _sc_workflow_steps;
|
|
DELETE FROM _sc_page_group_members;
|
|
DELETE FROM _sc_page_groups;
|
|
DELETE FROM _sc_pages;
|
|
DELETE FROM _sc_triggers;
|
|
DELETE FROM _sc_views;
|
|
DELETE FROM _sc_config WHERE key IN ('menu_items', 'unrolled_menu_items');
|
|
UPDATE _sc_plugins SET configuration = NULL WHERE name != 'dev-deploy';
|
|
`);
|
|
// Strip _dd_entity_ids back to Saltcorn's bootstrap baseline.
|
|
sql(dbPath, `
|
|
DELETE FROM _dd_entity_ids WHERE kind NOT IN ('table','field','role');
|
|
DELETE FROM _dd_entity_ids WHERE kind='table' AND current_name != 'users';
|
|
DELETE FROM _dd_entity_ids WHERE kind='field' AND current_name NOT IN ('id','email','role_id');
|
|
DELETE FROM _dd_entity_ids WHERE kind='role' AND current_name NOT IN ('admin','staff','user','public');
|
|
`);
|
|
const userTables = sqlRows(dbPath, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '\\_%' ESCAPE '\\' AND name != 'users' AND name NOT LIKE 'sqlite_%';");
|
|
for (const t of userTables) {
|
|
if (t) sql(dbPath, `DROP TABLE IF EXISTS "${t}"`);
|
|
}
|
|
sql(dbPath, "DELETE FROM _sc_tables WHERE name != 'users'; DELETE FROM _sc_fields WHERE table_id NOT IN (SELECT id FROM _sc_tables);");
|
|
};
|
|
|
|
|
|
const runJs = (envPath, code) => {
|
|
return execFileSync(
|
|
"bash",
|
|
["-c", `source ${envPath} && saltcorn run-js --code ${JSON.stringify(code)}`],
|
|
{ encoding: "utf8" }
|
|
);
|
|
};
|
|
|
|
|
|
// scExec is like runJs but uses our test/sc-exec.js shim, giving the JS body
|
|
// full require() access -- needed for Field, TableConstraint, File, etc. that
|
|
// saltcorn run-js's vm sandbox doesn't expose. Code is piped via stdin to
|
|
// dodge shell-escape pitfalls with multi-line strings.
|
|
const scExec = (envPath, code) => {
|
|
return execFileSync(
|
|
"bash",
|
|
["-c", `source ${envPath} && node ${path.join(__dirname, "sc-exec.js")}`],
|
|
{ encoding: "utf8", input: code }
|
|
);
|
|
};
|
|
|
|
|
|
// --- HTTP helpers (browser-style admin auth via cookie jar) ---
|
|
|
|
const curl = (args) => {
|
|
const out = execFileSync("curl", args, { encoding: "utf8" });
|
|
return out;
|
|
};
|
|
|
|
|
|
const extractCsrfFromHtml = (html) => {
|
|
const m = html.match(/name="_csrf"[^>]*value="([^"]+)"/);
|
|
return m ? m[1] : null;
|
|
};
|
|
|
|
|
|
const login = (instance, jar) => {
|
|
execFileSync("rm", ["-f", jar]);
|
|
const page = curl(["-sS", "-c", jar, `${instance.url}/auth/login`]);
|
|
const csrf = extractCsrfFromHtml(page);
|
|
if (!csrf) throw new Error(`no csrf on login page for ${instance.url}`);
|
|
curl([
|
|
"-sS", "-c", jar, "-b", jar, "-o", "/dev/null",
|
|
"-X", "POST", `${instance.url}/auth/login`,
|
|
"-d", `email=admin@local&password=AdminP@ss1&_csrf=${csrf}`
|
|
]);
|
|
};
|
|
|
|
|
|
const envIdOf = (instance, jar) => {
|
|
const html = curl(["-sS", "-b", jar, `${instance.url}/admin/dev-deploy/`]);
|
|
const m = html.match(/<code>([0-9a-f-]{36})<\/code>/);
|
|
if (!m) throw new Error(`no env_id in dashboard html for ${instance.url}`);
|
|
return m[1];
|
|
};
|
|
|
|
|
|
const freshCsrf = (instance, jar, urlPath) => {
|
|
const html = curl(["-sS", "-b", jar, `${instance.url}${urlPath}`]);
|
|
return extractCsrfFromHtml(html);
|
|
};
|
|
|
|
|
|
// Each admin POST handler embeds CSRF in a form on a specific GET page.
|
|
// Match the POST path to the page that renders its form.
|
|
const CSRF_PAGE_FOR = [
|
|
["/admin/dev-deploy/peers/add", "/admin/dev-deploy/peers"],
|
|
["/admin/dev-deploy/peers/rotate", "/admin/dev-deploy/peers"],
|
|
["/admin/dev-deploy/peers/delete", "/admin/dev-deploy/peers"],
|
|
["/admin/dev-deploy/promote", "/admin/dev-deploy/peers"],
|
|
["/admin/dev-deploy/pull", "/admin/dev-deploy/peers"],
|
|
["/admin/dev-deploy/conflicts/resolve", "/admin/dev-deploy/conflicts"],
|
|
["/admin/dev-deploy/tables/set", "/admin/dev-deploy/tables"],
|
|
["/admin/dev-deploy/revert", "/admin/dev-deploy/ops"]
|
|
];
|
|
|
|
|
|
const adminPost = (instance, jar, urlPath, fields) => {
|
|
const mapping = CSRF_PAGE_FOR.find(([p]) => urlPath === p);
|
|
if (!mapping) throw new Error(`no CSRF source page mapped for POST ${urlPath}`);
|
|
const csrf = freshCsrf(instance, jar, mapping[1]);
|
|
if (!csrf) throw new Error(`no CSRF token on ${mapping[1]} for POST ${urlPath}`);
|
|
const args = ["-sS", "-b", jar, "-o", "/tmp/dd-test-postbody.html",
|
|
"-w", "%{http_code}|%{redirect_url}",
|
|
"-X", "POST", `${instance.url}${urlPath}`,
|
|
"--data-urlencode", `_csrf=${csrf}`];
|
|
for (const [k, v] of Object.entries(fields)) {
|
|
args.push("--data-urlencode", `${k}=${v}`);
|
|
}
|
|
const out = curl(args);
|
|
const [code, redirect] = out.split("|");
|
|
return { status: parseInt(code, 10), redirect: redirect, body: require("node:fs").readFileSync("/tmp/dd-test-postbody.html", "utf8") };
|
|
};
|
|
|
|
|
|
// --- test runner ---
|
|
|
|
let passed = 0, failed = 0;
|
|
const failures = [];
|
|
|
|
const test = async (name, fn) => {
|
|
try {
|
|
await fn();
|
|
console.log(` PASS ${name}`);
|
|
passed++;
|
|
} catch (err) {
|
|
console.log(` FAIL ${name}`);
|
|
console.log(` ${err.message}`);
|
|
failures.push({ name, err });
|
|
failed++;
|
|
}
|
|
};
|
|
|
|
|
|
const section = (name) => {
|
|
console.log(`\n[${name}]`);
|
|
};
|
|
|
|
|
|
// --- scenarios ---
|
|
|
|
const main = async () => {
|
|
section("preconditions");
|
|
await test("both servers respond to /auth/login", async () => {
|
|
for (const inst of [MAIN, TEST]) {
|
|
const out = curl(["-sS", "-o", "/dev/null", "-w", "%{http_code}", `${inst.url}/auth/login`]);
|
|
assert.equal(out, "200", `${inst.url} returned ${out}`);
|
|
}
|
|
});
|
|
|
|
section("reset both instances");
|
|
resetInstanceDb(MAIN.db);
|
|
resetInstanceDb(TEST.db);
|
|
login(MAIN, MAIN_COOKIES);
|
|
login(TEST, TEST_COOKIES);
|
|
const mainEnv = envIdOf(MAIN, MAIN_COOKIES);
|
|
const testEnv = envIdOf(TEST, TEST_COOKIES);
|
|
console.log(` main env_id: ${mainEnv}`);
|
|
console.log(` test env_id: ${testEnv}`);
|
|
|
|
section("schema");
|
|
await test("six _dd_* tables exist on main", async () => {
|
|
const tables = sqlRows(MAIN.db, "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '_dd_%' ORDER BY name").join(",");
|
|
assert.equal(tables, "_dd_anchors,_dd_entity_ids,_dd_env,_dd_ops,_dd_peers,_dd_table_modes");
|
|
});
|
|
await test("_dd_ops has conflict_with_op_id column", async () => {
|
|
const cols = sqlRows(MAIN.db, "PRAGMA table_info(_dd_ops)").map((r) => r.split("|")[1]);
|
|
assert.ok(cols.includes("conflict_with_op_id"), `cols: ${cols.join(",")}`);
|
|
});
|
|
|
|
section("bootstrap identity");
|
|
await test("env_ids are distinct UUIDs", async () => {
|
|
assert.notEqual(mainEnv, testEnv);
|
|
assert.match(mainEnv, /^[0-9a-f-]{36}$/);
|
|
});
|
|
await test("'users' table has identical UUID on both instances (deterministic backfill)", async () => {
|
|
const m = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='table' AND current_name='users'");
|
|
const t = sql(TEST.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='table' AND current_name='users'");
|
|
assert.equal(m, t, "deterministic UUIDs diverged");
|
|
assert.notEqual(m, "");
|
|
});
|
|
await test("all baseline role UUIDs match across instances", async () => {
|
|
const m = sqlRows(MAIN.db, "SELECT current_name||'|'||uuid FROM _dd_entity_ids WHERE kind='role' ORDER BY current_name").join(",");
|
|
const t = sqlRows(TEST.db, "SELECT current_name||'|'||uuid FROM _dd_entity_ids WHERE kind='role' ORDER BY current_name").join(",");
|
|
assert.equal(m, t);
|
|
});
|
|
|
|
section("mutation capture");
|
|
runJs(MAIN.env, 'const t = await Table.create("widgets"); await t.update({ description: "initial" });');
|
|
await test("create_table op is journaled", async () => {
|
|
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='create_table' AND source_env_id='" + mainEnv + "'");
|
|
assert.equal(c, "1");
|
|
});
|
|
await test("update_table op is journaled", async () => {
|
|
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='update_table' AND source_env_id='" + mainEnv + "'");
|
|
assert.ok(parseInt(c, 10) >= 1);
|
|
});
|
|
|
|
section("pairing");
|
|
const addRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/peers/add", {
|
|
env_id: testEnv,
|
|
label: "test",
|
|
base_url: TEST.url
|
|
});
|
|
await test("main /peers/add returns 200 with shared secret", async () => {
|
|
assert.equal(addRes.status, 200);
|
|
});
|
|
const secretMatch = addRes.body.match(/class="secret">([0-9a-f]{64})</);
|
|
if (!secretMatch) throw new Error("no shared secret in /peers/add response");
|
|
const secret = secretMatch[1];
|
|
|
|
const pairTest = adminPost(TEST, TEST_COOKIES, "/admin/dev-deploy/peers/add", {
|
|
env_id: mainEnv,
|
|
label: "main",
|
|
base_url: MAIN.url,
|
|
existing_secret: secret
|
|
});
|
|
await test("test /peers/add accepts the shared secret", async () => {
|
|
assert.equal(pairTest.status, 200);
|
|
});
|
|
|
|
section("promote main -> test");
|
|
const peerIdOnMain = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
|
const promoteRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdOnMain });
|
|
await test("promote returns 302 with success message", async () => {
|
|
assert.equal(promoteRes.status, 302);
|
|
assert.match(promoteRes.redirect, /msg=promoted/);
|
|
});
|
|
await test("test instance now has widgets table", async () => {
|
|
const r = sql(TEST.db, "SELECT name FROM _sc_tables WHERE name='widgets'");
|
|
assert.equal(r, "widgets");
|
|
});
|
|
await test("widgets UUID matches between main and test", async () => {
|
|
const m = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE current_name='widgets'");
|
|
const t = sql(TEST.db, "SELECT uuid FROM _dd_entity_ids WHERE current_name='widgets'");
|
|
assert.equal(m, t);
|
|
});
|
|
|
|
section("pull test -> main (no conflicts)");
|
|
runJs(TEST.env, 'await Table.create("test_added");');
|
|
const peerIdOnMain2 = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
|
const pullRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/pull", { peer_id: peerIdOnMain2 });
|
|
await test("pull returns 302 with success message", async () => {
|
|
assert.equal(pullRes.status, 302);
|
|
assert.match(pullRes.redirect, /msg=pulled/);
|
|
});
|
|
await test("main now has test_added", async () => {
|
|
const r = sql(MAIN.db, "SELECT name FROM _sc_tables WHERE name='test_added'");
|
|
assert.equal(r, "test_added");
|
|
});
|
|
await test("test_added UUID matches across instances", async () => {
|
|
const m = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE current_name='test_added'");
|
|
const t = sql(TEST.db, "SELECT uuid FROM _dd_entity_ids WHERE current_name='test_added'");
|
|
assert.equal(m, t);
|
|
});
|
|
|
|
section("conflict detection + resolution");
|
|
runJs(MAIN.env, 'const t = Table.findOne({name: "widgets"}); await t.update({ description: "from MAIN" });');
|
|
runJs(TEST.env, 'const t = Table.findOne({name: "widgets"}); await t.update({ description: "from TEST" });');
|
|
const peerIdForConflict = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
|
const pullConflictRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/pull", { peer_id: peerIdForConflict });
|
|
await test("pull with divergent edits returns 1 conflict", async () => {
|
|
assert.match(pullConflictRes.redirect, /1%20conflicts/);
|
|
});
|
|
await test("conflict op recorded with status=conflict", async () => {
|
|
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE status='conflict'");
|
|
assert.equal(c, "1");
|
|
});
|
|
await test("conflict op has conflict_with_op_id set", async () => {
|
|
const r = sql(MAIN.db, "SELECT conflict_with_op_id FROM _dd_ops WHERE status='conflict'");
|
|
assert.match(r, /^[0-9a-f-]{36}$/);
|
|
});
|
|
await test("widgets description on main is still 'from MAIN' (held op not applied)", async () => {
|
|
const r = sql(MAIN.db, "SELECT description FROM _sc_tables WHERE name='widgets'");
|
|
assert.equal(r, "from MAIN");
|
|
});
|
|
|
|
const conflictOp = sql(MAIN.db, "SELECT op_id FROM _dd_ops WHERE status='conflict' LIMIT 1");
|
|
const resolveRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/conflicts/resolve", {
|
|
op_id: conflictOp,
|
|
action: "theirs"
|
|
});
|
|
await test("resolve theirs returns 302 with success", async () => {
|
|
assert.equal(resolveRes.status, 302);
|
|
assert.match(resolveRes.redirect, /msg=resolved/);
|
|
});
|
|
await test("widgets description on main is now 'from TEST'", async () => {
|
|
const r = sql(MAIN.db, "SELECT description FROM _sc_tables WHERE name='widgets'");
|
|
assert.equal(r, "from TEST");
|
|
});
|
|
await test("resolved op status is committed", async () => {
|
|
const r = sql(MAIN.db, `SELECT status FROM _dd_ops WHERE op_id='${conflictOp}'`);
|
|
assert.equal(r, "committed");
|
|
});
|
|
await test("pending conflicts is 0", async () => {
|
|
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE status='conflict'");
|
|
assert.equal(c, "0");
|
|
});
|
|
|
|
section("conflict merge per-field");
|
|
runJs(MAIN.env, 'const t = Table.findOne({name: "widgets"}); await t.update({ description: "M2" });');
|
|
runJs(TEST.env, 'const t = Table.findOne({name: "widgets"}); await t.update({ description: "T2" });');
|
|
const peerIdForMerge = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
|
adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/pull", { peer_id: peerIdForMerge });
|
|
|
|
await test("a fresh conflict appears for the second divergence", async () => {
|
|
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE status='conflict'");
|
|
assert.equal(c, "1");
|
|
});
|
|
|
|
const mergeOp = sql(MAIN.db, "SELECT op_id FROM _dd_ops WHERE status='conflict' LIMIT 1");
|
|
|
|
await test("merge view returns 200 for an update-vs-update conflict", async () => {
|
|
const out = curl(["-sS", "-b", MAIN_COOKIES, "-o", "/dev/null", "-w", "%{http_code}",
|
|
`${MAIN.url}/admin/dev-deploy/conflicts/merge?op_id=${mergeOp}`]);
|
|
assert.equal(out, "200");
|
|
});
|
|
|
|
await test("merge view shows the diverging description field", async () => {
|
|
const html = curl(["-sS", "-b", MAIN_COOKIES,
|
|
`${MAIN.url}/admin/dev-deploy/conflicts/merge?op_id=${mergeOp}`]);
|
|
assert.match(html, /choice_description/);
|
|
assert.match(html, /T2/);
|
|
});
|
|
|
|
// Fetch the merge page for CSRF then POST a custom value
|
|
const mergeHtml = curl(["-sS", "-b", MAIN_COOKIES,
|
|
`${MAIN.url}/admin/dev-deploy/conflicts/merge?op_id=${mergeOp}`]);
|
|
const mergeCsrf = extractCsrfFromHtml(mergeHtml);
|
|
const mergeResp = execFileSync("curl", [
|
|
"-sS", "-b", MAIN_COOKIES, "-o", "/dev/null",
|
|
"-w", "%{http_code}|%{redirect_url}",
|
|
"-X", "POST", `${MAIN.url}/admin/dev-deploy/conflicts/merge/apply`,
|
|
"--data-urlencode", `_csrf=${mergeCsrf}`,
|
|
"--data-urlencode", `op_id=${mergeOp}`,
|
|
"--data-urlencode", "choice_description=custom",
|
|
"--data-urlencode", "custom_description=hybrid value"
|
|
], { encoding: "utf8" });
|
|
const [mergeStatus, mergeRedirect] = mergeResp.split("|");
|
|
|
|
await test("merge apply returns 302 with success message", async () => {
|
|
assert.equal(mergeStatus, "302");
|
|
assert.match(mergeRedirect, /msg=merged/);
|
|
});
|
|
|
|
await test("widgets description on main is now 'hybrid value'", async () => {
|
|
const r = sql(MAIN.db, "SELECT description FROM _sc_tables WHERE name='widgets'");
|
|
assert.equal(r, "hybrid value");
|
|
});
|
|
|
|
await test("merged op has status=merged, conflict_with cleared", async () => {
|
|
const r = sql(MAIN.db, `SELECT status||'|'||COALESCE(conflict_with_op_id,'') FROM _dd_ops WHERE op_id='${mergeOp}'`);
|
|
assert.equal(r, "merged|");
|
|
});
|
|
|
|
await test("pending conflicts is 0 after merge", async () => {
|
|
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE status='conflict'");
|
|
assert.equal(c, "0");
|
|
});
|
|
|
|
section("table constraints");
|
|
// Add a field to widgets and a unique constraint on it. Promote to test.
|
|
scExec(MAIN.env, `
|
|
const t = Table.findOne({name: "widgets"});
|
|
await Field.create({ table_id: t.id, name: "sku", label: "SKU", type: "String" });
|
|
const fresh = Table.findOne({name: "widgets"});
|
|
await TableConstraint.create({ table_id: fresh.id, type: "Unique", configuration: { fields: ["sku"] } });
|
|
`);
|
|
|
|
await test("create_field op journaled for sku", async () => {
|
|
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='create_field' AND payload LIKE '%sku%'");
|
|
assert.equal(c, "1");
|
|
});
|
|
await test("create_constraint op journaled with type=Unique", async () => {
|
|
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='create_constraint' AND payload LIKE '%Unique%'");
|
|
assert.equal(c, "1");
|
|
});
|
|
|
|
const peerIdForConstraint = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
|
const promoteConstraintRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForConstraint });
|
|
await test("promote with constraint returns success", async () => {
|
|
assert.equal(promoteConstraintRes.status, 302);
|
|
assert.match(promoteConstraintRes.redirect, /msg=promoted/);
|
|
});
|
|
await test("test now has the sku field", async () => {
|
|
const r = sql(TEST.db, "SELECT COUNT(*) FROM _sc_fields WHERE name='sku' AND table_id=(SELECT id FROM _sc_tables WHERE name='widgets')");
|
|
assert.equal(r, "1");
|
|
});
|
|
await test("test now has the unique constraint on sku", async () => {
|
|
// _sc_table_constraints uses JSON for configuration; SQLite stores TEXT
|
|
const r = sql(TEST.db, "SELECT COUNT(*) FROM _sc_table_constraints WHERE type='Unique' AND configuration LIKE '%sku%'");
|
|
assert.equal(r, "1");
|
|
});
|
|
await test("constraint UUID matches across instances", async () => {
|
|
const m = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='constraint' AND current_name='unique(sku)'");
|
|
const t = sql(TEST.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='constraint' AND current_name='unique(sku)'");
|
|
assert.equal(m, t);
|
|
assert.notEqual(m, "");
|
|
});
|
|
|
|
section("file propagation");
|
|
const FILE_BYTES = "Hello, dev-deploy! This is a test document.";
|
|
scExec(MAIN.env, `
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const tenant = db.getTenantSchema();
|
|
const absPath = path.join(db.connectObj.file_store, tenant, "test_doc.txt");
|
|
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
|
fs.writeFileSync(absPath, ${JSON.stringify(FILE_BYTES)});
|
|
await File.create({
|
|
filename: "test_doc.txt",
|
|
location: absPath,
|
|
uploaded_at: new Date(),
|
|
size_kb: 1,
|
|
mime_super: "text",
|
|
mime_sub: "plain",
|
|
min_role_read: 1
|
|
});
|
|
`);
|
|
|
|
await test("create_file op journaled with content_hash + relative_path", async () => {
|
|
const r = sql(MAIN.db, "SELECT payload FROM _dd_ops WHERE op_type='create_file' LIMIT 1");
|
|
assert.match(r, /content_hash/);
|
|
assert.match(r, /test_doc\.txt/);
|
|
});
|
|
|
|
await test("file entry in _dd_entity_ids on main", async () => {
|
|
const r = sql(MAIN.db, "SELECT current_name FROM _dd_entity_ids WHERE kind='file' LIMIT 1");
|
|
assert.equal(r, "test_doc.txt");
|
|
});
|
|
|
|
const peerIdForFile = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
|
const fileRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForFile });
|
|
|
|
await test("file promote returns success", async () => {
|
|
assert.equal(fileRes.status, 302);
|
|
assert.match(fileRes.redirect, /msg=promoted/);
|
|
});
|
|
|
|
await test("file exists on test instance with same bytes", async () => {
|
|
const fs = require("node:fs");
|
|
const out = fs.readFileSync("/home/scott/claude/saltcorn/.dev-state-test/files/public/test_doc.txt", "utf8");
|
|
assert.equal(out, FILE_BYTES);
|
|
});
|
|
|
|
await test("file UUID matches across instances", async () => {
|
|
const m = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='file' AND current_name='test_doc.txt'");
|
|
const t = sql(TEST.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='file' AND current_name='test_doc.txt'");
|
|
assert.equal(m, t);
|
|
assert.notEqual(m, "");
|
|
});
|
|
|
|
section("plugin mismatch warning");
|
|
await test("health endpoint is reachable and HMAC-required", async () => {
|
|
const out = curl(["-sS", "-o", "/dev/null", "-w", "%{http_code}", `${TEST.url}/dev-deploy/api/health`]);
|
|
assert.equal(out, "400"); // missing HMAC headers
|
|
});
|
|
await test("promote includes no plugin warnings when peers match", async () => {
|
|
// Both instances have identical plugin lists (base, sbadmin2, dev-deploy)
|
|
// so the warning suffix should be absent.
|
|
runJs(MAIN.env, 'await Table.create("warning_demo_table");');
|
|
const peerId = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
|
const r = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerId });
|
|
assert.equal(r.status, 302);
|
|
// Both sides have the same plugins so no WARNINGS suffix should appear.
|
|
assert.ok(!/WARNINGS/.test(r.redirect || ""), `unexpected warnings: ${r.redirect}`);
|
|
});
|
|
|
|
section("file refs in page layout");
|
|
scExec(MAIN.env, `
|
|
const Page = require("@saltcorn/data/models/page");
|
|
// Pages with a file ref in their layout — fileid as a relative-path
|
|
// string (the form Saltcorn produces on a fresh upload).
|
|
await Page.create({
|
|
name: "page_with_image",
|
|
title: "Page With Image",
|
|
description: "Has an image",
|
|
min_role: 100,
|
|
layout: { type: "image", srctype: "File", fileid: "test_doc.txt", alt: "test" },
|
|
fixed_states: {}
|
|
});
|
|
`);
|
|
|
|
await test("create_page op replaces fileid with placeholder in journal", async () => {
|
|
const r = sql(MAIN.db, "SELECT payload FROM _dd_ops WHERE op_type='create_page' AND payload LIKE '%page_with_image%' LIMIT 1");
|
|
assert.match(r, /__dd_file_ref::/);
|
|
// The original string must NOT appear as the raw fileid value
|
|
assert.ok(!/"fileid":"test_doc\.txt"/.test(r), `fileid was not translated: ${r}`);
|
|
});
|
|
|
|
const peerIdForRefs = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
|
const refsPromote = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForRefs });
|
|
await test("page-with-image promote returns success", async () => {
|
|
assert.equal(refsPromote.status, 302);
|
|
assert.match(refsPromote.redirect, /msg=promoted/);
|
|
});
|
|
|
|
await test("page exists on test with fileid resolved back to local path", async () => {
|
|
const layout = sql(TEST.db, "SELECT layout FROM _sc_pages WHERE name='page_with_image'");
|
|
// On test, fileid should be the relative path that resolves locally
|
|
assert.match(layout, /"fileid":"test_doc\.txt"/);
|
|
// Placeholder must NOT leak into the live entity
|
|
assert.ok(!/__dd_file_ref::/.test(layout), `placeholder leaked into live page: ${layout}`);
|
|
});
|
|
|
|
section("page_group propagation");
|
|
scExec(MAIN.env, `
|
|
const Page = require("@saltcorn/data/models/page");
|
|
const PageGroup = require("@saltcorn/data/models/page_group");
|
|
const page = await Page.create({
|
|
name: "intro_page",
|
|
title: "Intro",
|
|
description: "Intro page",
|
|
min_role: 100,
|
|
layout: { type: "blank", contents: "Hello" },
|
|
fixed_states: {}
|
|
});
|
|
const pg = await PageGroup.create({
|
|
name: "home_group",
|
|
description: "home routing",
|
|
min_role: 100,
|
|
random_allocation: false,
|
|
members: []
|
|
});
|
|
await pg.addMember({
|
|
page_id: page.id,
|
|
eligible_formula: "true",
|
|
description: "default"
|
|
});
|
|
`);
|
|
|
|
await test("create_page_group op journaled", async () => {
|
|
const r = sql(MAIN.db, "SELECT payload FROM _dd_ops WHERE op_type='create_page_group' LIMIT 1");
|
|
assert.match(r, /home_group/);
|
|
});
|
|
await test("create_page_group_member op journaled with page_uuid", async () => {
|
|
const r = sql(MAIN.db, "SELECT payload FROM _dd_ops WHERE op_type='create_page_group_member' LIMIT 1");
|
|
assert.match(r, /page_uuid/);
|
|
});
|
|
|
|
const peerIdForPG = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
|
const pgPromote = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForPG });
|
|
await test("page_group promote returns success", async () => {
|
|
assert.equal(pgPromote.status, 302);
|
|
assert.match(pgPromote.redirect, /msg=promoted/);
|
|
});
|
|
await test("test instance has home_group page_group", async () => {
|
|
const r = sql(TEST.db, "SELECT name FROM _sc_page_groups WHERE name='home_group'");
|
|
assert.equal(r, "home_group");
|
|
});
|
|
await test("page_group on test has its member row", async () => {
|
|
const r = sql(TEST.db, "SELECT COUNT(*) FROM _sc_page_group_members m JOIN _sc_page_groups g ON m.page_group_id=g.id WHERE g.name='home_group'");
|
|
assert.equal(r, "1");
|
|
});
|
|
await test("page_group UUID matches across instances", async () => {
|
|
const m = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='page_group' AND current_name='home_group'");
|
|
const t = sql(TEST.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='page_group' AND current_name='home_group'");
|
|
assert.equal(m, t);
|
|
assert.notEqual(m, "");
|
|
});
|
|
|
|
section("workflow_step propagation");
|
|
scExec(MAIN.env, `
|
|
const Trigger = require("@saltcorn/data/models/trigger");
|
|
const WorkflowStep = require("@saltcorn/data/models/workflow_step");
|
|
// clean any prior
|
|
const oldT = Trigger.findOne({name: "demo_workflow"});
|
|
if (oldT) await oldT.delete();
|
|
const tr = await Trigger.create({
|
|
name: "demo_workflow",
|
|
action: "Workflow",
|
|
when_trigger: "Never",
|
|
min_role: 1,
|
|
configuration: {}
|
|
});
|
|
await WorkflowStep.create({
|
|
name: "step1",
|
|
trigger_id: tr.id,
|
|
action_name: "set_context",
|
|
next_step: "",
|
|
initial_step: true,
|
|
configuration: { ctx_values: '{ "hello": "world" }' },
|
|
only_if: ""
|
|
});
|
|
`);
|
|
|
|
await test("create_workflow_step op journaled", async () => {
|
|
const r = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='create_workflow_step'");
|
|
assert.equal(r, "1");
|
|
});
|
|
|
|
const peerIdForWf = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
|
const wfPromote = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForWf });
|
|
await test("workflow_step promote returns success", async () => {
|
|
assert.equal(wfPromote.status, 302);
|
|
assert.match(wfPromote.redirect, /msg=promoted/);
|
|
});
|
|
await test("test has the demo_workflow trigger", async () => {
|
|
const r = sql(TEST.db, "SELECT name FROM _sc_triggers WHERE name='demo_workflow'");
|
|
assert.equal(r, "demo_workflow");
|
|
});
|
|
await test("test has the workflow step", async () => {
|
|
const r = sql(TEST.db, "SELECT COUNT(*) FROM _sc_workflow_steps s JOIN _sc_triggers t ON s.trigger_id=t.id WHERE t.name='demo_workflow' AND s.name='step1'");
|
|
assert.equal(r, "1");
|
|
});
|
|
await test("workflow_step UUID matches across instances", async () => {
|
|
const m = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='workflow_step' AND current_name='step1'");
|
|
const t = sql(TEST.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='workflow_step' AND current_name='step1'");
|
|
assert.equal(m, t);
|
|
assert.notEqual(m, "");
|
|
});
|
|
|
|
section("config + menu propagation");
|
|
scExec(MAIN.env, `
|
|
const { getState } = require("@saltcorn/data/db/state");
|
|
await getState().setConfig("menu_items", [
|
|
{ label: "Widgets", type: "View", viewname: "widget_list" },
|
|
{ label: "About", type: "Page", pagename: "about" }
|
|
]);
|
|
`);
|
|
await test("set_config op journaled for menu_items", async () => {
|
|
const r = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='set_config' AND payload LIKE '%menu_items%'");
|
|
assert.ok(parseInt(r, 10) >= 1, `got ${r} set_config ops`);
|
|
});
|
|
const peerIdForMenu = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
|
const menuPromote = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForMenu });
|
|
await test("menu promote returns success", async () => {
|
|
assert.equal(menuPromote.status, 302);
|
|
assert.match(menuPromote.redirect, /msg=promoted/);
|
|
});
|
|
await test("test instance has matching menu_items", async () => {
|
|
const v = sql(TEST.db, "SELECT value FROM _sc_config WHERE key='menu_items'");
|
|
assert.match(v, /widget_list/);
|
|
assert.match(v, /about/);
|
|
});
|
|
|
|
section("plugin configuration propagation");
|
|
scExec(MAIN.env, `
|
|
const Plugin = require("@saltcorn/data/models/plugin");
|
|
const p = await Plugin.findOne({ name: "sbadmin2" });
|
|
if (!p) throw new Error("sbadmin2 plugin not found");
|
|
p.configuration = { test_key: "from MAIN", color_scheme: "dark" };
|
|
await p.upsert();
|
|
`);
|
|
await test("update_plugin_config op journaled for sbadmin2", async () => {
|
|
const r = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='update_plugin_config' AND payload LIKE '%sbadmin2%'");
|
|
assert.equal(r, "1");
|
|
});
|
|
const peerIdForPC = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
|
const pcPromote = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForPC });
|
|
await test("plugin-config promote returns success", async () => {
|
|
assert.equal(pcPromote.status, 302);
|
|
assert.match(pcPromote.redirect, /msg=promoted/);
|
|
});
|
|
await test("test sbadmin2 has updated configuration", async () => {
|
|
const cfg = sql(TEST.db, "SELECT configuration FROM _sc_plugins WHERE name='sbadmin2'");
|
|
assert.match(cfg, /from MAIN/);
|
|
assert.match(cfg, /color_scheme/);
|
|
});
|
|
|
|
section("managed row data propagation");
|
|
|
|
// Create two tables on main: categories + products with FK products.category_id -> categories.id.
|
|
// Add some rows. Then mark both managed. Promote. Verify rows on test with same UUIDs.
|
|
scExec(MAIN.env, `
|
|
const Table = require("@saltcorn/data/models/table");
|
|
const Field = require("@saltcorn/data/models/field");
|
|
|
|
const cats = await Table.create("dd_categories");
|
|
await Field.create({ table_id: cats.id, name: "name", label: "Name", type: "String" });
|
|
|
|
const prods = await Table.create("dd_products");
|
|
await Field.create({ table_id: prods.id, name: "name", label: "Name", type: "String" });
|
|
await Field.create({ table_id: prods.id, name: "price", label: "Price", type: "Float" });
|
|
await Field.create({ table_id: prods.id, name: "category", label: "Category", type: "Key to dd_categories", reftable_name: "dd_categories", reftype: "Integer" });
|
|
|
|
const cats2 = Table.findOne({name: "dd_categories"});
|
|
const electId = await cats2.insertRow({ name: "Electronics" });
|
|
const apparelId = await cats2.insertRow({ name: "Apparel" });
|
|
|
|
const prods2 = Table.findOne({name: "dd_products"});
|
|
await prods2.insertRow({ name: "Widget", price: 9.99, category: electId });
|
|
await prods2.insertRow({ name: "Gadget", price: 19.95, category: electId });
|
|
await prods2.insertRow({ name: "T-shirt", price: 14.99, category: apparelId });
|
|
`);
|
|
|
|
// Mark both tables managed via the admin UI (causes initial ship)
|
|
const catsUuid = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='table' AND current_name='dd_categories'");
|
|
const prodsUuid = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='table' AND current_name='dd_products'");
|
|
|
|
const setCatsManaged = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/tables/set", { table_uuid: catsUuid, data_mode: "managed" });
|
|
const setProdsManaged = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/tables/set", { table_uuid: prodsUuid, data_mode: "managed" });
|
|
|
|
await test("marking dd_categories managed ships 2 rows", async () => {
|
|
assert.match(setCatsManaged.redirect, /shipped.{1,3}2.{1,3}rows/);
|
|
});
|
|
await test("marking dd_products managed ships 3 rows", async () => {
|
|
assert.match(setProdsManaged.redirect, /shipped.{1,3}3.{1,3}rows/);
|
|
});
|
|
await test("_dd_row_uuid column exists on dd_categories on main", async () => {
|
|
const info = sqlRows(MAIN.db, "PRAGMA table_info(dd_categories);").join(",");
|
|
assert.match(info, /_dd_row_uuid/);
|
|
});
|
|
await test("insert_row ops journaled for the seed rows", async () => {
|
|
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='insert_row'");
|
|
assert.ok(parseInt(c, 10) >= 5, `expected >=5 insert_row ops, got ${c}`);
|
|
});
|
|
|
|
// Promote
|
|
const peerIdForRows = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
|
const rowPromote = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForRows });
|
|
await test("managed row promote returns success", async () => {
|
|
assert.equal(rowPromote.status, 302);
|
|
assert.match(rowPromote.redirect, /msg=promoted/);
|
|
});
|
|
await test("test has dd_categories rows", async () => {
|
|
const r = sql(TEST.db, "SELECT COUNT(*) FROM dd_categories");
|
|
assert.equal(r, "2");
|
|
});
|
|
await test("test has dd_products rows", async () => {
|
|
const r = sql(TEST.db, "SELECT COUNT(*) FROM dd_products");
|
|
assert.equal(r, "3");
|
|
});
|
|
await test("a product on test has its FK resolved to a local category id", async () => {
|
|
const r = sql(TEST.db, "SELECT p.name || '|' || c.name FROM dd_products p JOIN dd_categories c ON p.category=c.id WHERE p.name='Widget'");
|
|
assert.equal(r, "Widget|Electronics");
|
|
});
|
|
await test("row UUIDs match across instances for a product", async () => {
|
|
const m = sql(MAIN.db, "SELECT _dd_row_uuid FROM dd_products WHERE name='Widget'");
|
|
const t = sql(TEST.db, "SELECT _dd_row_uuid FROM dd_products WHERE name='Widget'");
|
|
assert.equal(m, t);
|
|
assert.notEqual(m, "");
|
|
});
|
|
|
|
// Update a row on main, promote again, verify on test.
|
|
scExec(MAIN.env, `
|
|
const Table = require("@saltcorn/data/models/table");
|
|
const prods = Table.findOne({name: "dd_products"});
|
|
const widget = (await prods.getJoinedRows({ where: { name: "Widget" }}))[0];
|
|
await prods.updateRow({ price: 12.50 }, widget.id);
|
|
`);
|
|
const rowPromote2 = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForRows });
|
|
await test("update_row op promoted", async () => {
|
|
assert.match(rowPromote2.redirect, /msg=promoted/);
|
|
});
|
|
await test("test sees Widget at new price 12.5", async () => {
|
|
const r = sql(TEST.db, "SELECT price FROM dd_products WHERE name='Widget'");
|
|
assert.equal(r, "12.5");
|
|
});
|
|
|
|
// Delete a row on main, promote, verify removed on test.
|
|
scExec(MAIN.env, `
|
|
const Table = require("@saltcorn/data/models/table");
|
|
const prods = Table.findOne({name: "dd_products"});
|
|
await prods.deleteRows({ name: "T-shirt" });
|
|
`);
|
|
const rowPromote3 = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForRows });
|
|
await test("drop_row op promoted", async () => {
|
|
assert.match(rowPromote3.redirect, /msg=promoted/);
|
|
});
|
|
await test("test no longer has T-shirt row", async () => {
|
|
const r = sql(TEST.db, "SELECT COUNT(*) FROM dd_products WHERE name='T-shirt'");
|
|
assert.equal(r, "0");
|
|
});
|
|
|
|
section("starter rows: ship once then detach");
|
|
scExec(MAIN.env, `
|
|
const Table = require("@saltcorn/data/models/table");
|
|
const Field = require("@saltcorn/data/models/field");
|
|
const seed = await Table.create("dd_templates");
|
|
await Field.create({ table_id: seed.id, name: "name", label: "Name", type: "String" });
|
|
const t = Table.findOne({name: "dd_templates"});
|
|
await t.insertRow({ name: "Welcome" });
|
|
await t.insertRow({ name: "Goodbye" });
|
|
`);
|
|
const seedUuid = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='table' AND current_name='dd_templates'");
|
|
const setSeedStarter = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/tables/set", { table_uuid: seedUuid, data_mode: "starter" });
|
|
await test("marking dd_templates starter ships 2 rows", async () => {
|
|
assert.match(setSeedStarter.redirect, /shipped.{1,3}2.{1,3}rows/);
|
|
});
|
|
await test("dd_templates marked starter_shipped_at", async () => {
|
|
const r = sql(MAIN.db, `SELECT starter_shipped_at IS NOT NULL FROM _dd_table_modes WHERE table_uuid='${seedUuid}'`);
|
|
assert.equal(r, "1");
|
|
});
|
|
|
|
// After starter ship, further row ops on main should NOT journal
|
|
const beforeCount = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='insert_row' OR op_type='update_row'");
|
|
scExec(MAIN.env, `
|
|
const Table = require("@saltcorn/data/models/table");
|
|
const t = Table.findOne({name: "dd_templates"});
|
|
await t.insertRow({ name: "Post-ship row" });
|
|
`);
|
|
const afterCount = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='insert_row' OR op_type='update_row'");
|
|
await test("subsequent inserts on starter table do NOT journal", async () => {
|
|
assert.equal(afterCount, beforeCount, `journal grew from ${beforeCount} to ${afterCount} on a starter table`);
|
|
});
|
|
|
|
const starterPromote = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForRows });
|
|
await test("starter promote ships initial rows", async () => {
|
|
assert.match(starterPromote.redirect, /msg=promoted/);
|
|
});
|
|
await test("test has the 2 initial templates (but not the post-ship row)", async () => {
|
|
const c = sql(TEST.db, "SELECT COUNT(*) FROM dd_templates");
|
|
assert.equal(c, "2");
|
|
const has = sql(TEST.db, "SELECT COUNT(*) FROM dd_templates WHERE name='Welcome'");
|
|
assert.equal(has, "1");
|
|
const postship = sql(TEST.db, "SELECT COUNT(*) FROM dd_templates WHERE name='Post-ship row'");
|
|
assert.equal(postship, "0");
|
|
});
|
|
|
|
section("revert");
|
|
runJs(MAIN.env, 'await Table.create("ephemeral");');
|
|
const createOp = sql(MAIN.db, "SELECT op_id FROM _dd_ops WHERE op_type='create_table' AND payload LIKE '%ephemeral%' LIMIT 1");
|
|
const revertRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/revert", { op_id: createOp });
|
|
await test("revert returns 302", async () => {
|
|
assert.equal(revertRes.status, 302);
|
|
});
|
|
await test("ephemeral table is gone after revert", async () => {
|
|
const r = sql(MAIN.db, "SELECT COUNT(*) FROM _sc_tables WHERE name='ephemeral'");
|
|
assert.equal(r, "0");
|
|
});
|
|
await test("drop_table op recorded as the revert", async () => {
|
|
const r = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='drop_table' AND payload LIKE '%ephemeral%'");
|
|
assert.ok(parseInt(r, 10) >= 1);
|
|
});
|
|
|
|
section("data_mode");
|
|
const widgetsUuid = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE current_name='widgets'");
|
|
const setRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/tables/set", {
|
|
table_uuid: widgetsUuid,
|
|
data_mode: "starter"
|
|
});
|
|
await test("tables/set redirects with success", async () => {
|
|
assert.equal(setRes.status, 302);
|
|
assert.match(setRes.redirect, /msg=set/);
|
|
});
|
|
await test("_dd_table_modes row inserted with starter", async () => {
|
|
const r = sql(MAIN.db, `SELECT data_mode FROM _dd_table_modes WHERE table_uuid='${widgetsUuid}'`);
|
|
assert.equal(r, "starter");
|
|
});
|
|
await test("users table is locked: cannot change data_mode", async () => {
|
|
const usersUuid = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE current_name='users' AND kind='table'");
|
|
const r = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/tables/set", {
|
|
table_uuid: usersUuid,
|
|
data_mode: "managed"
|
|
});
|
|
assert.match(r.redirect || "", /err=/);
|
|
const mode = sql(MAIN.db, `SELECT COUNT(*) FROM _dd_table_modes WHERE table_uuid='${usersUuid}'`);
|
|
assert.equal(mode, "0", "users table should have no row in _dd_table_modes");
|
|
});
|
|
|
|
section("revert (extended op types)");
|
|
// Validates the broadened revert handlers (rows / config / table-mode /
|
|
// constraint), which previously threw "no revert handler". Runs after the
|
|
// data_mode section so a set_table_mode op (with before_mode) exists.
|
|
const wUuidR = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE current_name='widgets' AND kind='table'");
|
|
const modeBeforeRevert = sql(MAIN.db, `SELECT data_mode FROM _dd_table_modes WHERE table_uuid='${wUuidR}'`);
|
|
const setModeOp = sql(MAIN.db, `SELECT op_id FROM _dd_ops WHERE op_type='set_table_mode' AND entity_uuid='${wUuidR}' ORDER BY created_at DESC LIMIT 1`);
|
|
const rmode = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/revert", { op_id: setModeOp });
|
|
await test("revert set_table_mode returns 302 (no crash)", async () => {
|
|
assert.equal(rmode.status, 302);
|
|
});
|
|
await test("set_table_mode revert changed widgets' mode away from 'starter'", async () => {
|
|
assert.equal(modeBeforeRevert, "starter");
|
|
const after = sql(MAIN.db, `SELECT data_mode FROM _dd_table_modes WHERE table_uuid='${wUuidR}'`);
|
|
assert.notEqual(after, "starter", "mode should have been restored to before_mode");
|
|
});
|
|
|
|
const conOp = sql(MAIN.db, "SELECT op_id FROM _dd_ops WHERE op_type='create_constraint' AND payload LIKE '%Unique%' ORDER BY created_at DESC LIMIT 1");
|
|
const rcon = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/revert", { op_id: conOp });
|
|
await test("revert create_constraint returns 302", async () => {
|
|
assert.equal(rcon.status, 302);
|
|
});
|
|
await test("unique(sku) constraint dropped on MAIN after revert", async () => {
|
|
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _sc_table_constraints WHERE type='Unique' AND configuration LIKE '%sku%'");
|
|
assert.equal(c, "0");
|
|
});
|
|
await test("drop_constraint op recorded as the constraint revert", async () => {
|
|
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='drop_constraint'");
|
|
assert.ok(parseInt(c, 10) >= 1);
|
|
});
|
|
|
|
scExec(MAIN.env, `const {getState}=require("@saltcorn/data/db/state"); await getState().setConfig("site_name","REV_BEFORE");`);
|
|
scExec(MAIN.env, `const {getState}=require("@saltcorn/data/db/state"); await getState().setConfig("site_name","REV_AFTER");`);
|
|
const cfgOp = sql(MAIN.db, "SELECT op_id FROM _dd_ops WHERE op_type='set_config' AND payload LIKE '%REV_AFTER%' ORDER BY created_at DESC LIMIT 1");
|
|
const rcfg = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/revert", { op_id: cfgOp });
|
|
await test("revert set_config returns 302", async () => {
|
|
assert.equal(rcfg.status, 302);
|
|
});
|
|
await test("set_config revert restored site_name to REV_BEFORE", async () => {
|
|
const v = sql(MAIN.db, "SELECT value FROM _sc_config WHERE key='site_name'");
|
|
assert.match(v, /REV_BEFORE/);
|
|
});
|
|
|
|
scExec(MAIN.env, `const t = await Table.create("revrows"); await Field.create({table_id:t.id,name:"label",label:"Label",type:"String"});`);
|
|
const rrUuid = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE current_name='revrows' AND kind='table'");
|
|
adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/tables/set", { table_uuid: rrUuid, data_mode: "managed" });
|
|
scExec(MAIN.env, `const t = Table.findOne({name:"revrows"}); await t.insertRow({label:"to-revert"});`);
|
|
const rowOp = sql(MAIN.db, "SELECT op_id FROM _dd_ops WHERE op_type='insert_row' AND payload LIKE '%to-revert%' ORDER BY created_at DESC LIMIT 1");
|
|
await test("insert_row op journaled for the managed table", async () => {
|
|
assert.notEqual(rowOp, "");
|
|
});
|
|
const rrow = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/revert", { op_id: rowOp });
|
|
await test("revert insert_row returns 302", async () => {
|
|
assert.equal(rrow.status, 302);
|
|
});
|
|
await test("row deleted from revrows after insert_row revert", async () => {
|
|
const c = sql(MAIN.db, "SELECT COUNT(*) FROM revrows");
|
|
assert.equal(c, "0");
|
|
});
|
|
|
|
section("machine-endpoint security");
|
|
// Test that requests without HMAC are rejected
|
|
await test("unsigned POST to ingest returns 400 (missing header)", async () => {
|
|
const out = curl([
|
|
"-sS", "-o", "/dev/null", "-w", "%{http_code}",
|
|
"-X", "POST",
|
|
"-H", "Content-Type: application/vnd.dev-deploy+json",
|
|
"--data", '{"ops":[]}',
|
|
`${TEST.url}/dev-deploy/api/ingest`
|
|
]);
|
|
assert.equal(out, "400");
|
|
});
|
|
await test("ingest with bogus signature returns 401", async () => {
|
|
const out = curl([
|
|
"-sS", "-o", "/dev/null", "-w", "%{http_code}",
|
|
"-X", "POST",
|
|
"-H", "Content-Type: application/vnd.dev-deploy+json",
|
|
"-H", `X-DD-Env-Id: ${mainEnv}`,
|
|
"-H", `X-DD-Timestamp: ${Date.now()}`,
|
|
"-H", "X-DD-Nonce: deadbeefdeadbeefdeadbeefdeadbeef",
|
|
"-H", "X-DD-Signature: 0000000000000000000000000000000000000000000000000000000000000000",
|
|
"--data", '{"ops":[]}',
|
|
`${TEST.url}/dev-deploy/api/ingest`
|
|
]);
|
|
assert.equal(out, "401");
|
|
});
|
|
await test("ingest from unknown env_id returns 401", async () => {
|
|
const out = curl([
|
|
"-sS", "-o", "/dev/null", "-w", "%{http_code}",
|
|
"-X", "POST",
|
|
"-H", "Content-Type: application/vnd.dev-deploy+json",
|
|
"-H", "X-DD-Env-Id: 00000000-0000-4000-8000-000000000000",
|
|
"-H", `X-DD-Timestamp: ${Date.now()}`,
|
|
"-H", "X-DD-Nonce: deadbeefdeadbeefdeadbeefdeadbeef",
|
|
"-H", "X-DD-Signature: 0000000000000000000000000000000000000000000000000000000000000000",
|
|
"--data", '{"ops":[]}',
|
|
`${TEST.url}/dev-deploy/api/ingest`
|
|
]);
|
|
assert.equal(out, "401");
|
|
});
|
|
await test("ingest with stale timestamp returns 401", async () => {
|
|
const stale = String(Date.now() - 10 * 60 * 1000);
|
|
const out = curl([
|
|
"-sS", "-o", "/dev/null", "-w", "%{http_code}",
|
|
"-X", "POST",
|
|
"-H", "Content-Type: application/vnd.dev-deploy+json",
|
|
"-H", `X-DD-Env-Id: ${mainEnv}`,
|
|
"-H", `X-DD-Timestamp: ${stale}`,
|
|
"-H", "X-DD-Nonce: deadbeefdeadbeefdeadbeefdeadbeef",
|
|
"-H", "X-DD-Signature: 0000000000000000000000000000000000000000000000000000000000000000",
|
|
"--data", '{"ops":[]}',
|
|
`${TEST.url}/dev-deploy/api/ingest`
|
|
]);
|
|
assert.equal(out, "401");
|
|
});
|
|
|
|
section("admin auth");
|
|
await test("unauthenticated GET /admin/dev-deploy/ returns 403", async () => {
|
|
const out = curl(["-sS", "-o", "/dev/null", "-w", "%{http_code}", `${MAIN.url}/admin/dev-deploy/`]);
|
|
assert.equal(out, "403");
|
|
});
|
|
|
|
// --- summary ---
|
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
if (failed > 0) {
|
|
console.log("\nFailures:");
|
|
for (const f of failures) {
|
|
console.log(` - ${f.name}`);
|
|
console.log(` ${f.err.stack.split("\n").slice(0, 4).join("\n ")}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
process.exit(0);
|
|
};
|
|
|
|
|
|
main().catch((err) => {
|
|
console.error("test runner crashed:", err);
|
|
process.exit(2);
|
|
});
|