// Shared helpers for site-sync's in-process test suites. // // Everything runs against ONE throwaway SQLite DB (set up by run.sh) with the // plugin registered by location so its real onLoad runs. Peer HMAC auth is // exercised with mock req/res objects: we sign a canonical with the plugin's own // crypto and feed requirePeerAuth an async-iterable mock request, so the full // inbound pipeline (skew, host-binding, signature, nonce replay, TLS gate) is // covered without standing up a second HTTP server. const path = require("path"); const db = require("@saltcorn/data/db"); const Plugin = require("@saltcorn/data/models/plugin"); const PLUGIN_DIR = path.resolve(__dirname, ".."); const { PLUGIN_NAME, PEER_CONTENT_TYPE, HEADERS } = require("../lib/constants"); const { buildCanonical, buildResponseCanonical, normalizeHost, sign, verifySignature, randomNonce } = require("../lib/common/peerProtocol"); let failures = 0; let passes = 0; const ok = (label, cond, detail) => { const tag = cond ? "PASS" : "FAIL"; if (cond) { passes += 1; } else { failures += 1; } console.log(` [${tag}] ${label}${detail ? " -- " + detail : ""}`); return cond; }; const section = (title) => { console.log(`\n# ${title}`); }; const step = async (label, fn) => { try { await fn(); } catch (e) { failures += 1; console.log(` [FAIL] ${label} THREW -- ${e && (e.stack || e.message)}`); } }; // Print the tally and return the process exit code (never the raw count: exit // codes wrap mod 256, so 256 failures would otherwise exit 0). const summary = () => { console.log(`\n${failures === 0 ? "ALL GREEN" : "FAILURES"}: ${passes} passed, ${failures} failed`); return failures > 0 ? 1 : 0; }; // Register site-sync by location so its real onLoad runs (createAllTables + // initEnvIfMissing + ensureCsrfBypass) against the throwaway DB. const installPlugin = async () => { const plugin = new Plugin({ name: PLUGIN_NAME, source: "local", location: PLUGIN_DIR, configuration: {} }); await Plugin.loadAndSaveNewPlugin(plugin, true, false); }; const bootstrapPlugin = async () => { await Plugin.loadAllPlugins(); await installPlugin(); }; // Mock express response. A signed peer-API reply arrives via set() + end(Buffer) // (lib/common/peerServer.js sendSigned): the raw bytes are kept (for signature // checks) and a JSON body is parsed into body. const mkRes = () => { const r = { statusCode: 200, headers: {}, body: undefined }; r.status = (c) => { r.statusCode = c; return r; }; r.json = (o) => { r.body = o; r._json = o; return r; }; r.send = (s) => { r._sent = s; r.raw = Buffer.isBuffer(s) ? s : Buffer.from(String(s)); r.body = Buffer.isBuffer(s) && /json/.test(r._type || "") ? JSON.parse(s.toString("utf8")) : s; return r; }; r.type = (t) => { r._type = t; return r; }; r.set = (k, v) => { const pairs = k && typeof k === "object" ? Object.entries(k) : [[k, v]]; for (const [hk, hv] of pairs) { r.headers[String(hk).toLowerCase()] = hv; } return r; }; r.get = (k) => r.headers[String(k).toLowerCase()]; r.redirect = (u) => { r._redirect = u; return r; }; r.sendWrap = (title, opts) => { r._wrapTitle = title; r._wrap = opts; return r; }; r.sendFile = (p, o, cb) => { r._file = p; if (cb) cb(); return r; }; r.end = (b) => { r._ended = true; if (b !== undefined) { r.raw = Buffer.isBuffer(b) ? b : Buffer.from(String(b)); r.body = /json/.test(r.headers["content-type"] || "") ? JSON.parse(r.raw.toString("utf8")) : r.raw; } return r; }; return r; }; // Mock admin request. req.flash records flashed messages in req._flash. const mkAdminReq = (over) => { const req = Object.assign({ user: { role_id: 1, tenant: db.getTenantSchema(), email: "admin@test" }, params: {}, query: {}, body: {}, headers: {}, xhr: true, path: "/admin/site-sync/", originalUrl: "/admin/site-sync/", method: "GET", get: () => undefined, csrfToken: () => "testcsrf", // Saltcorn's markup (post_btn etc.) localizes via req.__; identity stub. __: (s) => s, _flash: [], }, over); req.flash = (type, msg) => { req._flash.push({ type, msg }); }; return req; }; // Build a mock signed peer request that requirePeerAuth can consume. Signs with // the given peer secret using the plugin's own canonical/sign so the verify side // must agree. `override` lets a test corrupt individual fields (bad sig, stale ts, // wrong host, reused nonce) to prove rejection. const mkPeerReq = ({ method, path, host, envId, secret, body, secure, override }) => { const bodyStr = body ? JSON.stringify(body) : ""; const timestamp = String((override && override.timestamp) || Date.now()); const nonce = (override && override.nonce) || randomNonce().toString("hex"); const targetHost = normalizeHost(host); const canonical = buildCanonical({ timestamp, nonce, method, path, targetHost, body: bodyStr }); let signature = sign(secret, canonical); if (override && override.badSignature) { signature = "00".repeat(32); } const headers = { host: host, [HEADERS.ENV_ID.toLowerCase()]: envId, [HEADERS.TIMESTAMP.toLowerCase()]: timestamp, [HEADERS.NONCE.toLowerCase()]: nonce, [HEADERS.SIGNATURE.toLowerCase()]: signature }; if (bodyStr) { headers["content-type"] = PEER_CONTENT_TYPE; } if (override && override.headers) { Object.assign(headers, override.headers); } // req.host / req.protocol mimic Express with trust proxy OFF (Saltcorn's // default): they come from the Host header and the socket, never from // X-Forwarded-* headers a client can set. const req = { method: method, url: path, originalUrl: path, headers: headers, host: headers.host, protocol: secure ? "https" : "http", query: {}, params: {} }; // readRawBody drains `for await (const chunk of req)`. req[Symbol.asyncIterator] = async function* () { if (bodyStr) { yield Buffer.from(bodyStr, "utf8"); } }; return req; }; // Whether a mock response carries a valid response signature for `req`'s nonce. const responseSigned = (req, res, secret) => { const canonical = buildResponseCanonical({ nonce: req.headers[HEADERS.NONCE.toLowerCase()], status: res.statusCode, body: res.raw || Buffer.alloc(0) }); return verifySignature(secret, canonical, res.headers[HEADERS.RESPONSE_SIGNATURE.toLowerCase()]); }; // Route callback by method + url (GET and POST can share a url). const routeOf = (routesArr, method, url) => { const r = routesArr.find((x) => x.method === method && x.url === url); if (!r) { throw new Error(`no ${method} route ${url}`); } return r.callback; }; module.exports = { PLUGIN_DIR, db, ok, section, step, summary, installPlugin, bootstrapPlugin, mkRes, mkAdminReq, mkPeerReq, responseSigned, routeOf };