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

270 lines
10 KiB
JavaScript

// Multi-tenant ISOLATION gate for dev-deploy against the Postgres instance
// (:3002). Phase 0 proof: each tenant is its OWN dev-deploy environment on the
// shared Postgres server (schema-qualified _dd_* tables + per-tenant env row).
// The gate bootstraps the t1 and t2 admins over HTTP, reads each tenant's
// dev-deploy admin dashboard, and asserts that:
// * each dashboard is reachable (200) and exposes a dev-deploy env_id,
// * t1's env_id and t2's env_id are DISTINCT (separate per-tenant env rows),
// * the dashboards' "Ops recorded" and "Entities tracked" counts are reported
// per tenant -- mutating t1 moves only t1's numbers, never t2's.
// Tenants are addressed by Host header (tNN.localhost.localdomain:3002 -> tenant
// tNN); each tenant uses a separate cookie jar. Run: node test/mtGate.js
//
// Prerequisites:
// * PG multi-tenant instance up on :3002 (./startServerPg.sh).
// * Tenants t1 and t2 exist (saltcorn create-tenant t1 / t2).
// * dev-deploy installed per-tenant:
// ./dev-deploy/scripts/installDevDeployTenant.sh t1 t2
// (this creates the _dd_* tables + bootstraps each tenant's env row).
// If :3002 is not reachable the gate self-skips (exit 0, prints SKIP).
const http = require("http");
const net = require("net");
const PG_PORT = 3002;
const TENANTS = ["t1", "t2"];
const ADMIN_PW = "AdminP@ss1";
const jars = { 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 HOST = (t) => t + ".localhost.localdomain:" + PG_PORT;
const ADMIN = (t) => "admin@" + t + ".local";
// TCP connect probe: resolve true if :3002 accepts a connection within 1s.
const portOpen = (port) => {
return new Promise((resolve) => {
const sock = net.connect(port, "127.0.0.1");
const done = (up) => {
sock.destroy();
resolve(up);
};
sock.setTimeout(1000);
sock.on("connect", () => done(true));
sock.on("timeout", () => done(false));
sock.on("error", () => done(false));
});
};
const storeCookies = (t, 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[t][pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
}
}
};
const request = (t, method, path, opts) => {
const options = opts || {};
return new Promise((resolve, reject) => {
const headers = Object.assign({ Host: HOST(t) }, options.headers || {});
const jar = jars[t];
if (Object.keys(jar).length > 0) {
headers["Cookie"] = Object.keys(jar).map((k) => k + "=" + jar[k]).join("; ");
}
let data = null;
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: PG_PORT, method: method, path: path, headers: headers }, (resp) => {
storeCookies(t, 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) {
r.write(data);
}
r.end();
});
};
const csrfOf = (html) => {
const m = html.match(/name="_csrf" value="([^"]+)"/);
return m ? m[1] : "";
};
const authed = async (t) => {
return /true/.test((await request(t, "GET", "/auth/authenticated")).body);
};
// Ensure a tenant admin exists and the jar holds an authenticated session.
const bootstrapTenant = async (t) => {
const lp = await request(t, "GET", "/auth/login");
await request(t, "POST", "/auth/login", { body: { email: ADMIN(t), password: ADMIN_PW, _csrf: csrfOf(lp.body) } });
if (await authed(t)) {
return true;
}
const cp = await request(t, "GET", "/auth/create_first_user");
const cc = csrfOf(cp.body);
if (cc) {
await request(t, "POST", "/auth/create_first_user", { body: { email: ADMIN(t), password: ADMIN_PW, default_language: "en", _csrf: cc } });
}
if (await authed(t)) {
return true;
}
const lp2 = await request(t, "GET", "/auth/login");
await request(t, "POST", "/auth/login", { body: { email: ADMIN(t), password: ADMIN_PW, _csrf: csrfOf(lp2.body) } });
return await authed(t);
};
// The dashboard renders the env_id in the "Env ID" row as:
// <tr><th>Env ID</th><td><code>UUID</code></td></tr>
// (see lib/routes.js dashboard()). Anchor on that row so a later <code> UUID
// cannot be mistaken for it.
const envIdOf = (html) => {
const m = html.match(/<th>Env ID<\/th><td><code>([0-9a-f-]{36})<\/code>/);
return m ? m[1] : "";
};
// The "Ops recorded" row is: <tr><th>Ops recorded</th><td>N</td></tr>
const opCountOf = (html) => {
const m = html.match(/<th>Ops recorded<\/th><td>(\d+)<\/td>/);
return m ? parseInt(m[1], 10) : null;
};
// The "Entities tracked" table renders one <tr><td>kind</td><td>count</td></tr>
// per kind; sum the counts for a single per-tenant entity total.
const entityTotalOf = (html) => {
const idx = html.indexOf("Entities tracked");
if (idx < 0) {
return null;
}
const section = html.slice(idx);
let total = 0;
let matched = false;
const re = /<tr><td>[^<]+<\/td><td>(\d+)<\/td><\/tr>/g;
let m;
while ((m = re.exec(section)) !== null) {
total += parseInt(m[1], 10);
matched = true;
}
return matched ? total : null;
};
const dashboardOf = async (t) => {
return await request(t, "GET", "/admin/dev-deploy/");
};
const run = async () => {
// Bootstrap both tenant admins.
for (const t of TENANTS) {
ok(await bootstrapTenant(t), t + " admin session established");
}
// Both dashboards reachable (200) and each exposes a dev-deploy env_id.
const d1 = await dashboardOf("t1");
const d2 = await dashboardOf("t2");
ok(d1.status === 200, "t1 dev-deploy dashboard reachable (HTTP " + d1.status + ")");
ok(d2.status === 200, "t2 dev-deploy dashboard reachable (HTTP " + d2.status + ")");
const env1 = envIdOf(d1.body);
const env2 = envIdOf(d2.body);
ok(/^[0-9a-f-]{36}$/.test(env1), "t1 dashboard exposes an env_id (" + (env1 || "none") + ")");
ok(/^[0-9a-f-]{36}$/.test(env2), "t2 dashboard exposes an env_id (" + (env2 || "none") + ")");
// Core Phase 0 assertion: the two tenants are DISTINCT dev-deploy envs.
ok(env1 && env2 && env1 !== env2, "t1 and t2 have DISTINCT env_ids (" + env1 + " / " + env2 + ")");
// Each tenant reports its OWN ops/entities counts (they are not one shared
// number). Capture the baselines first.
const ops1 = opCountOf(d1.body);
const ops2 = opCountOf(d2.body);
const ent1 = entityTotalOf(d1.body);
const ent2 = entityTotalOf(d2.body);
ok(ops1 !== null && ops2 !== null, "both dashboards report an 'Ops recorded' count (t1=" + ops1 + ", t2=" + ops2 + ")");
ok(ent1 !== null && ent2 !== null, "both dashboards report an 'Entities tracked' total (t1=" + ent1 + ", t2=" + ent2 + ")");
// Mutate ONLY t1 (creating a table journals create_table + tracks a new
// 'table' entity), then re-read both dashboards. t1's counts must move and
// t2's must NOT -- proving the _dd_* tables are schema-qualified per tenant
// rather than a single shared journal. POST /table (Saltcorn's own admin
// create handler) calls Table.create, which dev-deploy's hooks journal as a
// create_table op. Its CSRF lives on GET /table/new; success redirects 302
// to /table/<id>.
const tableName = "mt_iso_probe_" + Date.now();
// Saltcorn's own table admin pages (e.g. GET /table/new/) call res.sendWrap,
// which 500s on a freshly created tenant that has no theme/layout configured.
// dev-deploy's admin pages self-render, so grab the (per-session) _csrf token
// from one of those; POST /table itself only mutates + redirects (no sendWrap),
// so it succeeds on a themeless tenant and journals a create_table op.
const tcsrf = csrfOf((await request("t1", "GET", "/admin/dev-deploy/peers")).body);
const createRes = await request("t1", "POST", "/table", { body: { name: tableName, _csrf: tcsrf } });
const created = createRes.status >= 300 && createRes.status < 400 && /\/table\/\d+/.test(createRes.headers.location || "");
ok(created, "t1 created a table to move its journal (HTTP " + createRes.status + " -> " + (createRes.headers.location || "") + ")");
const d1b = await dashboardOf("t1");
const d2b = await dashboardOf("t2");
const ops1b = opCountOf(d1b.body);
const ops2b = opCountOf(d2b.body);
const ent1b = entityTotalOf(d1b.body);
const ent2b = entityTotalOf(d2b.body);
ok(ops1b !== null && ops1b > ops1, "t1 'Ops recorded' increased after t1 mutation (" + ops1 + " -> " + ops1b + ")");
ok(ops2b === ops2, "t2 'Ops recorded' UNCHANGED by t1 mutation (" + ops2 + " -> " + ops2b + ")");
ok(ent1b !== null && ent1b > ent1, "t1 'Entities tracked' increased after t1 mutation (" + ent1 + " -> " + ent1b + ")");
ok(ent2b === ent2, "t2 'Entities tracked' UNCHANGED by t1 mutation (" + ent2 + " -> " + ent2b + ")");
// Cleanup: drop the probe table so reruns stay deterministic. Best-effort.
try {
const tid = (createRes.headers.location || "").match(/\/table\/(\d+)/);
if (tid) {
// Same theme caveat: take the _csrf from dev-deploy's page, not /table/<id>.
const dcsrf = csrfOf((await request("t1", "GET", "/admin/dev-deploy/peers")).body);
await request("t1", "POST", "/table/delete/" + tid[1], { body: { _csrf: dcsrf } });
}
} catch (e) {
// ignore cleanup failures
}
console.log("\nRESULT: " + pass + " passed, " + fail + " failed");
process.exit(fail ? 1 : 0);
};
const main = async () => {
if (!(await portOpen(PG_PORT))) {
console.log("SKIP: Postgres multi-tenant instance not reachable on 127.0.0.1:" + PG_PORT);
process.exit(0);
}
await run();
};
main().catch((e) => {
console.error("MT GATE ERROR:", e);
process.exit(2);
});