Files
tessera-ctl/apps/api/src/groups/migration-sql.spec.ts
T
schalli 92e8eaffa5 feat(15-01): RLS policies for Group/GroupMembership/ModuleGrant (T-15-11)
- Second, deliberately separate migration (pure hand-SQL, no Prisma-
  generated DDL): ENABLE/FORCE ROW LEVEL SECURITY plus a
  tenant_isolation_policy for each of the three new tables, following
  the pattern of 20260618112133_rls_policies (Auth-Kerntabellen)
  rather than the RLS-exempt Tender* app-layer tables
- Group/ModuleGrant compare tenantId directly against
  current_tenant_id(); GroupMembership has no own tenantId and follows
  the PasswordResetToken join pattern (groupId IN (SELECT id FROM
  Group WHERE tenantId = ...))
- migration-sql.spec.ts extended with a second describe block covering
  both migration files (6x ROW LEVEL SECURITY, 3x CREATE POLICY, the
  join vs. direct-comparison shape)
- Re-ran the Task-2 end-to-end proof after applying this migration:
  identical result (USER without grant 403 + empty list, USER with
  direct grant 200 + slug present, ADMIN 200) — the app's DB role
  (tessera) is a Postgres superuser with rolbypassrls=true, so it
  bypasses RLS as documented as an acceptable outcome by the plan;
  RLS remains the defense-in-depth net for any future non-superuser
  connection
2026-08-04 15:11:33 +02:00

97 lines
4.1 KiB
TypeScript

import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
/**
* Prüft die hand-editierten SQL-Ergänzungen in den beiden Phase-15-
* Migrationen (Plan 15-01, Task 1 + Task 3), ohne eine Datenbank zu
* brauchen — reiner Textabgleich der generierten migration.sql-Dateien.
* Stil folgt dem Repo-Muster hand-editierter Migrationen
* (20260618112133_rls_policies, 20260721150000_tender_cpv_divisions_backfill).
*/
const MIGRATIONS_DIR = join(__dirname, '../../prisma/migrations');
function readMigrationSql(suffix: string): string {
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.endsWith(suffix))
.map((entry) => entry.name);
if (dirs.length !== 1) {
throw new Error(
`Expected exactly one migration directory ending in "${suffix}", found ${dirs.length}: ${dirs.join(', ')}`,
);
}
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
}
describe('add_groups_and_module_grants migration.sql (D-04, D-06, D-13)', () => {
const sql = readMigrationSql('_add_groups_and_module_grants');
it('erzwingt genau eine Standardgruppe pro Mandant (D-13, partieller Unique-Index)', () => {
expect(sql).toContain('Group_one_default_per_tenant');
expect(sql).toContain('WHERE "isDefault" = true');
});
it('erzwingt die Entweder-oder-Beziehung Gruppe XOR Benutzer per CHECK-Constraint (D-04)', () => {
expect(sql).toContain('ModuleGrant_group_xor_user');
expect(sql).toContain('num_nonnulls("groupId", "userId") = 1');
});
it('schützt beide ModuleGrant-Varianten (Gruppe/Benutzer) je Modul über partielle Unique-Indizes', () => {
expect(sql).toContain('ModuleGrant_tenant_module_group_unique');
expect(sql).toContain('ModuleGrant_tenant_module_user_unique');
});
it('D-06-Backfill: alle drei INSERT-Statements verwenden gen_random_uuid() für neue IDs', () => {
const occurrences = sql.match(/gen_random_uuid\(\)/g) ?? [];
expect(occurrences.length).toBe(3);
});
it('D-06-Backfill: alle drei INSERT-Statements tragen einen NOT-EXISTS-Wächter (idempotent bei Wiederholungslauf)', () => {
const occurrences = sql.match(/NOT EXISTS/g) ?? [];
expect(occurrences.length).toBe(3);
});
it('D-06-Backfill läuft in der Reihenfolge Group vor GroupMembership vor ModuleGrant', () => {
const groupIdx = sql.indexOf('INSERT INTO "Group"');
const membershipIdx = sql.indexOf('INSERT INTO "GroupMembership"');
const grantIdx = sql.indexOf('INSERT INTO "ModuleGrant"');
expect(groupIdx).toBeGreaterThan(-1);
expect(membershipIdx).toBeGreaterThan(-1);
expect(grantIdx).toBeGreaterThan(-1);
expect(groupIdx).toBeLessThan(membershipIdx);
expect(membershipIdx).toBeLessThan(grantIdx);
});
});
describe('groups_rls_policies migration.sql (T-15-11)', () => {
const sql = readMigrationSql('_groups_rls_policies');
it('aktiviert ENABLE und FORCE ROW LEVEL SECURITY für alle drei Tabellen (6 Anweisungen)', () => {
const occurrences = sql.match(/ROW LEVEL SECURITY/g) ?? [];
expect(occurrences.length).toBe(6);
for (const table of ['"Group"', '"GroupMembership"', '"ModuleGrant"']) {
expect(sql).toContain(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY`);
expect(sql).toContain(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY`);
}
});
it('legt für jede der drei Tabellen eine tenant_isolation_policy an (3 CREATE POLICY)', () => {
const occurrences = sql.match(/CREATE POLICY tenant_isolation_policy/g) ?? [];
expect(occurrences.length).toBe(3);
});
it('GroupMembership folgt dem Join-Muster (kein direktes tenantId, Join über Group)', () => {
expect(sql).toContain('"groupId" IN (');
expect(sql).toContain('SELECT "id" FROM "Group" WHERE "tenantId" = current_tenant_id()');
});
it('Group und ModuleGrant vergleichen direkt gegen current_tenant_id() (eigene tenantId-Spalte)', () => {
const directPolicyCount = (sql.match(/USING \("tenantId" = current_tenant_id\(\)\)/g) ?? []).length;
expect(directPolicyCount).toBe(2);
});
});