270 lines
10 KiB
JavaScript
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);
|
|
});
|