sc-dev-deploy/test/mixedTopologyGate.js
2026-06-01 16:43:43 -05:00

267 lines
11 KiB
JavaScript

// Phase 1 mixed-topology peering gate. A STANDALONE dev instance (SQLite MAIN on
// :3000) pairs with a specific TENANT (t1) on the multi-tenant Postgres "prod"
// (:3002), promotes its metadata ops to t1, and we verify:
// 1. the ops land in t1's journal and NOT in sibling tenant t2 (mixed-topology
// isolation -- a standalone dev deploying to one tenant on a shared server);
// 2. the tenant-bound HMAC: a peer request signed for t1 is REJECTED (401) when
// replayed against t2 on the same server, while the SAME request signed for
// t2 passes auth -- proving the rejection is host-specific, not the secret.
//
// dev and t2 both hold a dev peer row with the SAME shared secret, so the only
// thing standing between a t1-signed request and t2 accepting it is the host
// binding in the canonical string (lib/crypto.js buildCanonical -> targetHost).
//
// Run: node test/mixedTopologyGate.js (MAIN :3000 + PG :3002 up, dev-deploy
// installed on MAIN and per-tenant on t1/t2). Self-skips if either is down.
const http = require("http");
const net = require("net");
const { buildCanonical, sign, normalizeHost } = require("../lib/crypto");
const DEV = { port: 3000, host: "localhost:3000", base: "http://localhost:3000" };
const PROD_PORT = 3002;
const ADMIN_PW = "AdminP@ss1";
const INGEST = "/dev-deploy/api/ingest";
const thost = (t) => t + ".localhost.localdomain:" + PROD_PORT;
const jars = { dev: {}, t1: {}, t2: {} };
let pass = 0;
let fail = 0;
const ok = (cond, msg) => {
if (cond) {
pass++;
console.log(" PASS " + msg);
} else {
fail++;
console.log(" FAIL " + msg);
}
};
const portOpen = (port) => {
return new Promise((resolve) => {
const s = net.connect(port, "127.0.0.1");
const done = (up) => { s.destroy(); resolve(up); };
s.setTimeout(1000);
s.on("connect", () => done(true));
s.on("timeout", () => done(false));
s.on("error", () => done(false));
});
};
const HOSTOF = (inst) => (inst === "dev" ? DEV.host : thost(inst));
const PORTOF = (inst) => (inst === "dev" ? DEV.port : PROD_PORT);
const ADMIN = (inst) => (inst === "dev" ? "admin@local" : "admin@" + inst + ".local");
const storeCookies = (inst, headers) => {
const sc = headers["set-cookie"];
if (!sc) {
return;
}
for (const line of sc) {
const pair = line.split(";")[0];
const eq = pair.indexOf("=");
if (eq > 0) {
jars[inst][pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
}
}
};
// opts.host overrides the Host header; opts.headers sets raw headers (for the
// signed replay); opts.rawBody sends an exact body string; opts.body is a form.
const request = (inst, method, path, opts) => {
const options = opts || {};
return new Promise((resolve, reject) => {
const headers = Object.assign({ Host: options.host || HOSTOF(inst) }, options.headers || {});
const jar = jars[inst];
if (jar && Object.keys(jar).length > 0) {
headers["Cookie"] = Object.keys(jar).map((k) => k + "=" + jar[k]).join("; ");
}
let data = null;
if (options.rawBody !== undefined) {
data = options.rawBody;
headers["Content-Length"] = Buffer.byteLength(data);
} else if (options.body) {
data = new URLSearchParams(options.body).toString();
headers["Content-Type"] = "application/x-www-form-urlencoded";
headers["Content-Length"] = Buffer.byteLength(data);
}
const r = http.request({ host: "127.0.0.1", port: PORTOF(inst), method: method, path: path, headers: headers }, (resp) => {
storeCookies(inst, resp.headers);
let body = "";
resp.on("data", (c) => { body += c; });
resp.on("end", () => resolve({ status: resp.statusCode, headers: resp.headers, body: body }));
});
r.on("error", reject);
if (data !== null && data !== undefined) {
r.write(data);
}
r.end();
});
};
const csrfOf = (h) => { const m = h.match(/name="_csrf" value="([^"]+)"/); return m ? m[1] : ""; };
const envIdOf = (h) => { const m = h.match(/env_id<\/strong> is <code>([0-9a-f-]{36})<\/code>/); return m ? m[1] : ""; };
const opCountOf = (h) => { const m = h.match(/<th>Ops recorded<\/th><td>(\d+)<\/td>/); return m ? parseInt(m[1], 10) : null; };
const secretOf = (h) => { const m = h.match(/<p class="secret">([0-9a-f]+)<\/p>/); return m ? m[1] : ""; };
// Find the peers-table row carrying envId and pull its peer_id out of the row's
// promote/delete form input.
const peerIdFor = (html, envId) => {
for (const row of html.split("<tr>")) {
if (row.indexOf("<code>" + envId + "</code>") >= 0) {
const m = row.match(/name="peer_id" value="(\d+)"/);
if (m) {
return m[1];
}
}
}
return null;
};
const authed = async (inst) => /true/.test((await request(inst, "GET", "/auth/authenticated")).body);
const bootstrap = async (inst) => {
const lp = await request(inst, "GET", "/auth/login");
await request(inst, "POST", "/auth/login", { body: { email: ADMIN(inst), password: ADMIN_PW, _csrf: csrfOf(lp.body) } });
if (await authed(inst)) {
return true;
}
const cp = await request(inst, "GET", "/auth/create_first_user");
const cc = csrfOf(cp.body);
if (cc) {
await request(inst, "POST", "/auth/create_first_user", { body: { email: ADMIN(inst), password: ADMIN_PW, default_language: "en", _csrf: cc } });
}
return await authed(inst);
};
const ddCsrf = async (inst) => csrfOf((await request(inst, "GET", "/admin/dev-deploy/peers")).body);
// Remove any existing peer row for envId (idempotent reruns).
const clearPeer = async (inst, envId) => {
const pid = peerIdFor((await request(inst, "GET", "/admin/dev-deploy/peers")).body, envId);
if (pid) {
await request(inst, "POST", "/admin/dev-deploy/peers/delete", { body: { peer_id: pid, _csrf: await ddCsrf(inst) } });
}
};
const addPeer = async (inst, envId, label, baseUrl, secret) => {
const body = { env_id: envId, label: label, base_url: baseUrl, _csrf: await ddCsrf(inst) };
if (secret) {
body.existing_secret = secret;
}
return await request(inst, "POST", "/admin/dev-deploy/peers/add", { body: body });
};
const run = async () => {
ok(await bootstrap("dev"), "dev (standalone MAIN :3000) admin session");
ok(await bootstrap("t1"), "t1 admin session");
ok(await bootstrap("t2"), "t2 admin session");
const devEnv = envIdOf((await request("dev", "GET", "/admin/dev-deploy/peers")).body);
const t1Env = envIdOf((await request("t1", "GET", "/admin/dev-deploy/peers")).body);
const t2Env = envIdOf((await request("t2", "GET", "/admin/dev-deploy/peers")).body);
ok(/^[0-9a-f-]{36}$/.test(devEnv), "dev env_id (" + devEnv + ")");
ok(t1Env && t2Env && t1Env !== t2Env && t1Env !== devEnv, "dev/t1/t2 have distinct env_ids");
// Idempotent: drop any peer rows left by a prior run.
await clearPeer("dev", t1Env);
await clearPeer("t1", devEnv);
await clearPeer("t2", devEnv);
// Pair dev -> t1 (dev generates the shared secret); t1 stores dev with it.
const addRes = await addPeer("dev", t1Env, "prod-t1", "http://" + thost("t1"));
const secret = secretOf(addRes.body);
ok(/^[0-9a-f]{64}$/.test(secret), "dev paired with t1 (shared secret issued)");
await addPeer("t1", devEnv, "dev", DEV.base, secret);
// t2 also holds the dev peer with the SAME secret -> the cross-tenant replay
// can ONLY be stopped by the host binding, not by a differing secret.
await addPeer("t2", devEnv, "dev", DEV.base, secret);
const t1Ops0 = opCountOf((await request("t1", "GET", "/admin/dev-deploy/")).body);
const t2Ops0 = opCountOf((await request("t2", "GET", "/admin/dev-deploy/")).body);
// dev promotes its journal to t1.
const t1PeerId = peerIdFor((await request("dev", "GET", "/admin/dev-deploy/peers")).body, t1Env);
ok(!!t1PeerId, "dev has a peer_id for t1 (" + t1PeerId + ")");
const prom = await request("dev", "POST", "/admin/dev-deploy/promote", { body: { peer_id: t1PeerId, _csrf: await ddCsrf("dev") } });
ok(prom.status >= 200 && prom.status < 400, "dev promote -> t1 returned HTTP " + prom.status);
const t1Ops1 = opCountOf((await request("t1", "GET", "/admin/dev-deploy/")).body);
const t2Ops1 = opCountOf((await request("t2", "GET", "/admin/dev-deploy/")).body);
ok(t1Ops1 !== null && t1Ops1 > t1Ops0, "t1 received dev's ops (" + t1Ops0 + " -> " + t1Ops1 + ")");
ok(t2Ops1 === t2Ops0, "t2 journal UNCHANGED by dev->t1 promote (" + t2Ops0 + " -> " + t2Ops1 + ")");
// --- Tenant-bound HMAC ---
// Build a peer-signed ingest exactly as transport.js would, then aim it at the
// wrong tenant. Signature material is identical except the target host.
const secretBuf = Buffer.from(secret, "hex");
const mkHeaders = (targetHost, body) => {
const ts = String(Date.now());
const nonce = "00112233445566778899aabbccddeeff";
const canonical = buildCanonical({
timestamp: ts,
nonce: nonce,
method: "POST",
path: INGEST,
targetHost: normalizeHost(targetHost),
body: body
});
return {
"X-DD-Env-Id": devEnv,
"X-DD-Timestamp": ts,
"X-DD-Nonce": nonce,
"X-DD-Signature": sign(secretBuf, canonical),
"Content-Type": "application/vnd.dev-deploy+json"
};
};
const body = "{}";
// Signed for t1, replayed at t2 -> must be rejected by the host binding.
const replay = await request("t2", "POST", INGEST, { host: thost("t2"), headers: mkHeaders(thost("t1"), body), rawBody: body });
ok(replay.status === 401, "ingest signed for t1 REJECTED at t2 (tenant-bound HMAC; HTTP " + replay.status + ")");
// Same request, but signed for t2 -> auth must pass (proves the rejection was
// host-specific, not a secret/timestamp problem). Non-401 = auth accepted.
const control = await request("t2", "POST", INGEST, { host: thost("t2"), headers: mkHeaders(thost("t2"), body), rawBody: body });
ok(control.status !== 401, "same ingest signed for t2 passes auth at t2 (HTTP " + control.status + ", host-specific rejection confirmed)");
// Cleanup (best-effort): drop the peer rows so reruns start clean.
await clearPeer("dev", t1Env);
await clearPeer("t1", devEnv);
await clearPeer("t2", devEnv);
console.log("\nRESULT: " + pass + " passed, " + fail + " failed");
process.exit(fail ? 1 : 0);
};
const main = async () => {
if (!(await portOpen(DEV.port))) {
console.log("SKIP: standalone dev not reachable on 127.0.0.1:" + DEV.port);
process.exit(0);
}
if (!(await portOpen(PROD_PORT))) {
console.log("SKIP: Postgres multi-tenant prod not reachable on 127.0.0.1:" + PROD_PORT);
process.exit(0);
}
await run();
};
main().catch((e) => {
console.error("MIXED-TOPOLOGY GATE ERROR:", e);
process.exit(2);
});