Tests: vollständige Suite auch mit RLS_ENFORCED=true grün

- test-tenant-isolation: Compound-Key mit fremdem Mandanten – im Owner-Betrieb Throw
  (Tenant-Guard), unter scharfer RLS liefert die DB null; beides = kein Datenabfluss
- run-tests.ts: lädt .env und leitet RLS_DATABASE_URL (Rolle craftvia_app) aus
  DATABASE_URL ab, wenn RLS_ENFORCED=true und keine URL gesetzt ist

Nachweis: RLS_ENFORCED=true npm run test → 52/52; npm run gate → 52/52.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 18:33:22 +02:00
co-authored by Claude Opus 5
parent bc58738129
commit 6687ef2e03
2 changed files with 27 additions and 2 deletions
+10
View File
@@ -8,6 +8,7 @@
* Lauf: npm run test (alle)
* npm run test -- mail tenant (nur Tests, deren Name einen der Filter enthält)
*/
import "dotenv/config";
import { spawnSync } from "node:child_process";
import { readdirSync } from "node:fs";
import { join, dirname } from "node:path";
@@ -26,6 +27,15 @@ if (tests.length === 0) {
process.exit(1);
}
// RLS runs (RLS_ENFORCED=true): db.ts refuses to start without RLS_DATABASE_URL. Default it to the
// same database as DATABASE_URL with the restricted role (as scripts/test-rls-enforcement.ts does).
if (process.env.RLS_ENFORCED === "true" && !process.env.RLS_DATABASE_URL && process.env.DATABASE_URL) {
const url = new URL(process.env.DATABASE_URL);
url.username = "craftvia_app";
url.password = "craftvia_app_local";
process.env.RLS_DATABASE_URL = url.toString();
}
const results: { name: string; ok: boolean; ms: number }[] = [];
for (const file of tests) {
const started = Date.now();
+17 -2
View File
@@ -29,6 +29,21 @@ async function expectThrow(fn: () => Promise<unknown>, msg: string) {
}
}
/**
* Compound keys with a foreign tenantId: in owner mode the tenant guard throws (fail-closed);
* with RLS_ENFORCED=true the database already hides the row, so the query returns null.
* Both outcomes mean "no data leak" — anything else fails.
*/
async function expectThrowOrNullUnderRls(fn: () => Promise<unknown>, msg: string) {
if (process.env.RLS_ENFORCED !== "true") return expectThrow(fn, msg);
try {
const r = await fn();
ok(r === null, `${msg} (RLS: null)${r === null ? "" : ` — statt null: ${JSON.stringify(r)}`}`);
} catch {
ok(true, `${msg} (RLS: Throw)`);
}
}
/** Erwartet, dass `fn` `null` liefert (kein Datensatz, kein Abfluss). */
async function expectNull(fn: () => Promise<unknown>, msg: string) {
const r = await fn();
@@ -98,7 +113,7 @@ async function main() {
);
// (6) Compound-Unique-Key (tenantId_key) mit fremdem tenantId, select ohne tenantId.
await expectThrow(
await expectThrowOrNullUnderRls(
() =>
dbA.role.findUnique({
where: { tenantId_key: { tenantId: tenantB.id, key: roleB.key } },
@@ -108,7 +123,7 @@ async function main() {
);
// (7) Compound-Unique-Key mit fremdem tenantId, ohne select.
await expectThrow(
await expectThrowOrNullUnderRls(
() =>
dbA.role.findUnique({
where: { tenantId_key: { tenantId: tenantB.id, key: roleB.key } },