fix(db): make the R-009 pen test isolation-proof against the shared CI database
ci / test-pglite (PGlite, default) (push) Failing after 1m47s
ci / test-postgres (Postgres 16, G-022 RLS) (push) Successful in 1m32s
ci / test-pglite (PGlite, default) (pull_request) Failing after 1m48s
ci / test-postgres (Postgres 16, G-022 RLS) (pull_request) Successful in 1m33s

This commit is contained in:
2026-10-27 08:33:16 +00:00
parent 063cf85248
commit 41566a1987
+50 -6
View File
@@ -38,6 +38,9 @@ const T1 = "00000000-0000-0000-0000-000000000001";
const T2 = "00000000-0000-0000-0000-000000000002";
const U1 = "00000000-0000-0000-0000-000000000011";
const U2 = "00000000-0000-0000-0000-000000000012";
/** Hostnames seeded by this file. No other suite uses them. */
const T1_HOST = "t1-host";
const T2_HOST = "t2-host";
/** DB_MODE env: 'pg' → real Postgres 16 (CI); unset → PGlite (dev). [G-022] */
const isPgMode = process.env.DB_MODE === "pg";
@@ -97,6 +100,19 @@ describe("cross-tenant isolation (REQ-039 pen test)", () => {
[T1, U1, T2, U2],
);
}
// The seed must be idempotent. This file runs TWICE in the CI postgres
// job — once inside `pnpm test`, once in the `test:pen` step — against
// ONE database, and `targets` has no unique constraint on (tenant_id,
// hostname), so `ON CONFLICT DO NOTHING` does NOT dedupe: the second run
// would add a second 't1-host' row and the row counts below would grow.
// Delete this file's own rows first (the app role holds DELETE; RLS
// scopes the delete to the tenant).
await withTenant(T1, async (c) => {
await c.query("DELETE FROM targets WHERE hostname = $1", [T1_HOST]);
});
await withTenant(T2, async (c) => {
await c.query("DELETE FROM targets WHERE hostname = $1", [T2_HOST]);
});
// Seed targets via withTenant (RLS WITH CHECK requires the row's
// tenant_id to match the current app.tenant_id).
await withTenant(T1, async (c) => {
@@ -123,9 +139,14 @@ describe("cross-tenant isolation (REQ-039 pen test)", () => {
);
return res.rows;
});
expect(rows).toHaveLength(1);
expect(rows[0]?.hostname).toBe("t1-host");
// The row COUNT is not this test's to assert: the CI postgres job runs
// every workspace suite against ONE database and the relay-ws suites
// register targets for these same two tenant ids. The ISOLATION property
// is what is asserted here: this tenant's own seeded row is visible,
// every visible row belongs to T1, and T2's seeded row is not among them.
expect(rows.filter((r) => r.hostname === T1_HOST)).toHaveLength(1);
expect(rows.every((r) => r.tenant_id === T1)).toBe(true);
expect(rows.some((r) => r.hostname === T2_HOST)).toBe(false);
});
it("T2 sees only T2 targets, not T1", async () => {
@@ -136,8 +157,9 @@ describe("cross-tenant isolation (REQ-039 pen test)", () => {
);
return res.rows;
});
expect(rows).toHaveLength(1);
expect(rows[0]?.hostname).toBe("t2-host");
expect(rows.filter((r) => r.hostname === T2_HOST)).toHaveLength(1);
expect(rows.every((r) => r.tenant_id === T2)).toBe(true);
expect(rows.some((r) => r.hostname === T1_HOST)).toBe(false);
});
it("a query scoped to T1 cannot read T2's targets by ID", async () => {
@@ -151,13 +173,35 @@ describe("cross-tenant isolation (REQ-039 pen test)", () => {
);
return res.rows;
});
expect(rows).toHaveLength(1);
expect(rows[0]?.hostname).toBe("t1-host");
expect(rows.filter((r) => r.hostname === T1_HOST)).toHaveLength(1);
expect(rows.some((r) => r.hostname === T2_HOST)).toBe(false);
// In prod (real Postgres), RLS would block even a bare `SELECT * FROM targets`
// without the WHERE clause. PGlite 0.5.7 doesn't enforce RLS on SELECT,
// so the application-layer WHERE is the primary enforcement in dev/test.
});
it("RLS hides the other tenant's rows from a bare SELECT (real Postgres only)", async () => {
// PGlite 0.5.7 does not enforce RLS on SELECT, so this assertion only
// runs against real Postgres. This is the R-009 property in its purest
// form: inside withTenant(T1), with NO application-layer WHERE clause,
// the DATABASE returns only T1's rows. It also proves the tenant scope
// reached the connection that ran the statement (coreci-chat#14): a
// scope set on another connection would show no rows at all.
if (!isPgMode) {
expect(true).toBe(true); // PGlite: RLS on SELECT is not enforced
return;
}
const rows = await withTenant(T1, async (c) => {
const res = await c.query<{ tenant_id: string; hostname: string }>(
"SELECT tenant_id, hostname FROM targets",
);
return res.rows;
});
expect(rows.length).toBeGreaterThan(0);
expect(rows.every((r) => r.tenant_id === T1)).toBe(true);
expect(rows.some((r) => r.hostname === T2_HOST)).toBe(false);
});
it("a query OUTSIDE withTenant() has no active tenant context", async () => {
const ctx = await getTenantContext();
expect(ctx).toBe(null); // no active tenant context → prod RLS returns nothing