218 lines
7.2 KiB
JavaScript
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
|
|
};
|