Files
alpenwerk-hr/tests/integration/session-context.test.ts
Maximilian Stubhan a66263a96e Put the session context under the app's own control
Erster Schritt weg von Supabase hin zu "läuft auf jedem PostgreSQL".

Gemessen sitzt die Kopplung nicht dort, wo der Begriff "Supabase-Projekt"
sie vermuten lässt: das Schema ist reines PostgreSQL, und von 58 RLS-Policies
rufen nur fünf auth.uid() direkt auf. Die übrigen 53 gehen über is_hr_user().
Diese eine Funktion ist die Brücke — wird sie umgelegt, folgt der Rest.

Die Migration legt sie um. app_current_user_id() liest jetzt zuerst
current_setting('app.user_id') und fällt nur ersatzweise auf auth.uid()
zurück. Deshalb plpgsql statt language sql: eine SQL-Funktion wird beim
Anlegen geparst, und auth.uid() gibt es auf einem gewöhnlichen PostgreSQL
nicht — die Migration liesse sich dort gar nicht erst anwenden. Der
Ausnahmeblock fängt das ab, und damit läuft dieselbe Migration auf beiden
Systemen. Der Rückfall verschwindet mit der Abschlussmigration.

Dazu app_users als Nachfolger von auth.users, external_id ist die oid des
Anbieters statt der E-Mail: eine Namensänderung darf kein zweites Konto
erzeugen.

Die neue Zugriffsschicht ist Kysely auf einem pg-Pool. Was daran zählt, ist
nicht der Query-Builder, sondern was er verhindert:

  - Die Kysely-Instanz wird nicht exportiert. Wer abfragen will, geht durch
    withUser() — und das öffnet immer eine Transaktion.
  - set_config(..., true) ist transaktionslokal. Ohne das dritte Argument
    bliebe die Kennung an der gepoolten Verbindung kleben und die nächste
    Anfrage liefe im Namen der vorherigen Person. In einer Personaldatenbank.
  - Eine ESLint-Regel verbietet den Import von pg und von lib/db/pool
    ausserhalb von lib/db. Nachgewiesen: eine Testdatei mit beiden Importen
    erzeugt zwei Fehler.
  - Einen privilegierten Zugang gibt es nicht mehr. asSystem() benutzt
    dieselbe Rolle ohne BYPASSRLS; was ohne angemeldete Person laufen darf,
    muss als SECURITY-DEFINER-Funktion in der Datenbank stehen.

tests/integration/session-context.test.ts läuft gegen einen Pool mit genau
einer Verbindung — sonst träfe er die Lücke mal und mal nicht. Er prüft, dass
nach Commit *und* nach Rollback nichts an der Verbindung zurückbleibt, und
belegt in einer Gegenprobe, dass eine Einstellung ohne Transaktion tatsächlich
hängen bleibt. Ein Sicherheitstest, der sich mangels DATABASE_URL selbst
überspringt, wäre schlimmer als keiner: in der CI schlägt schon das Fehlen
des Verbindungsstrings fehl.

Beim Schreiben der Migration stellte sich heraus, dass die Policies
hire_drafts_owner und saved_reports_owner heissen, nicht _own. Mit dem
geratenen Namen hätte drop policy nichts getroffen und create policy wäre mit
"already exists" abgebrochen.

Typecheck, Lint und 182 Tests sind grün. Die Anwendung läuft unverändert
weiter — sie benutzt die neue Schicht noch nicht.
2026-07-30 19:01:39 +02:00

119 lines
4.6 KiB
TypeScript

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<Record<string, never>>({ dialect: new PostgresDialect({ pool }) });
/** Die Fassung aus lib/db: transaktionslokal. */
async function withUser<T>(userId: string | null, fn: () => Promise<T>): Promise<T> {
return db.transaction().execute(async (tx) => {
await sql`select set_config('app.user_id', ${userId ?? ""}, true)`.execute(tx);
return fn();
});
}
async function currentUserOnConnection(): Promise<string | null> {
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();
});
});