// 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: //
UUID UUID
// cannot be mistaken for it.
const envIdOf = (html) => {
const m = html.match(/Env ID<\/th> ([0-9a-f-]{36})<\/code>/);
return m ? m[1] : "";
};
// The "Ops recorded" row is: Ops recorded N
const opCountOf = (html) => {
const m = html.match(/Ops recorded<\/th> (\d+)<\/td>/);
return m ? parseInt(m[1], 10) : null;
};
// The "Entities tracked" table renders one kind count
// 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 = /[^<]+<\/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/.
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/.
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);
});