1598 lines
96 KiB
JavaScript
1598 lines
96 KiB
JavaScript
// site-sync in-process test suite. Runs against one throwaway SQLite DB set up by
|
|
// run.sh, with the plugin registered by location (real onLoad). Grows per build
|
|
// phase; each phase is a self-contained section.
|
|
//
|
|
// ./test/run.sh
|
|
|
|
const { getState } = require("@saltcorn/data/db/state");
|
|
const Table = require("@saltcorn/data/models/table");
|
|
const Field = require("@saltcorn/data/models/field");
|
|
|
|
const {
|
|
db, ok, section, step, summary,
|
|
bootstrapPlugin, mkRes, mkAdminReq, mkPeerReq, responseSigned, routeOf
|
|
} = require("./harness");
|
|
|
|
const protocol = require("../lib/common/peerProtocol");
|
|
const peers = require("../lib/peers");
|
|
const env = require("../lib/env");
|
|
const { requirePeerAuth } = require("../lib/peerAuth");
|
|
const routes = require("../lib/routes");
|
|
const ops = require("../lib/operations");
|
|
const capture = require("../lib/capture");
|
|
const { SYNCABLE_CONFIG_KEYS } = require("../lib/constants");
|
|
const { randomUuid } = require("../lib/ids");
|
|
|
|
const HOST = "localhost:3000";
|
|
|
|
|
|
// Whether fn() rejects (optionally with a message matching re).
|
|
const throwsAsync = async (fn, re) => {
|
|
try {
|
|
await fn();
|
|
return false;
|
|
} catch (e) {
|
|
return !re || re.test(e.message);
|
|
}
|
|
};
|
|
|
|
|
|
// Delete every row of `name` matching `where` (all rows by default).
|
|
const clearRows = async (name, where) => {
|
|
const tbl = Table.findOne({ name });
|
|
for (const r of await tbl.getRows(where || {}, {})) {
|
|
await tbl.deleteRows({ [tbl.pk_name]: r[tbl.pk_name] });
|
|
}
|
|
};
|
|
|
|
|
|
const phase1 = async () => {
|
|
section("Phase 1: schema + identity (onLoad)");
|
|
const tablesPresent = ["_ss_peers", "_ss_table_modes"];
|
|
for (const name of tablesPresent) {
|
|
ok(`registered table ${name} exists in _sc_tables`, !!Table.findOne({ name }));
|
|
}
|
|
const rawTableExists = async (name) => {
|
|
if (db.isSQLite) {
|
|
return (await db.query(`SELECT name FROM sqlite_master WHERE type='table' AND name='${name}'`)).rows.length === 1;
|
|
}
|
|
return (await db.query(`SELECT 1 FROM information_schema.tables WHERE table_schema=$1 AND table_name=$2`, [db.getTenantSchema(), name])).rows.length === 1;
|
|
};
|
|
for (const name of ["_ss_seen_nonces", "_ss_plans", "_ss_rollback"]) {
|
|
ok(`raw table ${name} created`, await rawTableExists(name));
|
|
}
|
|
ok("write-only legacy _ss_peer_state is not created", !(await rawTableExists("_ss_peer_state")));
|
|
const myEnv = await env.getEnv();
|
|
ok("env identity created in _sc_config", !!(myEnv && myEnv.env_id));
|
|
ok("default sync_mode is mirror", myEnv.sync_mode === "mirror", myEnv.sync_mode);
|
|
ok("default on_delete is confirm", myEnv.on_delete === "confirm", myEnv.on_delete);
|
|
const csrf = getState().getConfig("disable_csrf_routes", "");
|
|
ok("csrf bypass registered for /site-sync/api/", String(csrf).includes("/site-sync/api/"), csrf);
|
|
|
|
section("Phase 1: onLoad idempotency");
|
|
await step("second createAllTables + initEnv is a no-op", async () => {
|
|
const { createAllTables } = require("../lib/schema");
|
|
await createAllTables();
|
|
const again = await env.getEnv();
|
|
ok("env_id unchanged after re-run", again.env_id === myEnv.env_id);
|
|
const cnt = (await db.query(`SELECT COUNT(*) AS c FROM _sc_config WHERE key='site_sync_env'`)).rows[0].c;
|
|
ok("exactly one identity row", Number(cnt) === 1, `count=${cnt}`);
|
|
});
|
|
|
|
section("Phase 1: crypto primitives");
|
|
const secret = protocol.randomSecret();
|
|
const sealed = peers.sealedColumns(secret);
|
|
ok("seal/open round-trips the secret", Buffer.compare(peers.openSecret(sealed), secret) === 0);
|
|
const canon = protocol.buildCanonical({ timestamp: "1", nonce: "n", method: "get", path: "/x", targetHost: "h", body: "" });
|
|
ok("sign/verify accepts a good signature", protocol.verifySignature(secret, canon, protocol.sign(secret, canon)));
|
|
ok("verify rejects a bad signature", !protocol.verifySignature(secret, canon, "00".repeat(32)));
|
|
ok("normalizeHost strips :443 not :3000", protocol.normalizeHost("Host:443") === "host" && protocol.normalizeHost("h:3000") === "h:3000");
|
|
ok("timestampWithinSkew rejects a 10-min-old ts", !protocol.timestampWithinSkew(String(Date.now() - 10 * 60 * 1000)));
|
|
await step("a truncated GCM tag is refused (authTagLength pinned to 16)", async () => {
|
|
const s = peers.sealedColumns(protocol.randomSecret());
|
|
let refused = false;
|
|
try {
|
|
peers.openSecret({ ...s, peer_secret_tag: s.peer_secret_tag.slice(0, 8) });
|
|
} catch (e) {
|
|
refused = true;
|
|
}
|
|
ok("4-byte prefix of the real tag does not open the secret", refused);
|
|
});
|
|
await step("the KEK comes ONLY from SALTCORN_SESSION_SECRET or the config file", async () => {
|
|
const connect = require("@saltcorn/data/db/connect");
|
|
const realGetConfigFile = connect.getConfigFile;
|
|
const realEnvSecret = process.env.SALTCORN_SESSION_SECRET;
|
|
const sealThrows = () => {
|
|
try {
|
|
peers.sealedColumns(Buffer.from("x"));
|
|
return false;
|
|
} catch (e) {
|
|
return /no session_secret configured/.test(e.message);
|
|
}
|
|
};
|
|
try {
|
|
delete process.env.SALTCORN_SESSION_SECRET;
|
|
connect.getConfigFile = () => ({});
|
|
// db.connectObj still holds a session_secret (Saltcorn's resolved value or
|
|
// its generated fallback); it must NOT be used.
|
|
ok("neither env nor config file -> refused (fallback not used)", sealThrows());
|
|
connect.getConfigFile = () => ({ session_secret: "from-config-file" });
|
|
ok("config-file secret accepted", !sealThrows());
|
|
} finally {
|
|
connect.getConfigFile = realGetConfigFile;
|
|
process.env.SALTCORN_SESSION_SECRET = realEnvSecret;
|
|
}
|
|
ok("env secret restored and accepted", !sealThrows());
|
|
});
|
|
|
|
section("Phase 1: peer pairing");
|
|
const peerEnvId = randomUuid();
|
|
let peerRow;
|
|
let peerSecretBuf;
|
|
await step("addPeer seals a secret and reads it back", async () => {
|
|
const { peer, secret: s } = await peers.addPeer({ envId: peerEnvId, label: "dev", baseUrl: `http://${HOST}` });
|
|
peerRow = peer;
|
|
peerSecretBuf = s;
|
|
const back = await peers.peerSecret(peer.peer_id);
|
|
ok("peerSecret decrypts to the pairing secret", Buffer.compare(back, s) === 0);
|
|
ok("listPeers omits sealed columns", (await peers.listPeers())[0].peer_secret_ciphertext === undefined);
|
|
});
|
|
await step("addPeer rejects a duplicate env_id", async () => {
|
|
ok("duplicate pairing throws", await throwsAsync(() => peers.addPeer({ envId: peerEnvId, baseUrl: `http://${HOST}` })));
|
|
});
|
|
await step("addPeer rejects a non-http(s) base_url", async () => {
|
|
ok("file: scheme rejected", await throwsAsync(() => peers.addPeer({ envId: randomUuid(), baseUrl: "file:///etc/passwd" })));
|
|
});
|
|
await step("rotatePeerSecret changes the secret", async () => {
|
|
const { secret: rotated } = await peers.rotatePeerSecret(peerRow.peer_id);
|
|
ok("rotated secret differs", Buffer.compare(rotated, peerSecretBuf) !== 0);
|
|
peerSecretBuf = rotated;
|
|
});
|
|
|
|
section("Phase 1: inbound HMAC auth (requirePeerAuth)");
|
|
const authOk = async (label, reqOpts) => {
|
|
const req = mkPeerReq(Object.assign({ method: "GET", path: "/site-sync/api/health", host: HOST, envId: peerEnvId, secret: peerSecretBuf }, reqOpts));
|
|
const res = mkRes();
|
|
const peer = await requirePeerAuth(req, res);
|
|
return { peer, res };
|
|
};
|
|
await step("valid signed GET authenticates", async () => {
|
|
const { peer, res } = await authOk("valid");
|
|
ok("returns the peer row", !!peer && res.statusCode === 200, `status=${res.statusCode}`);
|
|
});
|
|
await step("reused nonce is rejected (replay)", async () => {
|
|
const nonce = protocol.randomNonce().toString("hex");
|
|
const first = await authOk("first", { override: { nonce } });
|
|
const second = await authOk("second", { override: { nonce } });
|
|
ok("first accepted", !!first.peer);
|
|
ok("replay rejected 401", second.peer === null && second.res.statusCode === 401, `status=${second.res.statusCode}`);
|
|
});
|
|
await step("bad signature is rejected", async () => {
|
|
const { peer, res } = await authOk("badsig", { override: { badSignature: true } });
|
|
ok("rejected 401", peer === null && res.statusCode === 401);
|
|
});
|
|
await step("stale timestamp is rejected (skew)", async () => {
|
|
const { peer, res } = await authOk("skew", { override: { timestamp: String(Date.now() - 10 * 60 * 1000) } });
|
|
ok("rejected 401", peer === null && res.statusCode === 401);
|
|
});
|
|
await step("cross-tenant host rebinding is rejected", async () => {
|
|
// Signed for HOST, but the request arrives at a different Host header:
|
|
// the canonical no longer matches, so the signature fails.
|
|
const req = mkPeerReq({ method: "GET", path: "/site-sync/api/health", host: HOST, envId: peerEnvId, secret: peerSecretBuf,
|
|
override: { headers: { host: "evil.example.com" } } });
|
|
const res = mkRes();
|
|
const peer = await requirePeerAuth(req, res);
|
|
ok("rejected 401", peer === null && res.statusCode === 401);
|
|
});
|
|
await step("a spoofed X-Forwarded-Host cannot restore the signed host", async () => {
|
|
// Routed (by Host) to another tenant, but the client claims the signed host
|
|
// via X-Forwarded-Host. With trust proxy off that header is the client's
|
|
// own word, so it must not feed the canonical.
|
|
const req = mkPeerReq({ method: "GET", path: "/site-sync/api/health", host: HOST, envId: peerEnvId, secret: peerSecretBuf,
|
|
override: { headers: { host: "t2.example.com", "x-forwarded-host": HOST } } });
|
|
const res = mkRes();
|
|
const peer = await requirePeerAuth(req, res);
|
|
ok("XFH spoof rejected 401", peer === null && res.statusCode === 401, `status=${res.statusCode}`);
|
|
});
|
|
await step("a spoofed X-Forwarded-Proto does not satisfy require_tls", async () => {
|
|
await env.setEnvFields({ require_tls: 1 });
|
|
const req = mkPeerReq({ method: "GET", path: "/site-sync/api/health", host: HOST, envId: peerEnvId, secret: peerSecretBuf,
|
|
secure: false, override: { headers: { "x-forwarded-proto": "https" } } });
|
|
const res = mkRes();
|
|
const peer = await requirePeerAuth(req, res);
|
|
await env.setEnvFields({ require_tls: 0 });
|
|
ok("XFP spoof rejected 403", peer === null && res.statusCode === 403, `status=${res.statusCode}`);
|
|
});
|
|
await step("request body is capped BEFORE the signature check", async () => {
|
|
const { PEER_LIMITS } = require("../lib/transport");
|
|
// (a) declared Content-Length over the cap: refused without reading.
|
|
const declared = mkPeerReq({ method: "POST", path: "/site-sync/api/plan", host: HOST, envId: peerEnvId, secret: peerSecretBuf, body: { a: 1 },
|
|
override: { headers: { "content-length": String(PEER_LIMITS.REQUEST_BODY_BYTES + 1) } } });
|
|
let res = mkRes();
|
|
ok("over-cap Content-Length -> 413", (await requirePeerAuth(declared, res)) === null && res.statusCode === 413, `status=${res.statusCode}`);
|
|
// (b) undeclared stream with a FORGED signature: cut off at the cap, not buffered whole.
|
|
const chunk = Buffer.alloc(1024 * 1024, 0x41);
|
|
const chunks = Math.ceil(PEER_LIMITS.REQUEST_BODY_BYTES / chunk.length) + 8;
|
|
let consumed = 0;
|
|
const flood = mkPeerReq({ method: "POST", path: "/site-sync/api/plan", host: HOST, envId: peerEnvId, secret: Buffer.alloc(32, 9), body: { a: 1 } });
|
|
flood[Symbol.asyncIterator] = async function* () {
|
|
for (let n = 0; n < chunks; n += 1) {
|
|
consumed += chunk.length;
|
|
yield chunk;
|
|
}
|
|
};
|
|
res = mkRes();
|
|
ok("over-cap stream -> 413", (await requirePeerAuth(flood, res)) === null && res.statusCode === 413, `status=${res.statusCode}`);
|
|
ok("stream abandoned at the cap", consumed <= PEER_LIMITS.REQUEST_BODY_BYTES + chunk.length, `consumed=${consumed}`);
|
|
});
|
|
await step("unknown env_id is rejected", async () => {
|
|
const req = mkPeerReq({ method: "GET", path: "/site-sync/api/health", host: HOST, envId: randomUuid(), secret: peerSecretBuf });
|
|
const res = mkRes();
|
|
const peer = await requirePeerAuth(req, res);
|
|
ok("rejected 401", peer === null && res.statusCode === 401);
|
|
});
|
|
await step("POST body is bound into the signature", async () => {
|
|
const body = { hello: "world", n: 42 };
|
|
const req = mkPeerReq({ method: "POST", path: "/site-sync/api/plan", host: HOST, envId: peerEnvId, secret: peerSecretBuf, body });
|
|
const res = mkRes();
|
|
const peer = await requirePeerAuth(req, res);
|
|
ok("authenticated and body parsed", !!peer && req.body && req.body.n === 42);
|
|
});
|
|
await step("require_tls rejects non-HTTPS inbound", async () => {
|
|
await env.setEnvFields({ require_tls: 1 });
|
|
const req = mkPeerReq({ method: "GET", path: "/site-sync/api/health", host: HOST, envId: peerEnvId, secret: peerSecretBuf, secure: false });
|
|
const res = mkRes();
|
|
const peer = await requirePeerAuth(req, res);
|
|
ok("rejected 403", peer === null && res.statusCode === 403, `status=${res.statusCode}`);
|
|
await env.setEnvFields({ require_tls: 0 });
|
|
});
|
|
|
|
section("Phase 1: admin + machine handlers render");
|
|
await step("GET /admin/site-sync/ dashboard renders", async () => {
|
|
const res = mkRes();
|
|
await routeOf(routes.routes, "get", "/admin/site-sync/")(mkAdminReq(), res);
|
|
ok("sendWrap called with a title", typeof res._wrapTitle === "string" && res._wrapTitle.includes("site-sync"));
|
|
});
|
|
await step("GET /admin/site-sync/peers renders", async () => {
|
|
const res = mkRes();
|
|
await routeOf(routes.routes, "get", "/admin/site-sync/peers")(mkAdminReq({ originalUrl: "/admin/site-sync/peers" }), res);
|
|
ok("sendWrap called", !!res._wrap);
|
|
});
|
|
await step("dashboard refuses a non-admin", async () => {
|
|
const res = mkRes();
|
|
await routeOf(routes.routes, "get", "/admin/site-sync/")(mkAdminReq({ user: { role_id: 80, tenant: db.getTenantSchema() } }), res);
|
|
ok("403 for non-admin", res.statusCode === 403);
|
|
});
|
|
await step("an admin of ANOTHER tenant is not this tenant's admin", async () => {
|
|
// PG sessions are shared by all tenants: tenant B's admin replaying their
|
|
// cookie here arrives as { role_id: 1, tenant: "<B>" }.
|
|
const foreign = { role_id: 1, tenant: `${db.getTenantSchema()}_other`, email: "admin@other" };
|
|
let res = mkRes();
|
|
await routeOf(routes.routes, "get", "/admin/site-sync/")(mkAdminReq({ user: foreign }), res);
|
|
ok("dashboard 403 for a foreign-tenant admin", res.statusCode === 403 && !res._wrap, `status=${res.statusCode}`);
|
|
const before = (await peers.listPeers()).length;
|
|
res = mkRes();
|
|
await routeOf(routes.routes, "post", "/admin/site-sync/peers/add")(mkAdminReq({ user: foreign, method: "POST",
|
|
body: { env_id: randomUuid(), base_url: "http://evil.example.com" } }), res);
|
|
ok("peer pairing 403 for a foreign-tenant admin", res.statusCode === 403 && (await peers.listPeers()).length === before);
|
|
});
|
|
await step("GET /site-sync/api/health returns env + plugins, signed", async () => {
|
|
const req = mkPeerReq({ method: "GET", path: "/site-sync/api/health", host: HOST, envId: peerEnvId, secret: peerSecretBuf });
|
|
const res = mkRes();
|
|
await routeOf(routes.routes, "get", "/site-sync/api/health")(req, res);
|
|
ok("health body has env_id", !!(res.body && res.body.env_id));
|
|
ok("health lists site-sync among plugins", !!(res.body && Array.isArray(res.body.plugins) && res.body.plugins.some((p) => p.name === "site-sync")));
|
|
ok("health response carries a valid response signature", responseSigned(req, res, peerSecretBuf));
|
|
});
|
|
};
|
|
|
|
|
|
const phase2 = async () => {
|
|
section("Phase 2: desired-state capture");
|
|
const myEnv = await env.getEnv();
|
|
|
|
let h1;
|
|
await step("buildDesiredState returns a sanitized pack + hash", async () => {
|
|
const cap = await capture.buildDesiredState();
|
|
h1 = cap.hash;
|
|
ok("pack.tables is an array", Array.isArray(cap.pack.tables));
|
|
ok("hash is a 64-hex sha256", /^[0-9a-f]{64}$/.test(cap.hash), cap.hash);
|
|
ok("event_logs forced empty", Array.isArray(cap.pack.event_logs) && cap.pack.event_logs.length === 0);
|
|
ok("model_instances forced empty", Array.isArray(cap.pack.model_instances) && cap.pack.model_instances.length === 0);
|
|
});
|
|
await step("capture is stable for unchanged state", async () => {
|
|
const cap = await capture.buildDesiredState();
|
|
ok("second hash equals first", cap.hash === h1, `${cap.hash} vs ${h1}`);
|
|
});
|
|
await step("infra tables (_ss_/_sc_) are excluded from the pack", async () => {
|
|
const cap = await capture.buildDesiredState();
|
|
const infra = cap.pack.tables.filter((t) => t.name.startsWith("_"));
|
|
ok("_ss_peers not in desired state", !cap.pack.tables.some((t) => t.name === "_ss_peers"), `underscored=${infra.map((t) => t.name).join(",")}`);
|
|
});
|
|
await step("config restricted to the syncable allow-list", async () => {
|
|
const cap = await capture.buildDesiredState();
|
|
const keys = Object.keys(cap.pack.config || {});
|
|
const leaked = keys.filter((k) => !SYNCABLE_CONFIG_KEYS.includes(k));
|
|
ok("no non-allow-listed config key", leaked.length === 0, `leaked=${leaked.join(",")}`);
|
|
ok("disable_csrf_routes not present", !keys.includes("disable_csrf_routes"));
|
|
});
|
|
await step("plugin entries carry no secrets/config", async () => {
|
|
const cap = await capture.buildDesiredState();
|
|
const bad = (cap.pack.plugins || []).filter((p) => "deploy_private_key" in p || "configuration" in p);
|
|
ok("no deploy_private_key or configuration on any plugin", bad.length === 0);
|
|
ok("site-sync present in parity list", (cap.pack.plugins || []).some((p) => p.name === "site-sync"));
|
|
});
|
|
|
|
section("Phase 2: capture reflects state changes");
|
|
await step("creating a user table changes the hash + appears in pack", async () => {
|
|
const t = await Table.create("widgets", { min_role_read: 1, min_role_write: 1 });
|
|
await Field.create({ table: t, name: "title", label: "title", type: "String" });
|
|
const cap = await capture.buildDesiredState();
|
|
ok("hash changed after schema edit", cap.hash !== h1);
|
|
const w = cap.pack.tables.find((x) => x.name === "widgets");
|
|
ok("widgets table in desired state", !!w);
|
|
ok("widgets carries its title field", !!(w && w.fields && w.fields.some((f) => f.name === "title")));
|
|
});
|
|
|
|
section("Phase 2: GET /api/state over the signed transport");
|
|
const stateEnvId = randomUuid();
|
|
const { secret: stateSecret } = await peers.addPeer({ envId: stateEnvId, label: "src", baseUrl: `http://${HOST}` });
|
|
await step("apiState serves the sanitized pack to a peer", async () => {
|
|
const req = mkPeerReq({ method: "GET", path: "/site-sync/api/state", host: HOST, envId: stateEnvId, secret: stateSecret });
|
|
const res = mkRes();
|
|
await routeOf(routes.routes, "get", "/site-sync/api/state")(req, res);
|
|
ok("returns a pack", !!(res.body && res.body.pack && Array.isArray(res.body.pack.tables)));
|
|
ok("includes widgets", !!(res.body && res.body.pack.tables.some((t) => t.name === "widgets")));
|
|
ok("hash matches a fresh local capture", res.body.hash === (await capture.buildDesiredState()).hash);
|
|
ok("state response carries a valid response signature", responseSigned(req, res, stateSecret));
|
|
});
|
|
await step("apiState refuses an unauthenticated request", async () => {
|
|
const req = mkPeerReq({ method: "GET", path: "/site-sync/api/state", host: HOST, envId: stateEnvId, secret: stateSecret, override: { badSignature: true } });
|
|
const res = mkRes();
|
|
await routeOf(routes.routes, "get", "/site-sync/api/state")(req, res);
|
|
ok("401 without a valid signature", res.statusCode === 401 && !res.body.pack);
|
|
});
|
|
};
|
|
|
|
|
|
// Minimal well-formed pack for pure diff tests.
|
|
const emptyPack = () => ({
|
|
tables: [], views: [], pages: [], page_groups: [], triggers: [],
|
|
roles: [], library: [], tags: [], models: [], config: {}
|
|
});
|
|
const mkTable = (name, fields, constraints) => ({
|
|
name, description: null, min_role_read: 1, min_role_write: 1, versioned: false,
|
|
fields: fields || [], constraints: constraints || []
|
|
});
|
|
const mkField = (name, over) => Object.assign({ name, type: "String", required: false, is_unique: false, primary_key: false, attributes: {} }, over);
|
|
|
|
|
|
const phase3 = async () => {
|
|
const diff = require("../lib/diff");
|
|
const planLib = require("../lib/plan");
|
|
|
|
section("Phase 3: pure diff classification");
|
|
await step("create: source-only table", async () => {
|
|
const d = emptyPack(); d.tables = [mkTable("a", [mkField("id", { primary_key: true, type: "Integer" })])];
|
|
const p = diff.computePlan(d, emptyPack(), { rowCounts: {} });
|
|
ok("a is a create", p.entities.tables.create.some((t) => t.name === "a"));
|
|
ok("summary counts it", p.summary.tables === 1 && p.summary.total === 1);
|
|
});
|
|
await step("delete under mirror; kept under additive", async () => {
|
|
const c = emptyPack(); c.tables = [mkTable("b")];
|
|
const mir = diff.computePlan(emptyPack(), c, { syncMode: "mirror", rowCounts: { b: 0 } });
|
|
const add = diff.computePlan(emptyPack(), c, { syncMode: "additive", rowCounts: { b: 0 } });
|
|
ok("mirror deletes b", mir.entities.tables.delete.some((t) => t.name === "b"));
|
|
ok("additive keeps b", add.entities.tables.delete.length === 0);
|
|
});
|
|
await step("users table is never a delete candidate (capture keeps it out of both packs)", async () => {
|
|
// A mirror plan whose source has NO tables at all: every target table is a
|
|
// delete candidate -- except users, which never enters the target's pack.
|
|
const cur = await capture.buildDesiredState();
|
|
const desired = JSON.parse(JSON.stringify(cur.pack));
|
|
desired.tables = [mkTable("users", [mkField("email")])];
|
|
const { plan } = await planLib.buildPlan({ peerRow: null, env: await env.getEnv(), desired, desiredHash: "h", sourceEnvId: "src" });
|
|
ok("users not deleted", !plan.entities.tables.delete.some((t) => t.name === "users"));
|
|
ok("users not created/updated from a hostile source spec", !plan.entities.tables.create.some((t) => t.name === "users") && !plan.entities.tables.update.some((u) => u.name === "users"));
|
|
});
|
|
await step("roles keyed by id; roles never deleted", async () => {
|
|
const d = emptyPack(); d.roles = [{ id: 5, role: "editor" }];
|
|
const c = emptyPack(); c.roles = [{ id: 5, role: "old" }, { id: 7, role: "gone" }];
|
|
const p = diff.computePlan(d, c, { syncMode: "mirror", rowCounts: {} });
|
|
ok("role 5 is an update by id", p.entities.roles.update.some((u) => u.key === "5"));
|
|
ok("role 7 is NOT deleted (additive)", p.entities.roles.delete.length === 0);
|
|
});
|
|
await step("nested field create + data-bearing field delete", async () => {
|
|
const d = emptyPack(); d.tables = [mkTable("t", [mkField("a"), mkField("c")])];
|
|
const c = emptyPack(); c.tables = [mkTable("t", [mkField("a"), mkField("b")])];
|
|
const p = diff.computePlan(d, c, { syncMode: "mirror", rowCounts: { t: 3 } });
|
|
const u = p.entities.tables.update.find((x) => x.name === "t");
|
|
ok("field c is created", !!u && u.fields.create.some((f) => f.name === "c"));
|
|
ok("field b is deleted", !!u && u.fields.delete.some((f) => f.name === "b"));
|
|
ok("field-drop flagged data-bearing", p.destructive.some((x) => x.dataBearing && x.field === "b"));
|
|
});
|
|
await step("constraint add/remove diff", async () => {
|
|
const con = (cfg) => ({ type: "Unique", configuration: cfg });
|
|
const d = emptyPack(); d.tables = [mkTable("t", [mkField("a")], [con({ fields: ["a"] })])];
|
|
const c = emptyPack(); c.tables = [mkTable("t", [mkField("a")], [])];
|
|
const p = diff.computePlan(d, c, { rowCounts: { t: 0 } });
|
|
const u = p.entities.tables.update.find((x) => x.name === "t");
|
|
ok("constraint add detected", !!u && u.constraints.add.length === 1);
|
|
});
|
|
await step("rename detection by structural signature", async () => {
|
|
const fields = [mkField("id", { primary_key: true, type: "Integer" }), mkField("title")];
|
|
const d = emptyPack(); d.tables = [mkTable("articles", fields)];
|
|
const c = emptyPack(); c.tables = [mkTable("posts", fields)];
|
|
const p = diff.computePlan(d, c, { syncMode: "mirror", rowCounts: { posts: 4 } });
|
|
ok("posts->articles detected as a rename", p.entities.tables.rename.some((r) => r.from === "posts" && r.to === "articles"));
|
|
ok("rename removed from create", !p.entities.tables.create.some((t) => t.name === "articles"));
|
|
ok("rename removed from delete", !p.entities.tables.delete.some((t) => t.name === "posts"));
|
|
});
|
|
await step("no false diff for identical packs; config overwrite", async () => {
|
|
const a = emptyPack(); a.tables = [mkTable("t", [mkField("a")])]; a.config = { site_name: "X" };
|
|
const b = JSON.parse(JSON.stringify(a));
|
|
ok("identical packs => empty plan", diff.isEmptyPlan(diff.computePlan(a, b, { rowCounts: {} })));
|
|
const d = JSON.parse(JSON.stringify(a)); d.config = { site_name: "Y" };
|
|
const p = diff.computePlan(d, b, { rowCounts: {} });
|
|
ok("config change => a set", p.config.sets.site_name === "Y" && p.summary.config === 1);
|
|
});
|
|
|
|
section("Phase 3: live plan build + persistence");
|
|
const myEnv = await env.getEnv();
|
|
await step("unchanged desired => empty plan", async () => {
|
|
const cur = await capture.buildDesiredState();
|
|
const { plan } = await planLib.buildPlan({ peerRow: null, env: myEnv, desired: cur.pack, desiredHash: cur.hash, sourceEnvId: "src" });
|
|
ok("plan is empty", diff.isEmptyPlan(plan));
|
|
});
|
|
let savedPlanId;
|
|
await step("modified desired => create + delete surfaced and persisted", async () => {
|
|
const cur = await capture.buildDesiredState();
|
|
const desired = JSON.parse(JSON.stringify(cur.pack));
|
|
// add gadgets, remove widgets from the desired state
|
|
desired.tables = desired.tables.filter((t) => t.name !== "widgets");
|
|
desired.tables.push(mkTable("gadgets", [mkField("id", { primary_key: true, type: "Integer" }), mkField("name")]));
|
|
const { plan_id, plan } = await planLib.buildPlan({ peerRow: null, env: myEnv, desired, desiredHash: "srchash", sourceEnvId: "src" });
|
|
savedPlanId = plan_id;
|
|
ok("gadgets is a create", plan.entities.tables.create.some((t) => t.name === "gadgets"));
|
|
ok("widgets is a delete (0 rows, non-data-bearing)", plan.entities.tables.delete.some((t) => t.name === "widgets" && !t.dataBearing));
|
|
ok("precondition hash recorded", plan.precondition_hash === cur.hash);
|
|
});
|
|
await step("loadPlan round-trips the persisted plan", async () => {
|
|
const loaded = await planLib.loadPlan(savedPlanId);
|
|
ok("loaded plan has same id", loaded && loaded.plan_id === savedPlanId);
|
|
ok("desired pack persisted", loaded && Array.isArray(loaded.desired.tables));
|
|
ok("status pending", loaded && loaded.status === "pending");
|
|
});
|
|
await step("renderPlanHtml produces a preview without throwing", async () => {
|
|
const loaded = await planLib.loadPlan(savedPlanId);
|
|
const html = planLib.renderPlanHtml(loaded.plan_id, loaded.plan, "src");
|
|
ok("html mentions gadgets", typeof html === "string" && html.includes("gadgets"));
|
|
});
|
|
};
|
|
|
|
|
|
const phase4 = async () => {
|
|
const diff = require("../lib/diff");
|
|
const planLib = require("../lib/plan");
|
|
const applyLib = require("../lib/apply");
|
|
const myEnv = await env.getEnv();
|
|
|
|
section("Phase 4: upsert apply (create + update + config)");
|
|
// Faithful "record desired -> perturb back -> converge": build the desired pack
|
|
// by actually creating the entities (real create_pack_json field shapes), then
|
|
// revert the live DB and prove apply reconstructs it.
|
|
let convergedDesired;
|
|
let convergedHash;
|
|
await step("apply creates a table, adds a field, and sets config", async () => {
|
|
const prevSiteName = getState().getConfig("site_name") || "";
|
|
const sprk = await Table.create("sprockets", { min_role_read: 1, min_role_write: 1 });
|
|
await Field.create({ table: sprk, name: "kind", label: "kind", type: "String" });
|
|
await Field.create({ table: Table.findOne({ name: "widgets" }), name: "color", label: "color", type: "String" });
|
|
await getState().setConfig("site_name", "SyncedSite");
|
|
|
|
const desiredCap = await capture.buildDesiredState();
|
|
convergedDesired = desiredCap.pack;
|
|
convergedHash = desiredCap.hash;
|
|
|
|
// revert the live DB to before the perturbation
|
|
await Table.findOne({ name: "sprockets" }).delete();
|
|
await Table.findOne({ name: "widgets" }).getField("color").delete();
|
|
await getState().setConfig("site_name", prevSiteName);
|
|
|
|
const { plan_id, plan } = await planLib.buildPlan({ peerRow: null, env: myEnv, desired: convergedDesired, desiredHash: convergedHash, sourceEnvId: "src" });
|
|
ok("plan is non-empty", !diff.isEmptyPlan(plan));
|
|
ok("plan creates sprockets", plan.entities.tables.create.some((t) => t.name === "sprockets"));
|
|
const result = await applyLib.applyPlan(plan_id, { applyDeletes: true });
|
|
ok("apply reported success", result.applied === true);
|
|
|
|
const t = Table.findOne({ name: "sprockets" });
|
|
ok("sprockets table created", !!t);
|
|
ok("sprockets has kind field", !!(t && t.getField("kind")));
|
|
const w = Table.findOne({ name: "widgets" });
|
|
ok("widgets gained color field", !!(w && w.getField("color")));
|
|
ok("site_name config applied", getState().getConfig("site_name") === "SyncedSite", getState().getConfig("site_name"));
|
|
const loaded = await planLib.loadPlan(plan_id);
|
|
ok("plan marked applied", loaded.status === "applied");
|
|
ok("persisted desired not mutated by install_pack", loaded.desired.tables.some((x) => x.name === "sprockets"));
|
|
});
|
|
|
|
section("Phase 4: idempotency (re-sync of converged state)");
|
|
await step("re-planning the same desired (JSON round-tripped as over the wire) => empty plan", async () => {
|
|
// Round-trip the desired through JSON exactly as the signed transport does,
|
|
// so this also guards the instance-vs-plain-object false-diff regression.
|
|
const overTheWire = JSON.parse(JSON.stringify(convergedDesired));
|
|
const { plan } = await planLib.buildPlan({ peerRow: null, env: myEnv, desired: overTheWire, desiredHash: "h", sourceEnvId: "src" });
|
|
ok("converged => nothing to do", diff.isEmptyPlan(plan), `total=${plan.summary.total}`);
|
|
});
|
|
|
|
section("Phase 4: config allow-list enforced on apply");
|
|
await step("a non-allow-listed config key is never written", async () => {
|
|
const before = getState().getConfig("disable_csrf_routes", "");
|
|
const cur = await capture.buildDesiredState();
|
|
const desired = JSON.parse(JSON.stringify(cur.pack));
|
|
desired.config = Object.assign({}, desired.config, { disable_csrf_routes: "/hacked", site_name: "AllowListOK" });
|
|
const { plan_id, plan } = await planLib.buildPlan({ peerRow: null, env: myEnv, desired, desiredHash: "h", sourceEnvId: "src" });
|
|
ok("plan config.sets excludes disable_csrf_routes", !("disable_csrf_routes" in plan.config.sets));
|
|
ok("plan config.sets includes site_name", plan.config.sets.site_name === "AllowListOK");
|
|
await applyLib.applyPlan(plan_id, { applyDeletes: true });
|
|
ok("disable_csrf_routes unchanged after apply", getState().getConfig("disable_csrf_routes", "") === before, getState().getConfig("disable_csrf_routes", ""));
|
|
ok("site_name did change", getState().getConfig("site_name") === "AllowListOK");
|
|
});
|
|
|
|
section("Phase 4: precondition drift refusal");
|
|
await step("applying a plan after the target drifts is refused", async () => {
|
|
const cur = await capture.buildDesiredState();
|
|
const desired = JSON.parse(JSON.stringify(cur.pack));
|
|
desired.tables.push(mkTable("cogs", [mkField("id", { primary_key: true, type: "Integer" })]));
|
|
const { plan_id } = await planLib.buildPlan({ peerRow: null, env: myEnv, desired, desiredHash: "h", sourceEnvId: "src" });
|
|
// drift the target after the plan is built
|
|
const drift = await Table.create("drifttable", { min_role_read: 1, min_role_write: 1 });
|
|
await Field.create({ table: drift, name: "x", label: "x", type: "String" });
|
|
let threw = false;
|
|
try {
|
|
await applyLib.applyPlan(plan_id, { applyDeletes: true });
|
|
} catch (e) {
|
|
threw = /changed since/.test(e.message);
|
|
}
|
|
ok("stale plan refused", threw);
|
|
ok("cogs was NOT created (apply aborted)", !Table.findOne({ name: "cogs" }));
|
|
});
|
|
};
|
|
|
|
|
|
// Create a live table with optional String fields; return the fresh Table.
|
|
const mkLiveTable = async (name, fields) => {
|
|
const t = await Table.create(name, { min_role_read: 1, min_role_write: 1 });
|
|
for (const fn of (fields || [])) {
|
|
await Field.create({ table: t, name: fn, label: fn, type: "String" });
|
|
}
|
|
return Table.findOne({ name });
|
|
};
|
|
|
|
// A desired pack = current capture minus the named tables/pages (source dropped them).
|
|
const desiredWithout = async ({ tables, pages }) => {
|
|
const cur = await capture.buildDesiredState();
|
|
const d = JSON.parse(JSON.stringify(cur.pack));
|
|
if (tables) {
|
|
d.tables = d.tables.filter((t) => !tables.includes(t.name));
|
|
}
|
|
if (pages) {
|
|
d.pages = d.pages.filter((p) => !pages.includes(p.name));
|
|
}
|
|
return d;
|
|
};
|
|
|
|
|
|
const phase5 = async () => {
|
|
const planLib = require("../lib/plan");
|
|
const applyLib = require("../lib/apply");
|
|
const deletes = require("../lib/deletes");
|
|
const Page = require("@saltcorn/data/models/page");
|
|
const myEnv = () => env.getEnv();
|
|
const buildAndApply = async (desired, opts) => {
|
|
const e = await myEnv();
|
|
const { plan_id } = await planLib.buildPlan({ peerRow: null, env: e, desired, desiredHash: "h", sourceEnvId: "src" });
|
|
return applyLib.applyPlan(plan_id, Object.assign({ applyDeletes: true }, opts));
|
|
};
|
|
|
|
section("Phase 5: mirror prune -- metadata-only vs data-bearing");
|
|
await step("empty table + page pruned; data-bearing table retained without allow-list", async () => {
|
|
await mkLiveTable("temp_empty");
|
|
const td = await mkLiveTable("temp_data", ["val"]);
|
|
await td.insertRow({ val: "keep" });
|
|
await Page.create({ name: "temp_page", title: "t", description: "", min_role: 1, layout: {}, fixed_states: {} });
|
|
|
|
const desired = await desiredWithout({ tables: ["temp_empty", "temp_data"], pages: ["temp_page"] });
|
|
const res = await buildAndApply(desired, { prunable: [], confirmDataDrops: false });
|
|
ok("empty table dropped", !Table.findOne({ name: "temp_empty" }));
|
|
ok("page dropped", !Page.findOne({ name: "temp_page" }));
|
|
ok("data-bearing table RETAINED", !!Table.findOne({ name: "temp_data" }));
|
|
ok("skip reason cites allow-list", res.deletes.skipped.some((s) => s.name === "temp_data" && /allow-list/.test(s.reason)));
|
|
ok("its row is intact", (await Table.findOne({ name: "temp_data" }).countRows()) === 1);
|
|
});
|
|
|
|
await step("data-bearing drop proceeds with allow-list + typed confirm", async () => {
|
|
const desired = await desiredWithout({ tables: ["temp_data"] });
|
|
const res = await buildAndApply(desired, { prunable: ["temp_data"], confirmDataDrops: true });
|
|
ok("temp_data dropped", !Table.findOne({ name: "temp_data" }));
|
|
ok("delete counted", res.deletes.deleted >= 1);
|
|
});
|
|
|
|
section("Phase 5: refuse policy blocks all data-bearing drops");
|
|
await step("refuse policy retains a data-bearing table even when allow-listed", async () => {
|
|
const t = await mkLiveTable("temp_refuse", ["val"]);
|
|
await t.insertRow({ val: "x" });
|
|
await env.setEnvFields({ on_delete: "refuse" });
|
|
const desired = await desiredWithout({ tables: ["temp_refuse"] });
|
|
const res = await buildAndApply(desired, { prunable: ["temp_refuse"], confirmDataDrops: true });
|
|
ok("table retained under refuse", !!Table.findOne({ name: "temp_refuse" }));
|
|
ok("skip reason cites refuse", res.deletes.skipped.some((s) => s.name === "temp_refuse" && /refuse/.test(s.reason)));
|
|
await env.setEnvFields({ on_delete: "confirm" });
|
|
});
|
|
|
|
section("Phase 5: users table is never dropped");
|
|
await step("applyDeletes refuses a users drop even if forced into the plan + allow-list", async () => {
|
|
// A real (empty) plan has every entity bucket; force the users drop into it.
|
|
const fakePlan = require("../lib/diff").computePlan(emptyPack(), emptyPack(), { rowCounts: {} });
|
|
fakePlan.entities.tables.delete = [{ name: "users", rowCount: 3, dataBearing: true }];
|
|
const res = await deletes.applyDeletes(fakePlan, { onDelete: "auto", prunable: ["users"], confirmDataDrops: true });
|
|
ok("users still exists", !!Table.findOne({ name: "users" }));
|
|
ok("skip cites locked table", res.skipped.some((s) => s.name === "users" && /locked/.test(s.reason)));
|
|
});
|
|
|
|
section("Phase 5: additive mode performs no deletes");
|
|
await step("additive sync keeps a source-absent table", async () => {
|
|
await mkLiveTable("temp_additive");
|
|
await env.setEnvFields({ sync_mode: "additive" });
|
|
const desired = await desiredWithout({ tables: ["temp_additive"] });
|
|
const res = await buildAndApply(desired, { prunable: [] });
|
|
ok("table retained under additive", !!Table.findOne({ name: "temp_additive" }));
|
|
ok("no deletes performed", res.deletes.deleted === 0);
|
|
await env.setEnvFields({ sync_mode: "mirror" });
|
|
});
|
|
|
|
section("Phase 5: field drop gate");
|
|
await step("data-bearing column drop gated, then allowed", async () => {
|
|
const t = await mkLiveTable("fielddrop", ["keepme", "extra"]);
|
|
await t.insertRow({ keepme: "a", extra: "b" });
|
|
// desired: fielddrop without "extra"
|
|
const cur = await capture.buildDesiredState();
|
|
const desired = JSON.parse(JSON.stringify(cur.pack));
|
|
const fd = desired.tables.find((x) => x.name === "fielddrop");
|
|
fd.fields = fd.fields.filter((f) => f.name !== "extra");
|
|
const res1 = await buildAndApply(desired, { prunable: [], confirmDataDrops: false });
|
|
ok("extra field retained without allow-list", !!Table.findOne({ name: "fielddrop" }).getField("extra"));
|
|
ok("field-drop skipped + reported", res1.deletes.skipped.some((s) => s.name === "fielddrop.extra"));
|
|
const res2 = await buildAndApply(desired, { prunable: ["fielddrop"], confirmDataDrops: true });
|
|
ok("extra field dropped with allow-list+confirm", !Table.findOne({ name: "fielddrop" }).getField("extra"));
|
|
ok("dropped-field counted", res2.deletes.droppedFields >= 1);
|
|
});
|
|
|
|
section("Phase 5: rename applies on PG, safely falls back on SQLite -- data preserved either way");
|
|
await step("a detected rename never loses data", async () => {
|
|
const t = await mkLiveTable("old_name", ["title"]);
|
|
await t.insertRow({ title: "row1" });
|
|
// desired: same structure under a new name (rename), old_name absent
|
|
const cur = await capture.buildDesiredState();
|
|
const desired = JSON.parse(JSON.stringify(cur.pack));
|
|
const oldT = desired.tables.find((x) => x.name === "old_name");
|
|
oldT.name = "new_name";
|
|
const e = await myEnv();
|
|
const { plan_id, plan } = await planLib.buildPlan({ peerRow: null, env: e, desired, desiredHash: "h", sourceEnvId: "src" });
|
|
ok("rename detected in plan", plan.entities.tables.rename.some((r) => r.from === "old_name" && r.to === "new_name"));
|
|
const res = await applyLib.applyPlan(plan_id, { applyDeletes: true, prunable: [], confirmDataDrops: false, confirmRenames: ["old_name"] });
|
|
ok("new_name table exists", !!Table.findOne({ name: "new_name" }));
|
|
if (db.isSQLite) {
|
|
// SQLite can't ALTER RENAME: safe fallback = new empty table, old retained.
|
|
ok("SQLite: rename fell back", res.renames.failed.some((r) => r.from === "old_name"));
|
|
ok("SQLite: old_name RETAINED (data preserved)", !!Table.findOne({ name: "old_name" }));
|
|
ok("SQLite: old_name row intact", (await Table.findOne({ name: "old_name" }).countRows()) === 1);
|
|
} else {
|
|
// Postgres: data-preserving Table.rename; old name gone, row moved.
|
|
ok("PG: rename applied", res.renames.renamed.some((r) => r.from === "old_name" && r.to === "new_name"));
|
|
ok("PG: old_name gone (renamed)", !Table.findOne({ name: "old_name" }));
|
|
ok("PG: row preserved under new_name", (await Table.findOne({ name: "new_name" }).countRows()) === 1);
|
|
}
|
|
});
|
|
|
|
section("Phase 5: apply form exposes data-drop controls");
|
|
await step("renderApplyForm requires the data-bearing table names to be TYPED", async () => {
|
|
const fakePlan = { destructive: [{ text: "drop table x", dataBearing: true, table: "x" }], entities: { tables: { rename: [] } } };
|
|
const html = planLib.renderApplyForm("pid", fakePlan, "<csrf>");
|
|
ok("form posts to apply", html.includes('action="/admin/site-sync/apply"'));
|
|
ok("prunable input present but NOT pre-filled", /name="prunable"/.test(html) && !/name="prunable"[^>]*value="x"/.test(html) && !/value="x"[^>]*name="prunable"/.test(html));
|
|
ok("confirmation checkbox present", html.includes('name="confirm_data"'));
|
|
});
|
|
};
|
|
|
|
|
|
const phase6 = async () => {
|
|
const rowsLib = require("../lib/rows");
|
|
const valuesOf = async (name, field) =>
|
|
(await Table.findOne({ name }).getRows({}, {})).map((r) => r[field]).sort();
|
|
|
|
section("Phase 6: data-mode storage + row-uuid schema");
|
|
await step("setDataMode(managed) registers _ss_row_uuid and backfills", async () => {
|
|
const t = await mkLiveTable("ref_a", ["val"]);
|
|
await t.insertRow({ val: "one" });
|
|
await rowsLib.setDataMode("ref_a", "managed");
|
|
const fresh = Table.findOne({ name: "ref_a" });
|
|
ok("_ss_row_uuid is a registered field", !!fresh.getField("_ss_row_uuid"));
|
|
const rs = await fresh.getRows({}, {});
|
|
ok("existing row backfilled with a uuid", !!(rs[0] && rs[0]._ss_row_uuid));
|
|
ok("dataModeOf reports managed", (await rowsLib.dataModeOf("ref_a")) === "managed");
|
|
});
|
|
await step("users cannot be set to managed", async () => {
|
|
ok("locked table refuses managed mode", await throwsAsync(() => rowsLib.setDataMode("users", "managed"), /locked/));
|
|
});
|
|
|
|
section("Phase 6: managed mirror (insert / update / delete by uuid)");
|
|
await step("target converges to the source's managed rows", async () => {
|
|
const t = await mkLiveTable("ref_colors", ["val"]);
|
|
const id1 = await t.insertRow({ val: "red" });
|
|
await t.insertRow({ val: "green" });
|
|
const id3 = await t.insertRow({ val: "blue" });
|
|
await rowsLib.setDataMode("ref_colors", "managed");
|
|
const { manifest } = await rowsLib.buildRowManifest();
|
|
ok("manifest has 3 managed rows", manifest.ref_colors && manifest.ref_colors.rows.length === 3);
|
|
|
|
// perturb the "target": lose blue, corrupt red, add a stray row
|
|
const tt = Table.findOne({ name: "ref_colors" });
|
|
await tt.deleteRows({ id: id3 });
|
|
await tt.updateRow({ val: "crimson" }, id1);
|
|
await tt.insertRow({ val: "stray" });
|
|
|
|
const res = await rowsLib.applyRowSync(manifest, {});
|
|
ok("converged to exactly the source rows", JSON.stringify(await valuesOf("ref_colors", "val")) === JSON.stringify(["blue", "green", "red"]));
|
|
ok("insert/update/delete all happened", res.perTable.ref_colors.inserted >= 1 && res.perTable.ref_colors.deleted >= 1);
|
|
});
|
|
|
|
section("Phase 6: starter seed-once");
|
|
await step("starter seeds missing rows, then is inert", async () => {
|
|
const t = await mkLiveTable("ref_seed", ["val"]);
|
|
const s1 = await t.insertRow({ val: "s1" });
|
|
await t.insertRow({ val: "s2" });
|
|
await rowsLib.setDataMode("ref_seed", "starter");
|
|
const { manifest } = await rowsLib.buildRowManifest();
|
|
// perturb: lose s1, edit s2, add stray
|
|
const tt = Table.findOne({ name: "ref_seed" });
|
|
await tt.deleteRows({ id: s1 });
|
|
await tt.insertRow({ val: "target_only" });
|
|
const res1 = await rowsLib.applyRowSync(manifest, {});
|
|
const vals = await valuesOf("ref_seed", "val");
|
|
ok("s1 re-seeded", vals.includes("s1"));
|
|
ok("target-only row NOT deleted (starter doesn't prune)", vals.includes("target_only"));
|
|
ok("starter marked shipped", res1.perTable.ref_seed.inserted >= 1);
|
|
// second apply: already shipped => no-op
|
|
const res2 = await rowsLib.applyRowSync(manifest, {});
|
|
ok("re-apply is a no-op (shipped)", res2.perTable.ref_seed.note === "starter already shipped");
|
|
});
|
|
|
|
section("Phase 6: foreign-key translation across managed tables");
|
|
await step("child FK resolves to the parent's local id on the target", async () => {
|
|
const cat = await mkLiveTable("ref_cat", ["cname"]);
|
|
await cat.insertRow({ cname: "fruit" });
|
|
await cat.insertRow({ cname: "veg" });
|
|
const item = await Table.create("ref_item", { min_role_read: 1, min_role_write: 1 });
|
|
await Field.create({ table: item, name: "iname", label: "iname", type: "String" });
|
|
await Field.create({ table: item, name: "cat", label: "cat", type: "Key to ref_cat", attributes: {}, reftable_name: "ref_cat" });
|
|
await rowsLib.setDataMode("ref_cat", "managed");
|
|
await rowsLib.setDataMode("ref_item", "managed");
|
|
const catFresh = Table.findOne({ name: "ref_cat" });
|
|
const fruitId = (await catFresh.getRows({ cname: "fruit" }, {}))[0].id;
|
|
const itemFresh = Table.findOne({ name: "ref_item" });
|
|
await itemFresh.insertRow({ iname: "apple", cat: fruitId });
|
|
const { manifest } = await rowsLib.buildRowManifest();
|
|
ok("child row FK carried as a uuid", manifest.ref_item.rows.some((r) => "cat__uuid" in r && r.cat__uuid));
|
|
|
|
// wipe all reference rows, then converge from the manifest
|
|
await clearRows("ref_item");
|
|
await clearRows("ref_cat");
|
|
await rowsLib.applyRowSync(manifest, {});
|
|
|
|
const newFruit = (await Table.findOne({ name: "ref_cat" }).getRows({ cname: "fruit" }, {}))[0];
|
|
const apple = (await Table.findOne({ name: "ref_item" }).getRows({ iname: "apple" }, {}))[0];
|
|
ok("apple re-created", !!apple);
|
|
ok("apple.cat resolves to the new fruit id", apple && newFruit && apple.cat === newFruit.id);
|
|
});
|
|
|
|
section("Phase 6: user data is protected");
|
|
await step("user-mode tables are never in the manifest", async () => {
|
|
const t = await mkLiveTable("user_notes", ["note"]);
|
|
await t.insertRow({ note: "private" });
|
|
const { manifest } = await rowsLib.buildRowManifest();
|
|
ok("user_notes excluded from manifest", !manifest.user_notes);
|
|
});
|
|
await step("applyRowSync skips a table the target has in user mode", async () => {
|
|
const hostile = { user_notes: { mode: "managed", rows: [{ __uuid: "x", note: "injected" }] } };
|
|
const res = await rowsLib.applyRowSync(hostile, {});
|
|
ok("skipped as not-managed on target", res.skipped.some((s) => s.name === "user_notes"));
|
|
ok("no injected row", (await Table.findOne({ name: "user_notes" }).countRows()) === 1);
|
|
});
|
|
await step("applyRowSync refuses row injection into users (locked)", async () => {
|
|
const before = await Table.findOne({ name: "users" }).countRows();
|
|
const hostile = { users: { mode: "managed", rows: [{ __uuid: "evil", email: "evil@x.com", role_id: 1 }] } };
|
|
const res = await rowsLib.applyRowSync(hostile, {});
|
|
ok("users skipped as locked", res.skipped.some((s) => s.name === "users"));
|
|
ok("users row count unchanged", (await Table.findOne({ name: "users" }).countRows()) === before);
|
|
});
|
|
};
|
|
|
|
|
|
const phase7 = async () => {
|
|
const planLib = require("../lib/plan");
|
|
const files = require("../lib/common/fileStore");
|
|
const myEnv = await env.getEnv();
|
|
|
|
section("Phase 7: hostile peer -- infra-table injection");
|
|
await step("a desired pack containing a plugin infra table is never applied", async () => {
|
|
const cur = await capture.buildDesiredState();
|
|
const desired = JSON.parse(JSON.stringify(cur.pack));
|
|
desired.tables.push({ name: "_dd_secret", min_role_read: 1, min_role_write: 1, versioned: false,
|
|
fields: [{ name: "id", type: "Integer", primary_key: true, required: false, is_unique: false, attributes: {} }], constraints: [] });
|
|
const { plan } = await planLib.buildPlan({ peerRow: null, env: myEnv, desired, desiredHash: "h", sourceEnvId: "src" });
|
|
ok("_dd_secret excluded from the plan (leading-underscore infra filter)", !plan.entities.tables.create.some((t) => t.name === "_dd_secret"));
|
|
});
|
|
|
|
section("Phase 7: hostile peer -- config key injection");
|
|
await step("only allow-listed config keys ever reach the plan", async () => {
|
|
const cur = await capture.buildDesiredState();
|
|
const desired = JSON.parse(JSON.stringify(cur.pack));
|
|
desired.config = { disable_csrf_routes: "/x", smtp_host: "evil", columns: "z", site_name: "ok", site_logo_id: 3 };
|
|
const { plan } = await planLib.buildPlan({ peerRow: null, env: myEnv, desired, desiredHash: "h", sourceEnvId: "src" });
|
|
const keys = Object.keys(plan.config.sets);
|
|
ok("no security config key present", !keys.some((k) => ["disable_csrf_routes", "smtp_host", "columns"].includes(k)), keys.join(","));
|
|
ok("allow-listed keys allowed", keys.includes("site_name"));
|
|
});
|
|
|
|
section("Phase 7: file-store containment");
|
|
await step("toAbsolutePath rejects traversal and absolute paths", async () => {
|
|
const throws = (rel) => {
|
|
try {
|
|
files.toAbsolutePath(db, rel);
|
|
return false;
|
|
} catch (e) {
|
|
return true;
|
|
}
|
|
};
|
|
ok("rejects ../ traversal", throws("../../../etc/passwd"));
|
|
ok("rejects a sibling tenant's store", throws(`../${db.getTenantSchema()}_other/x.png`));
|
|
ok("rejects absolute path", throws("/etc/passwd"));
|
|
ok("rejects the store root itself", throws(""));
|
|
ok("accepts an in-store relative path", !throws("sub/ok.png"));
|
|
});
|
|
|
|
section("Phase 7: push-apply gated behind on_apply=auto");
|
|
await step("api/apply refuses remote-triggered apply when on_apply=confirm", async () => {
|
|
await env.setEnvFields({ on_apply: "confirm" });
|
|
const peerEnvId = randomUuid();
|
|
const { secret } = await peers.addPeer({ envId: peerEnvId, label: "pusher", baseUrl: "http://localhost:3000" });
|
|
const req = mkPeerReq({ method: "POST", path: "/site-sync/api/apply", host: HOST, envId: peerEnvId, secret, body: { plan_id: "whatever" } });
|
|
const res = mkRes();
|
|
await routeOf(routes.routes, "post", "/site-sync/api/apply")(req, res);
|
|
ok("403 when confirmation required", res.statusCode === 403);
|
|
ok("error explains admin-UI requirement", /admin/i.test(JSON.stringify(res.body)));
|
|
});
|
|
await step("api/plan (compute-only) is allowed and returns a plan_id", async () => {
|
|
const peerEnvId = randomUuid();
|
|
const { secret } = await peers.addPeer({ envId: peerEnvId, label: "planner", baseUrl: "http://localhost:3000" });
|
|
const cur = await capture.buildDesiredState();
|
|
const req = mkPeerReq({ method: "POST", path: "/site-sync/api/plan", host: HOST, envId: peerEnvId, secret, body: { pack: cur.pack, hash: cur.hash, rows: {} } });
|
|
const res = mkRes();
|
|
await routeOf(routes.routes, "post", "/site-sync/api/plan")(req, res);
|
|
ok("plan computed for authenticated push", !!(res.body && res.body.plan_id));
|
|
ok("empty plan for identical state (round-trip normalized)", res.body.summary && res.body.summary.total === 0, JSON.stringify(res.body.summary));
|
|
});
|
|
};
|
|
|
|
|
|
const phase8 = async () => {
|
|
const diff = require("../lib/diff");
|
|
const planLib = require("../lib/plan");
|
|
const applyLib = require("../lib/apply");
|
|
const rowsLib = require("../lib/rows");
|
|
|
|
section("Phase 8 (regression): TOCTOU -- drop gate re-counts rows at apply time");
|
|
await step("rows inserted between preview and apply prevent a silent drop", async () => {
|
|
await mkLiveTable("toctou", ["v"]); // 0 rows at preview
|
|
const e = await env.getEnv();
|
|
const desired = await desiredWithout({ tables: ["toctou"] });
|
|
const { plan_id, plan } = await planLib.buildPlan({ peerRow: null, env: e, desired, desiredHash: "h", sourceEnvId: "src" });
|
|
const del = plan.entities.tables.delete.find((d) => d.name === "toctou");
|
|
ok("planned as non-data-bearing at preview", del && !del.dataBearing);
|
|
// rows arrive AFTER preview (metadata hash is unchanged, so precondition still passes)
|
|
await Table.findOne({ name: "toctou" }).insertRow({ v: "late" });
|
|
const res = await applyLib.applyPlan(plan_id, { applyDeletes: true, prunable: [], confirmDataDrops: false });
|
|
ok("table RETAINED (live recount caught the new rows)", !!Table.findOne({ name: "toctou" }));
|
|
ok("drop skipped, not silently applied", res.deletes.skipped.some((s) => s.name === "toctou"));
|
|
});
|
|
|
|
section("Phase 8 (regression): users table schema is hard-locked on the upsert half");
|
|
await step("a hostile users table spec never reaches install_pack", async () => {
|
|
const cur = await capture.buildDesiredState();
|
|
ok("users excluded from the captured pack", !cur.pack.tables.some((t) => t.name === "users"));
|
|
const before = Table.findOne({ name: "users" }).min_role_write;
|
|
const desired = JSON.parse(JSON.stringify(cur.pack));
|
|
desired.tables.push({ name: "users", min_role_read: 100, min_role_write: 100, ownership_formula: "true", versioned: false,
|
|
fields: [{ name: "backdoor", type: "String", required: false, is_unique: false, primary_key: false, attributes: {} }], constraints: [] });
|
|
const e = await env.getEnv();
|
|
const { plan_id, plan } = await planLib.buildPlan({ peerRow: null, env: e, desired, desiredHash: "h", sourceEnvId: "src" });
|
|
ok("users not in the plan", !plan.entities.tables.create.some((t) => t.name === "users") && !plan.entities.tables.update.some((u) => u.name === "users"));
|
|
await applyLib.applyPlan(plan_id, { applyDeletes: true });
|
|
const after = Table.findOne({ name: "users" });
|
|
ok("users min_role_write unchanged", after.min_role_write === before, `${after.min_role_write} vs ${before}`);
|
|
ok("no backdoor field added to users", !after.getField("backdoor"));
|
|
});
|
|
|
|
section("Phase 8 (regression): required-FK child rows are inserted, not dropped");
|
|
await step("insert fixpoint handles a required cross-table FK", async () => {
|
|
const parent = await mkLiveTable("fk_parent", ["pname"]);
|
|
await parent.insertRow({ pname: "P1" });
|
|
const child = await Table.create("fk_child", { min_role_read: 1, min_role_write: 1 });
|
|
await Field.create({ table: child, name: "cname", label: "cname", type: "String" });
|
|
await Field.create({ table: child, name: "parent", label: "parent", type: "Key to fk_parent", required: true, reftable_name: "fk_parent", attributes: {} });
|
|
await rowsLib.setDataMode("fk_parent", "managed");
|
|
await rowsLib.setDataMode("fk_child", "managed");
|
|
const pid = (await Table.findOne({ name: "fk_parent" }).getRows({}, {}))[0].id;
|
|
await Table.findOne({ name: "fk_child" }).insertRow({ cname: "C1", parent: pid });
|
|
const { manifest } = await rowsLib.buildRowManifest();
|
|
await clearRows("fk_child");
|
|
await clearRows("fk_parent");
|
|
await rowsLib.applyRowSync(manifest, {});
|
|
ok("child re-inserted despite required FK", (await Table.findOne({ name: "fk_child" }).countRows()) === 1);
|
|
const ch = (await Table.findOne({ name: "fk_child" }).getRows({}, {}))[0];
|
|
const par = (await Table.findOne({ name: "fk_parent" }).getRows({}, {}))[0];
|
|
ok("child FK resolves to the parent", ch.parent === par.id);
|
|
});
|
|
|
|
section("Phase 8 (regression): remote apply cannot self-confirm data drops, nor apply another peer's plan");
|
|
await step("sender-supplied confirm_data is ignored on api/apply", async () => {
|
|
await env.setEnvFields({ on_apply: "auto", on_delete: "confirm", sync_mode: "mirror" });
|
|
const t = await mkLiveTable("remote_drop", ["v"]);
|
|
await t.insertRow({ v: "data" });
|
|
const peerEnvId = randomUuid();
|
|
const { secret } = await peers.addPeer({ envId: peerEnvId, label: "rp", baseUrl: "http://localhost:3000" });
|
|
const cur = await capture.buildDesiredState();
|
|
const desired = JSON.parse(JSON.stringify(cur.pack));
|
|
desired.tables = desired.tables.filter((x) => x.name !== "remote_drop");
|
|
let req = mkPeerReq({ method: "POST", path: "/site-sync/api/plan", host: HOST, envId: peerEnvId, secret, body: { pack: desired, hash: "h", rows: {} } });
|
|
let res = mkRes();
|
|
await routeOf(routes.routes, "post", "/site-sync/api/plan")(req, res);
|
|
const planId = res.body.plan_id;
|
|
req = mkPeerReq({ method: "POST", path: "/site-sync/api/apply", host: HOST, envId: peerEnvId, secret, body: { plan_id: planId, prunable: ["remote_drop"], confirm_data: true } });
|
|
res = mkRes();
|
|
await routeOf(routes.routes, "post", "/site-sync/api/apply")(req, res);
|
|
// The apply itself must have SUCCEEDED -- a failed apply would also leave the
|
|
// table in place and make the retention check meaningless.
|
|
ok("remote apply succeeded (200, ok)", res.statusCode === 200 && !!(res.body && res.body.ok), `status=${res.statusCode} ${JSON.stringify(res.body).slice(0, 200)}`);
|
|
ok("drop skipped by the gate, not by an error", !!(res.body && res.body.result && res.body.result.deletes.skipped.some((s) => s.name === "remote_drop")));
|
|
ok("data-bearing table RETAINED (sender cannot self-confirm)", !!Table.findOne({ name: "remote_drop" }));
|
|
await env.setEnvFields({ on_delete: "confirm" });
|
|
});
|
|
await step("a peer cannot apply another peer's plan_id", async () => {
|
|
const aEnv = randomUuid();
|
|
const bEnv = randomUuid();
|
|
const { secret: aSec } = await peers.addPeer({ envId: aEnv, label: "A", baseUrl: "http://localhost:3000" });
|
|
const { secret: bSec } = await peers.addPeer({ envId: bEnv, label: "B", baseUrl: "http://localhost:3000" });
|
|
const cur = await capture.buildDesiredState();
|
|
let req = mkPeerReq({ method: "POST", path: "/site-sync/api/plan", host: HOST, envId: bEnv, secret: bSec, body: { pack: cur.pack, hash: cur.hash, rows: {} } });
|
|
let res = mkRes();
|
|
await routeOf(routes.routes, "post", "/site-sync/api/plan")(req, res);
|
|
const bPlan = res.body.plan_id;
|
|
req = mkPeerReq({ method: "POST", path: "/site-sync/api/apply", host: HOST, envId: aEnv, secret: aSec, body: { plan_id: bPlan } });
|
|
res = mkRes();
|
|
await routeOf(routes.routes, "post", "/site-sync/api/apply")(req, res);
|
|
ok("404 -- A cannot apply B's plan", res.statusCode === 404);
|
|
await env.setEnvFields({ on_apply: "confirm" });
|
|
});
|
|
|
|
section("Phase 8 (regression): diff/hash normalization");
|
|
await step("root_page_for_roles is not a design diff", async () => {
|
|
const d = emptyPack(); d.pages = [{ name: "home", title: "H", layout: {}, root_page_for_roles: ["public"] }];
|
|
const c = emptyPack(); c.pages = [{ name: "home", title: "H", layout: {}, root_page_for_roles: [] }];
|
|
ok("home page not flagged changed", diff.isEmptyPlan(diff.computePlan(d, c, { rowCounts: {} })));
|
|
});
|
|
await step("hashPack is order-insensitive", async () => {
|
|
const a = emptyPack(); a.roles = [{ id: 1, role: "admin" }, { id: 100, role: "public" }]; a.tables = [mkTable("x", [mkField("a")]), mkTable("y", [mkField("b")])];
|
|
const b = emptyPack(); b.roles = [{ id: 100, role: "public" }, { id: 1, role: "admin" }]; b.tables = [mkTable("y", [mkField("b")]), mkTable("x", [mkField("a")])];
|
|
ok("permuted packs hash equal", capture.hashPack(a) === capture.hashPack(b));
|
|
});
|
|
};
|
|
|
|
|
|
const phase9 = async () => {
|
|
const rowsLib = require("../lib/rows");
|
|
const File = require("@saltcorn/data/models/file");
|
|
const { sha256Hex } = require("../lib/common/hash");
|
|
const CONTENT = Buffer.from("hello world file content");
|
|
const HASH = sha256Hex(CONTENT);
|
|
|
|
section("Phase 9: file-content transfer (managed rows)");
|
|
await step("File field produces a content-hash placeholder", async () => {
|
|
const docs = await Table.create("docs", { min_role_read: 1, min_role_write: 1 });
|
|
await Field.create({ table: docs, name: "title", label: "title", type: "String" });
|
|
await Field.create({ table: docs, name: "attachment", label: "attachment", type: "File" });
|
|
const f = await File.from_contents("hello.txt", "text/plain", CONTENT, 1, 1, "/");
|
|
const fresh = Table.findOne({ name: "docs" });
|
|
await fresh.insertRow({ title: "doc1", attachment: f.field_value });
|
|
await rowsLib.setDataMode("docs", "managed");
|
|
const { manifest } = await rowsLib.buildRowManifest();
|
|
const row = manifest.docs.rows[0];
|
|
ok("placeholder carries the content hash", row.attachment__file && row.attachment__file.hash === HASH);
|
|
ok("raw file path not shipped", !("attachment" in row));
|
|
ok("filename + mime carried", /^hello/.test(row.attachment__file.filename) && /text/.test(row.attachment__file.mime));
|
|
});
|
|
|
|
let goodManifest;
|
|
await step("apply fetches bytes by hash and materializes the file", async () => {
|
|
const { manifest } = await rowsLib.buildRowManifest();
|
|
goodManifest = manifest;
|
|
// simulate the target losing the row; a content-addressed fetcher supplies bytes
|
|
await clearRows("docs");
|
|
const fetcher = async (hash) => (hash === HASH ? CONTENT : Buffer.from("?"));
|
|
const res = await rowsLib.applyRowSync(manifest, { fileFetcher: fetcher });
|
|
ok("row re-inserted", res.perTable.docs.inserted === 1);
|
|
const doc = (await Table.findOne({ name: "docs" }).getRows({}, {}))[0];
|
|
ok("attachment field set to a local file", !!(doc && doc.attachment));
|
|
const local = await File.findOne(doc.attachment);
|
|
const bytes = local && await local.get_contents();
|
|
ok("materialized content matches source", !!bytes && Buffer.compare(bytes, CONTENT) === 0);
|
|
});
|
|
|
|
await step("re-apply is idempotent (unchanged file not re-fetched)", async () => {
|
|
let fetches = 0;
|
|
const fetcher = async (hash) => { fetches += 1; return hash === HASH ? CONTENT : Buffer.from("?"); };
|
|
await rowsLib.applyRowSync(goodManifest, { fileFetcher: fetcher });
|
|
ok("no fetch for an unchanged managed file", fetches === 0, `fetches=${fetches}`);
|
|
const doc = (await Table.findOne({ name: "docs" }).getRows({}, {}))[0];
|
|
const local = await File.findOne(doc.attachment);
|
|
ok("content still correct", Buffer.compare(await local.get_contents(), CONTENT) === 0);
|
|
});
|
|
|
|
await step("content-hash integrity: tampered bytes are refused", async () => {
|
|
await clearRows("docs");
|
|
const tamperer = async () => Buffer.from("tampered payload");
|
|
const res = await rowsLib.applyRowSync(goodManifest, { fileFetcher: tamperer });
|
|
const doc = (await Table.findOne({ name: "docs" }).getRows({}, {}))[0];
|
|
ok("attachment left empty on hash mismatch", !!doc && !doc.attachment);
|
|
ok("mismatch reported", res.warnings.some((w) => /hash mismatch/.test(w)));
|
|
});
|
|
|
|
await step("resolveFileByHash finds a synced file by content hash", async () => {
|
|
// restore a good file so the source index can resolve it
|
|
await rowsLib.applyRowSync(goodManifest, { fileFetcher: async () => CONTENT });
|
|
const fv = await rowsLib.resolveFileByHash(HASH);
|
|
ok("hash resolves to a local file value", !!fv);
|
|
const unknown = await rowsLib.resolveFileByHash("f".repeat(64));
|
|
ok("unknown hash resolves to null", unknown === null);
|
|
});
|
|
};
|
|
|
|
|
|
const phase10 = async () => {
|
|
const planLib = require("../lib/plan");
|
|
const applyLib = require("../lib/apply");
|
|
const rollback = require("../lib/rollback");
|
|
const rowsLib = require("../lib/rows");
|
|
const File = require("@saltcorn/data/models/file");
|
|
const myEnv = () => env.getEnv();
|
|
|
|
section("Phase 10: rollback -- metadata + config");
|
|
await step("apply captures a restore point; rollback reverts schema + config", async () => {
|
|
const prevSiteName = getState().getConfig("site_name") || "";
|
|
// build a real desired pack that adds a table + changes config
|
|
const rb = await Table.create("rbtable", { min_role_read: 1, min_role_write: 1 });
|
|
await Field.create({ table: rb, name: "note", label: "note", type: "String" });
|
|
await getState().setConfig("site_name", "RolledChange");
|
|
const cap = await capture.buildDesiredState();
|
|
// revert live to the pre-apply state
|
|
await Table.findOne({ name: "rbtable" }).delete();
|
|
await getState().setConfig("site_name", prevSiteName);
|
|
|
|
const { plan_id } = await planLib.buildPlan({ peerRow: null, env: await myEnv(), desired: cap.pack, desiredHash: cap.hash, sourceEnvId: "src" });
|
|
await applyLib.applyPlan(plan_id, { applyDeletes: true });
|
|
ok("rbtable created by apply", !!Table.findOne({ name: "rbtable" }));
|
|
ok("config changed by apply", getState().getConfig("site_name") === "RolledChange");
|
|
ok("restore point recorded", await rollback.hasRestorePoint(plan_id));
|
|
|
|
await rollback.rollbackApply(plan_id, {});
|
|
ok("rbtable removed by rollback", !Table.findOne({ name: "rbtable" }));
|
|
ok("config reverted by rollback", getState().getConfig("site_name") === prevSiteName, getState().getConfig("site_name"));
|
|
});
|
|
|
|
section("Phase 10: rollback -- managed rows");
|
|
await step("rollback restores changed/deleted/inserted managed rows", async () => {
|
|
const t = await mkLiveTable("rbrows", ["v"]);
|
|
const id1 = await t.insertRow({ v: "orig1" });
|
|
await t.insertRow({ v: "keep2" });
|
|
await rowsLib.setDataMode("rbrows", "managed");
|
|
// craft the "source" manifest: change row1, add row3, drop row2
|
|
const tt = Table.findOne({ name: "rbrows" });
|
|
await tt.updateRow({ v: "changed1" }, id1);
|
|
await tt.insertRow({ v: "new3" });
|
|
const row2 = (await tt.getRows({ v: "keep2" }, {}))[0];
|
|
await tt.deleteRows({ id: row2.id });
|
|
const { manifest } = await rowsLib.buildRowManifest();
|
|
// revert live to pre-apply {orig1, keep2}
|
|
await tt.updateRow({ v: "orig1" }, id1);
|
|
await tt.insertRow({ v: "keep2" });
|
|
await clearRows("rbrows", { v: "new3" });
|
|
|
|
const desired = await capture.buildDesiredState();
|
|
const { plan_id } = await planLib.buildPlan({ peerRow: null, env: await myEnv(), desired: desired.pack, desiredHash: desired.hash, sourceEnvId: "src", sourceRows: manifest });
|
|
await applyLib.applyPlan(plan_id, { applyDeletes: true });
|
|
const afterApply = (await Table.findOne({ name: "rbrows" }).getRows({}, {})).map((r) => r.v).sort();
|
|
ok("apply changed the managed rows", JSON.stringify(afterApply) === JSON.stringify(["changed1", "new3"]), afterApply.join(","));
|
|
|
|
await rollback.rollbackApply(plan_id, {});
|
|
const afterRb = (await Table.findOne({ name: "rbrows" }).getRows({}, {})).map((r) => r.v).sort();
|
|
ok("rollback restored the pre-apply rows", JSON.stringify(afterRb) === JSON.stringify(["keep2", "orig1"]), afterRb.join(","));
|
|
});
|
|
|
|
section("Phase 10: rollback -- file reference");
|
|
await step("rollback restores the pre-apply file reference", async () => {
|
|
const A = Buffer.from("original file A");
|
|
const B = Buffer.from("apply-changed file B");
|
|
const HB = require("../lib/common/hash").sha256Hex(B);
|
|
const docs = await Table.create("rbdocs", { min_role_read: 1, min_role_write: 1 });
|
|
await Field.create({ table: docs, name: "attachment", label: "attachment", type: "File" });
|
|
const fileA = await File.from_contents("a.txt", "text/plain", A, 1, 1, "/");
|
|
const rowId1 = await Table.findOne({ name: "rbdocs" }).insertRow({ attachment: fileA.field_value });
|
|
await rowsLib.setDataMode("rbdocs", "managed");
|
|
// craft source manifest referencing file B
|
|
const fileB = await File.from_contents("b.txt", "text/plain", B, 1, 1, "/");
|
|
await Table.findOne({ name: "rbdocs" }).updateRow({ attachment: fileB.field_value }, rowId1);
|
|
const { manifest } = await rowsLib.buildRowManifest();
|
|
// revert live row to file A
|
|
await Table.findOne({ name: "rbdocs" }).updateRow({ attachment: fileA.field_value }, rowId1);
|
|
|
|
const desired = await capture.buildDesiredState();
|
|
const { plan_id } = await planLib.buildPlan({ peerRow: null, env: await myEnv(), desired: desired.pack, desiredHash: desired.hash, sourceEnvId: "src", sourceRows: manifest });
|
|
await applyLib.applyPlan(plan_id, { applyDeletes: true, fileFetcher: async (h) => (h === HB ? B : Buffer.from("?")) });
|
|
let doc = (await Table.findOne({ name: "rbdocs" }).getRows({}, {}))[0];
|
|
let bytes = await (await File.findOne(doc.attachment)).get_contents();
|
|
ok("apply switched the file to B", Buffer.compare(bytes, B) === 0);
|
|
|
|
await rollback.rollbackApply(plan_id, {});
|
|
doc = (await Table.findOne({ name: "rbdocs" }).getRows({}, {}))[0];
|
|
bytes = await (await File.findOne(doc.attachment)).get_contents();
|
|
ok("rollback restored the original file A reference", Buffer.compare(bytes, A) === 0);
|
|
});
|
|
};
|
|
|
|
|
|
const phase11 = async () => {
|
|
const planLib = require("../lib/plan");
|
|
const applyLib = require("../lib/apply");
|
|
const rollback = require("../lib/rollback");
|
|
const rowsLib = require("../lib/rows");
|
|
const layoutFiles = require("../lib/layoutFiles");
|
|
const File = require("@saltcorn/data/models/file");
|
|
const Page = require("@saltcorn/data/models/page");
|
|
const { sha256Hex } = require("../lib/common/hash");
|
|
const myEnv = () => env.getEnv();
|
|
|
|
// a real 1x1 PNG (content-sniffing rejects non-images); vary the trailing bytes
|
|
// to get distinct-content images that still carry the PNG magic.
|
|
const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64");
|
|
const A = Buffer.concat([PNG, Buffer.from("brand")]);
|
|
const HA = sha256Hex(A);
|
|
|
|
section("Phase 11: layout/logo file refs -> placeholders on capture");
|
|
await step("layout image + site logo become content-hash placeholders", async () => {
|
|
const fileA = await File.from_contents("logo.png", "image/png", A, 1, 100, "/");
|
|
await Page.create({ name: "brand", title: "Brand", description: "", min_role: 1,
|
|
layout: { above: [{ type: "image", fileid: fileA.field_value, srcType: "File" }] }, fixed_states: {} });
|
|
await getState().setConfig("site_logo_id", fileA.field_value);
|
|
const cap = await capture.buildDesiredState();
|
|
const brand = cap.pack.pages.find((p) => p.name === "brand");
|
|
const ph = brand.layout.above[0].fileid;
|
|
ok("layout image is a placeholder", layoutFiles.isPlaceholder(ph));
|
|
ok("placeholder hash matches the file content", layoutFiles.parsePlaceholder(ph).hash === HA);
|
|
ok("site_logo_id is a placeholder", layoutFiles.isPlaceholder(cap.pack.config.site_logo_id));
|
|
});
|
|
await step("layout/logo files are indexed for serving (by content hash)", async () => {
|
|
const map = new Map();
|
|
await layoutFiles.indexLocalFiles(map);
|
|
ok("brand logo indexed by hash", map.has(HA));
|
|
});
|
|
|
|
section("Phase 11: apply materializes layout + logo files");
|
|
let brandDesired;
|
|
let brandHash;
|
|
await step("a fresh target fetches the bytes and resolves placeholders to local files", async () => {
|
|
const cap = await capture.buildDesiredState();
|
|
brandDesired = cap.pack;
|
|
brandHash = cap.hash;
|
|
// "fresh target": unreference file A so it isn't deduped -> forces a fetch
|
|
await Page.findOne({ name: "brand" }).delete();
|
|
await getState().setConfig("site_logo_id", "");
|
|
const { plan_id } = await planLib.buildPlan({ peerRow: null, env: await myEnv(), desired: brandDesired, desiredHash: brandHash, sourceEnvId: "src" });
|
|
const res = await applyLib.applyPlan(plan_id, { applyDeletes: true, fileFetcher: async (h) => (h === HA ? A : Buffer.from("?")) });
|
|
const brand = Page.findOne({ name: "brand" });
|
|
ok("brand page re-created", !!brand);
|
|
const fid = brand.layout.above[0].fileid;
|
|
ok("layout image resolved to a local file (not a placeholder)", !layoutFiles.isPlaceholder(fid) && !!fid);
|
|
ok("layout image content matches source", Buffer.compare(await (await File.findOne(fid)).get_contents(), A) === 0);
|
|
const logoVal = getState().getConfig("site_logo_id");
|
|
ok("logo resolved to a local file", !layoutFiles.isPlaceholder(logoVal) && !!logoVal);
|
|
ok("logo content matches source", Buffer.compare(await (await File.findOne(logoVal)).get_contents(), A) === 0);
|
|
ok("no file warnings", !res.fileWarnings || res.fileWarnings.length === 0, JSON.stringify(res.fileWarnings));
|
|
});
|
|
await step("re-sync of the converged brand page is a no-op (content-stable placeholder)", async () => {
|
|
const { plan } = await planLib.buildPlan({ peerRow: null, env: await myEnv(), desired: brandDesired, desiredHash: brandHash, sourceEnvId: "src" });
|
|
const pageChange = plan.entities.pages.create.concat(plan.entities.pages.update).some((p) => (p.name || (p.to && p.to.name)) === "brand");
|
|
ok("brand page not re-flagged as changed", !pageChange);
|
|
});
|
|
|
|
section("Phase 11: content-sniffed image gate + cross-path guard");
|
|
await step("non-image content is refused and the reference is removed", async () => {
|
|
const evil = Buffer.from("<script>alert(1)</script> definitely not an image");
|
|
const evilHash = sha256Hex(evil);
|
|
const pack = { pages: [{ name: "x", layout: { above: [{ type: "image", fileid: layoutFiles.makePlaceholder(evilHash) }] } }], views: [], config: {} };
|
|
const warnings = [];
|
|
const res = await layoutFiles.resolvePlaceholders(pack, { fileFetcher: async (h) => (h === evilHash ? evil : Buffer.from("?")), warnings });
|
|
ok("skipped as non-image (content-sniffed)", res.skipped >= 1 && warnings.some((w) => /non-image/.test(w)));
|
|
ok("fileid removed (not persisted as a placeholder)", pack.pages[0].layout.above[0].fileid === "");
|
|
});
|
|
await step("a hash that exists only as a ROW file is not laundered into a layout", async () => {
|
|
const html = Buffer.from("<html><script>x</script></html>");
|
|
const hHtml = sha256Hex(html);
|
|
const t = await Table.create("xpath", { min_role_read: 1, min_role_write: 1 });
|
|
await Field.create({ table: t, name: "f", label: "f", type: "File" });
|
|
const rf = await File.from_contents("evil.html", "text/html", html, 1, 1, "/");
|
|
await Table.findOne({ name: "xpath" }).insertRow({ f: rf.field_value });
|
|
await rowsLib.setDataMode("xpath", "managed"); // now the html file is in the ROW index
|
|
const pack = { pages: [{ name: "y", layout: { above: [{ type: "image", fileid: layoutFiles.makePlaceholder(hHtml) }] } }], views: [], config: {} };
|
|
const res = await layoutFiles.resolvePlaceholders(pack, { fileFetcher: async (h) => (h === hHtml ? html : Buffer.from("?")), warnings: [] });
|
|
ok("row file NOT deduped into the layout (layout-only dedup)", res.deduped === 0);
|
|
ok("and the fetched non-image is removed", pack.pages[0].layout.above[0].fileid === "");
|
|
});
|
|
|
|
section("Phase 11: rollback restores the pre-apply logo file");
|
|
await step("apply changes the logo, rollback restores the original", async () => {
|
|
const A2 = Buffer.concat([PNG, Buffer.from("rbA")]);
|
|
const B2 = Buffer.concat([PNG, Buffer.from("rbB")]);
|
|
const HB2 = sha256Hex(B2);
|
|
const fA = await File.from_contents("la.png", "image/png", A2, 1, 100, "/");
|
|
await getState().setConfig("site_logo_id", fA.field_value); // pre-apply logo = A2
|
|
const fB = await File.from_contents("lb.png", "image/png", B2, 1, 100, "/");
|
|
await getState().setConfig("site_logo_id", fB.field_value);
|
|
const capB = await capture.buildDesiredState(); // desired logo = B2 placeholder
|
|
await getState().setConfig("site_logo_id", fA.field_value); // revert live to A2
|
|
const { plan_id } = await planLib.buildPlan({ peerRow: null, env: await myEnv(), desired: capB.pack, desiredHash: capB.hash, sourceEnvId: "src" });
|
|
await applyLib.applyPlan(plan_id, { applyDeletes: true, fileFetcher: async (h) => (h === HB2 ? B2 : Buffer.from("?")) });
|
|
const afterApply = getState().getConfig("site_logo_id");
|
|
ok("apply switched the logo to B", Buffer.compare(await (await File.findOne(afterApply)).get_contents(), B2) === 0);
|
|
await rollback.rollbackApply(plan_id, {});
|
|
const afterRb = getState().getConfig("site_logo_id");
|
|
ok("rollback restored the original logo A", Buffer.compare(await (await File.findOne(afterRb)).get_contents(), A2) === 0);
|
|
});
|
|
};
|
|
|
|
|
|
// Start an HTTP server on an ephemeral loopback port; resolves to { server, base }.
|
|
const listenLocal = (server) => new Promise((resolve) => {
|
|
server.listen(0, "127.0.0.1", () => resolve({ server, base: `http://127.0.0.1:${server.address().port}` }));
|
|
});
|
|
|
|
|
|
// Signed request over real HTTP with an explicit Host / extra headers (fetch cannot
|
|
// set Host). Resolves to { status, headers, body }.
|
|
const rawSignedGet = ({ base, path, envId, secret, signedHost, hostHeader, extraHeaders }) => new Promise((resolve, reject) => {
|
|
const http = require("http");
|
|
const { HEADERS } = require("../lib/constants");
|
|
const timestamp = String(Date.now());
|
|
const nonce = protocol.randomNonce().toString("hex");
|
|
const canonical = protocol.buildCanonical({ timestamp, nonce, method: "GET", path, targetHost: protocol.normalizeHost(signedHost), body: "" });
|
|
const url = new URL(base);
|
|
const headers = Object.assign({
|
|
Host: hostHeader,
|
|
[HEADERS.ENV_ID]: envId,
|
|
[HEADERS.TIMESTAMP]: timestamp,
|
|
[HEADERS.NONCE]: nonce,
|
|
[HEADERS.SIGNATURE]: protocol.sign(secret, canonical)
|
|
}, extraHeaders || {});
|
|
const r = http.request({ host: url.hostname, port: url.port, method: "GET", path, headers }, (resp) => {
|
|
const chunks = [];
|
|
resp.on("data", (c) => chunks.push(c));
|
|
resp.on("end", () => resolve({ status: resp.statusCode, headers: resp.headers, body: Buffer.concat(chunks), nonce }));
|
|
});
|
|
r.on("error", reject);
|
|
r.end();
|
|
});
|
|
|
|
|
|
const phase12 = async () => {
|
|
const http = require("http");
|
|
const express = require("express");
|
|
const transport = require("../lib/transport");
|
|
const planLib = require("../lib/plan");
|
|
const applyLib = require("../lib/apply");
|
|
const rollback = require("../lib/rollback");
|
|
const rowsLib = require("../lib/rows");
|
|
const { HEADERS } = require("../lib/constants");
|
|
const { API_PREFIX, API_PATHS, peerJson, peerBytes, signedRequest, PEER_LIMITS } = transport;
|
|
const { sha256Hex } = require("../lib/common/hash");
|
|
const myEnv = await env.getEnv();
|
|
// phase 9's managed docs file
|
|
const FILE_HASH = sha256Hex(Buffer.from("hello world file content"));
|
|
// The HTML a mock sendWrap received, with JSON quote-escaping undone.
|
|
const wrapHtml = (res) => JSON.stringify(res._wrap || {}).replace(/\\"/g, "\"");
|
|
|
|
section("Phase 12: signed peer API over REAL HTTP (express + the plugin's own API routes)");
|
|
const app = express();
|
|
for (const r of routes.routes.filter((x) => x.url.startsWith(API_PREFIX))) {
|
|
app[r.method](r.url, r.callback);
|
|
}
|
|
const { server, base } = await listenLocal(http.createServer(app));
|
|
// Self-pairing: the "peer" is this very instance, so the responder's env_id is ours.
|
|
const { peer: self, secret: selfSecret } = await peers.addPeer({ envId: myEnv.env_id, label: "self", baseUrl: base });
|
|
try {
|
|
await step("signed health/state/file round-trip verifies end to end", async () => {
|
|
const health = await peerJson(self, myEnv.env_id, "GET", API_PATHS.HEALTH, null);
|
|
ok("health over HTTP names this env", health.env_id === myEnv.env_id);
|
|
const state = await ops.fetchPeerState(self, myEnv);
|
|
ok("state over HTTP returns a pack", Array.isArray(state.pack.tables));
|
|
const bytes = await peerBytes(self, myEnv.env_id, API_PATHS.file(FILE_HASH));
|
|
ok("file over HTTP returns the verified bytes", sha256Hex(bytes) === FILE_HASH);
|
|
let msg = "";
|
|
try {
|
|
await peerBytes(self, myEnv.env_id, API_PATHS.file("e".repeat(64)));
|
|
} catch (e) {
|
|
msg = e.message;
|
|
}
|
|
ok("unknown file hash -> signed 404 surfaced as an error", /status 404/.test(msg), msg);
|
|
});
|
|
await step("real Express: X-Forwarded-Host spoof does not rebind the signed host", async () => {
|
|
const realHost = new URL(base).host;
|
|
const good = await rawSignedGet({ base, path: API_PATHS.HEALTH, envId: myEnv.env_id, secret: selfSecret, signedHost: realHost, hostHeader: realHost });
|
|
ok("control: correct Host -> 200", good.status === 200, `status=${good.status}`);
|
|
const expected = protocol.buildResponseCanonical({ nonce: good.nonce, status: good.status, body: good.body });
|
|
ok("control: response signature verifies", protocol.verifySignature(selfSecret, expected, good.headers[HEADERS.RESPONSE_SIGNATURE.toLowerCase()]));
|
|
const spoof = await rawSignedGet({ base, path: API_PATHS.HEALTH, envId: myEnv.env_id, secret: selfSecret, signedHost: realHost,
|
|
hostHeader: "t2.example.com", extraHeaders: { "X-Forwarded-Host": realHost } });
|
|
ok("Host=t2 + XFH=<signed host> -> 401", spoof.status === 401, `status=${spoof.status}`);
|
|
});
|
|
await step("admin preview POST pulls over HTTP and persists exactly one plan; GET persists none", async () => {
|
|
const count = async () => Number((await db.query(`SELECT COUNT(*) AS c FROM ${db.getTenantSchemaPrefix()}_ss_plans`)).rows[0].c);
|
|
const n0 = await count();
|
|
let res = mkRes();
|
|
await routeOf(routes.routes, "get", "/admin/site-sync/plan")(mkAdminReq({ originalUrl: "/admin/site-sync/plan" }), res);
|
|
ok("GET plan renders a POST selector", /method="post"/i.test(wrapHtml(res)) && (await count()) === n0);
|
|
res = mkRes();
|
|
const req = mkAdminReq({ method: "POST", originalUrl: "/admin/site-sync/plan", body: { peer: String(self.peer_id) } });
|
|
await routeOf(routes.routes, "post", "/admin/site-sync/plan")(req, res);
|
|
ok("POST plan rendered a preview", !!res._wrap && !res._redirect, JSON.stringify(req._flash));
|
|
ok("POST plan persisted one plan", (await count()) === n0 + 1);
|
|
res = mkRes();
|
|
const bad = mkAdminReq({ method: "POST", body: { peer: "999999" } });
|
|
await routeOf(routes.routes, "post", "/admin/site-sync/plan")(bad, res);
|
|
ok("unknown peer -> flashed error + redirect", res._redirect === "/admin/site-sync/plan" && bad._flash.some((f) => f.type === "danger"));
|
|
});
|
|
} finally {
|
|
await peers.deletePeer(self.peer_id);
|
|
server.close();
|
|
}
|
|
|
|
section("Phase 12: the client rejects unauthenticated / tampered responses");
|
|
const tamperEnv = randomUuid();
|
|
let mode = "good";
|
|
const tamper = http.createServer((req, res) => {
|
|
const nonce = req.headers[HEADERS.NONCE.toLowerCase()];
|
|
const genuine = Buffer.from(JSON.stringify({ env_id: mode === "wrongEnv" ? randomUuid() : tamperEnv, plugins: [] }));
|
|
const signFor = mode === "replayed" ? protocol.randomNonce().toString("hex") : nonce;
|
|
const sig = protocol.sign(tamperSecret, protocol.buildResponseCanonical({ nonce: signFor, status: 200, body: genuine }));
|
|
if (mode === "hang") {
|
|
return;
|
|
}
|
|
res.setHeader("content-type", "application/json");
|
|
if (mode === "unsigned401") {
|
|
res.statusCode = 401;
|
|
res.end(JSON.stringify({ error: "bad signature" }));
|
|
return;
|
|
}
|
|
if (mode !== "missing") {
|
|
res.setHeader(HEADERS.RESPONSE_SIGNATURE, sig);
|
|
}
|
|
res.end(mode === "tamper" ? Buffer.from(JSON.stringify({ env_id: tamperEnv, plugins: [{ name: "evil" }] })) : genuine);
|
|
});
|
|
const { base: tamperBase } = await listenLocal(tamper);
|
|
const { peer: tamperPeer, secret: tamperSecret } = await peers.addPeer({ envId: tamperEnv, label: "tamper", baseUrl: tamperBase });
|
|
const attempt = async () => {
|
|
try {
|
|
await peerJson(tamperPeer, myEnv.env_id, "GET", API_PATHS.HEALTH, null);
|
|
return "accepted";
|
|
} catch (e) {
|
|
return e.message;
|
|
}
|
|
};
|
|
try {
|
|
await step("signed responses: good accepted; tampered/missing/replayed/wrong-env rejected", async () => {
|
|
mode = "good";
|
|
ok("correctly signed response accepted", (await attempt()) === "accepted");
|
|
mode = "tamper";
|
|
ok("tampered body rejected", /without a valid response signature/.test(await attempt()));
|
|
mode = "missing";
|
|
ok("missing signature rejected", /without a valid response signature/.test(await attempt()));
|
|
mode = "replayed";
|
|
ok("signature for another request's nonce rejected", /without a valid response signature/.test(await attempt()));
|
|
mode = "wrongEnv";
|
|
ok("validly signed answer from another env_id rejected", /answered as env_id/.test(await attempt()));
|
|
mode = "unsigned401";
|
|
const m = await attempt();
|
|
ok("unsigned 401 reported with status + unverified detail", /status 401/.test(m) && /unverified detail: .*bad signature/.test(m), m);
|
|
});
|
|
await step("response byte cap and request timeout", async () => {
|
|
mode = "good";
|
|
let msg = "";
|
|
try {
|
|
await signedRequest({ baseUrl: tamperBase, method: "GET", path: API_PATHS.HEALTH, body: null, sourceEnvId: myEnv.env_id, secret: tamperSecret, maxBytes: 8 });
|
|
} catch (e) {
|
|
msg = e.message;
|
|
}
|
|
ok("over-cap response refused", /too large/.test(msg), msg);
|
|
mode = "hang";
|
|
const saved = PEER_LIMITS.TIMEOUT_MS;
|
|
PEER_LIMITS.TIMEOUT_MS = 300;
|
|
const t0 = Date.now();
|
|
const outcome = await attempt();
|
|
PEER_LIMITS.TIMEOUT_MS = saved;
|
|
ok("hung peer aborted by the timeout", outcome !== "accepted" && Date.now() - t0 < 5000, `${outcome} after ${Date.now() - t0}ms`);
|
|
});
|
|
} finally {
|
|
await peers.deletePeer(tamperPeer.peer_id);
|
|
tamper.closeAllConnections();
|
|
tamper.close();
|
|
}
|
|
|
|
section("Phase 12: GET /api/file/:hash error branches (all signed)");
|
|
const fileEnv = randomUuid();
|
|
const { secret: fileSecret } = await peers.addPeer({ envId: fileEnv, label: "files", baseUrl: "http://localhost:3000" });
|
|
const fileCall = async (hash) => {
|
|
const req = mkPeerReq({ method: "GET", path: API_PATHS.file(hash), host: HOST, envId: fileEnv, secret: fileSecret });
|
|
req.params = { hash };
|
|
const res = mkRes();
|
|
await routeOf(routes.routes, "get", API_PATHS.FILE_ROUTE)(req, res);
|
|
return { req, res };
|
|
};
|
|
await step("apiFile: bad hash 400, unknown 404, outside-store 400, missing 404, good 200", async () => {
|
|
let r = await fileCall("NOT-A-HASH");
|
|
ok("malformed hash -> 400 (signed)", r.res.statusCode === 400 && responseSigned(r.req, r.res, fileSecret));
|
|
r = await fileCall("d".repeat(64));
|
|
ok("unindexed hash -> 404 (signed)", r.res.statusCode === 404 && responseSigned(r.req, r.res, fileSecret));
|
|
const realResolve = rowsLib.resolveFileByHash;
|
|
try {
|
|
rowsLib.resolveFileByHash = async () => "../../../../etc/passwd";
|
|
r = await fileCall("c".repeat(64));
|
|
ok("indexed value escaping the tenant store -> 400, nothing read", r.res.statusCode === 400 && !/root:/.test(String(r.res.raw)) && responseSigned(r.req, r.res, fileSecret));
|
|
rowsLib.resolveFileByHash = async () => "no-such-dir/gone.bin";
|
|
r = await fileCall("c".repeat(64));
|
|
ok("indexed but missing from the store -> 404", r.res.statusCode === 404 && responseSigned(r.req, r.res, fileSecret));
|
|
} finally {
|
|
rowsLib.resolveFileByHash = realResolve;
|
|
}
|
|
r = await fileCall(FILE_HASH);
|
|
ok("synced file -> 200 with signed bytes", r.res.statusCode === 200 && sha256Hex(r.res.raw) === FILE_HASH && responseSigned(r.req, r.res, fileSecret));
|
|
});
|
|
|
|
section("Phase 12: admin flows (peers, tables, settings, apply page)");
|
|
await step("peers page Plan button posts `peer` to the POST plan route", async () => {
|
|
const res = mkRes();
|
|
await routeOf(routes.routes, "get", "/admin/site-sync/peers")(mkAdminReq({ originalUrl: "/admin/site-sync/peers" }), res);
|
|
ok("a POST form to /admin/site-sync/plan carrying name=peer", /action="\/admin\/site-sync\/plan" method="post"[^>]*><input type="hidden" name="_csrf"[^>]*><input type="hidden" name="peer"/.test(wrapHtml(res)));
|
|
});
|
|
await step("rotate generates; replace-with-pasted-secret installs exactly that secret", async () => {
|
|
const target = (await peers.listPeers()).find((x) => x.env_id === fileEnv);
|
|
const before = await peers.peerSecret(target.peer_id);
|
|
let req = mkAdminReq({ method: "POST", body: { peer_id: String(target.peer_id) } });
|
|
let res = mkRes();
|
|
await routeOf(routes.routes, "post", "/admin/site-sync/peers/rotate")(req, res);
|
|
ok("rotate rendered the new secret once", !!res._wrap && Buffer.compare(before, await peers.peerSecret(target.peer_id)) !== 0);
|
|
const pasted = protocol.randomSecret();
|
|
req = mkAdminReq({ method: "POST", body: { peer_id: String(target.peer_id), secret: pasted.toString("hex") } });
|
|
res = mkRes();
|
|
await routeOf(routes.routes, "post", "/admin/site-sync/peers/rotate")(req, res);
|
|
ok("pasted secret installed verbatim", Buffer.compare(pasted, await peers.peerSecret(target.peer_id)) === 0 && res._redirect === "/admin/site-sync/peers");
|
|
req = mkAdminReq({ method: "POST", body: { peer_id: String(target.peer_id), secret: "zz" } });
|
|
res = mkRes();
|
|
await routeOf(routes.routes, "post", "/admin/site-sync/peers/rotate")(req, res);
|
|
ok("malformed pasted secret refused", req._flash.some((f) => f.type === "danger") && Buffer.compare(pasted, await peers.peerSecret(target.peer_id)) === 0);
|
|
});
|
|
await step("tables page renders every table with its mode (Table.find is async)", async () => {
|
|
const res = mkRes();
|
|
await routeOf(routes.routes, "get", "/admin/site-sync/tables")(mkAdminReq({ originalUrl: "/admin/site-sync/tables" }), res);
|
|
const html = wrapHtml(res);
|
|
ok("the page lists users as locked and offers the mode buttons", !!res._wrap && html.includes("locked (user)") && html.includes("/admin/site-sync/tables/set"));
|
|
});
|
|
await step("tables/set refuses plugin infrastructure tables", async () => {
|
|
const req = mkAdminReq({ method: "POST", body: { table: "_ss_peers", mode: "managed" } });
|
|
const res = mkRes();
|
|
await routeOf(routes.routes, "post", "/admin/site-sync/tables/set")(req, res);
|
|
ok("_ss_peers flip refused", req._flash.some((f) => f.type === "danger") && (await rowsLib.dataModeOf("_ss_peers")) === "user");
|
|
});
|
|
await step("settings: invalid choice refused (not silently defaulted); flashes are escaped", async () => {
|
|
const before = await env.getEnv();
|
|
const req = mkAdminReq({ method: "POST", body: { sync_mode: "<b>bogus</b>", on_delete: "confirm", on_apply: "confirm" } });
|
|
const res = mkRes();
|
|
await routeOf(routes.routes, "post", "/admin/site-sync/settings")(req, res);
|
|
const flash = req._flash[0] || {};
|
|
ok("refused with an error flash", flash.type === "danger" && (await env.getEnv()).sync_mode === before.sync_mode);
|
|
ok("flash text is HTML-escaped", !/<b>/.test(flash.msg || "") && /<b>/.test(flash.msg || ""));
|
|
});
|
|
await step("apply page renders every warning; a failed apply offers the rollback form", async () => {
|
|
const cur = await capture.buildDesiredState();
|
|
const { plan_id, plan } = await planLib.buildPlan({ peerRow: null, env: myEnv, desired: cur.pack, desiredHash: cur.hash, sourceEnvId: "src" });
|
|
const realApply = applyLib.applyPlan;
|
|
try {
|
|
applyLib.applyPlan = async () => ({
|
|
applied: true, plan_id, summary: { total: 2 },
|
|
deletes: { applied: true, deleted: 0, skipped: [{ name: "t", reason: "r" }] },
|
|
rows: { applied: false }, warnings: ["layout image <img> fetch failed", "row not inserted"]
|
|
});
|
|
let res = mkRes();
|
|
await routeOf(routes.routes, "post", "/admin/site-sync/apply")(mkAdminReq({ method: "POST", body: { plan_id } }), res);
|
|
let html = wrapHtml(res);
|
|
ok("success page lists every warning, escaped", html.includes("Warnings (2)") && html.includes("<img>") && html.includes("row not inserted"));
|
|
await rollback.saveRestorePoint(plan_id, cur.pack, plan);
|
|
applyLib.applyPlan = async () => {
|
|
const err = new Error("install_pack exploded");
|
|
err.planId = plan_id;
|
|
throw err;
|
|
};
|
|
res = mkRes();
|
|
await routeOf(routes.routes, "post", "/admin/site-sync/apply")(mkAdminReq({ method: "POST", body: { plan_id } }), res);
|
|
html = wrapHtml(res);
|
|
ok("failure page shows the error", html.includes("install_pack exploded"));
|
|
ok("failure page offers rollback for the saved restore point", html.includes("/admin/site-sync/rollback") && html.includes(plan_id));
|
|
} finally {
|
|
applyLib.applyPlan = realApply;
|
|
}
|
|
});
|
|
await step("real apply + rollback through the admin routes", async () => {
|
|
const prev = getState().getConfig("site_name") || "";
|
|
await getState().setConfig("site_name", "ViaRoutes");
|
|
const cap = await capture.buildDesiredState();
|
|
await getState().setConfig("site_name", prev);
|
|
const { plan_id } = await planLib.buildPlan({ peerRow: null, env: await env.getEnv(), desired: cap.pack, desiredHash: cap.hash, sourceEnvId: "src" });
|
|
let req = mkAdminReq({ method: "POST", body: { plan_id } });
|
|
let res = mkRes();
|
|
await routeOf(routes.routes, "post", "/admin/site-sync/apply")(req, res);
|
|
ok("apply page rendered and the change landed", wrapHtml(res).includes("Applied plan") && getState().getConfig("site_name") === "ViaRoutes");
|
|
ok("apply page offers the rollback form", wrapHtml(res).includes('action="/admin/site-sync/rollback"'));
|
|
req = mkAdminReq({ method: "POST", body: { plan_id } });
|
|
res = mkRes();
|
|
await routeOf(routes.routes, "post", "/admin/site-sync/rollback")(req, res);
|
|
ok("rollback via the route reverted the change", req._flash.some((f) => f.type === "success") && getState().getConfig("site_name") === prev, JSON.stringify(req._flash));
|
|
req = mkAdminReq({ method: "POST", body: { plan_id } });
|
|
res = mkRes();
|
|
await routeOf(routes.routes, "post", "/admin/site-sync/rollback")(req, res);
|
|
ok("a second rollback is refused with an error flash", req._flash.some((f) => f.type === "danger") && res._redirect === "/admin/site-sync/");
|
|
});
|
|
await peers.deletePeer((await peers.findPeerByEnvId(fileEnv)).peer_id);
|
|
};
|
|
|
|
|
|
const main = async () => {
|
|
console.log(`DB = ${process.env.SQLITE_FILEPATH}`);
|
|
await bootstrapPlugin();
|
|
await phase1();
|
|
await phase2();
|
|
await phase3();
|
|
await phase4();
|
|
await phase5();
|
|
await phase6();
|
|
await phase7();
|
|
await phase8();
|
|
await phase9();
|
|
await phase10();
|
|
await phase11();
|
|
await phase12();
|
|
process.exit(summary());
|
|
};
|
|
|
|
|
|
main().catch((err) => {
|
|
console.error(err && err.stack ? err.stack : err);
|
|
process.exit(1);
|
|
});
|