import { randomUUID } from "node:crypto"; import { Kysely, PostgresDialect, sql } from "kysely"; import { Pool } from "pg"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; // Der Test, ohne den die Zugriffsschicht eine Behauptung wäre. // // Die Zugriffsrechte hängen an `current_setting('app.user_id')`. Diese // Variable gehört der *Verbindung*, nicht der Anfrage — und Verbindungen // kommen aus einem Pool. Wird sie ohne Transaktion gesetzt, sieht die // nächste Anfrage auf derselben Verbindung die Kennung der vorherigen // Person. // // Deshalb läuft hier ein Pool mit **genau einer** Verbindung: so ist // garantiert, dass zwei aufeinanderfolgende Zugriffe dieselbe physische // Verbindung benutzen. Mit mehreren Verbindungen würde der Test die Lücke // zufällig mal treffen und mal nicht — und wäre damit wertlos. const CONNECTION = process.env.DATABASE_URL; // Ohne Datenbank lässt sich lokal nichts prüfen — in der CI wäre ein // stillschweigend übersprungener Sicherheitstest allerdings schlimmer als // gar keiner. Deshalb schlägt dort schon das Fehlen des Verbindungsstrings // fehl, statt eine grüne Anzeige vorzutäuschen. describe("Voraussetzung", () => { it.runIf(process.env.CI)("DATABASE_URL ist in der CI gesetzt", () => { expect( CONNECTION, "DATABASE_URL fehlt. Ohne sie wird der Nachweis zum Sitzungskontext übersprungen — " + "und genau dieser Nachweis trägt die Zugriffsabsicherung." ).toBeTruthy(); }); }); const pool = new Pool({ connectionString: CONNECTION, max: 1 }); const db = new Kysely>({ dialect: new PostgresDialect({ pool }) }); /** Die Fassung aus lib/db: transaktionslokal. */ async function withUser(userId: string | null, fn: () => Promise): Promise { return db.transaction().execute(async (tx) => { await sql`select set_config('app.user_id', ${userId ?? ""}, true)`.execute(tx); return fn(); }); } async function currentUserOnConnection(): Promise { const r = await sql<{ v: string | null }>`select nullif(current_setting('app.user_id', true), '') as v`.execute(db); return r.rows[0]?.v ?? null; } describe.skipIf(!CONNECTION)("Sitzungskontext über eine gepoolte Verbindung", () => { const alice = randomUUID(); const bob = randomUUID(); beforeAll(async () => { // Ein Pool mit einer Verbindung: der Test ist nur dann aussagekräftig, // wenn beide Zugriffe garantiert dieselbe benutzen. await sql`select 1`.execute(db); }); afterAll(async () => { await db.destroy(); }); it("sieht innerhalb der Transaktion die eigene Kennung", async () => { const seen = await withUser(alice, async () => { const r = await sql<{ v: string }>`select current_setting('app.user_id', true) as v`.execute(db); return r.rows[0].v; }); expect(seen).toBe(alice); }); it("lässt nach der Transaktion nichts an der Verbindung zurück", async () => { // Das ist der eigentliche Befund: nach dem Commit muss die Variable weg // sein. Bliebe sie stehen, liefe die nächste Anfrage im Namen von Alice. await withUser(alice, async () => { await sql`select 1`.execute(db); }); expect(await currentUserOnConnection()).toBeNull(); }); it("trägt die Kennung nicht von einer Anfrage in die nächste", async () => { await withUser(alice, async () => { await sql`select 1`.execute(db); }); const seenByBob = await withUser(bob, async () => { const r = await sql<{ v: string }>`select current_setting('app.user_id', true) as v`.execute(db); return r.rows[0].v; }); expect(seenByBob).toBe(bob); expect(seenByBob).not.toBe(alice); }); it("hinterlässt auch nach einem Fehler keinen Kontext", async () => { // Ein Rollback muss die Variable genauso zurücknehmen wie ein Commit — // sonst wird ausgerechnet der Fehlerfall zur Lücke. await expect( withUser(alice, async () => { await sql`select 1 / 0`.execute(db); }) ).rejects.toThrow(); expect(await currentUserOnConnection()).toBeNull(); }); it("zeigt, dass eine Einstellung ohne Transaktion tatsächlich hängen bleibt", async () => { // Die Gegenprobe. Sie belegt, dass die Vorsichtsmassnahme kein Ritual // ist: ohne das dritte Argument `true` überlebt die Kennung die Anfrage // und die nächste Person erbt sie. await sql`select set_config('app.user_id', ${alice}, false)`.execute(db); expect(await currentUserOnConnection()).toBe(alice); // Aufräumen, sonst verfälscht dieser Test die anderen. await sql`select set_config('app.user_id', '', false)`.execute(db); expect(await currentUserOnConnection()).toBeNull(); }); });