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