sc-site-sync/test/harness.js
2026-10-19 01:25:16 -05:00

218 lines
7.2 KiB
JavaScript

// 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
};