// @vitest-environment node import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { createPrismaClient, loadEnvFromRepoRoot, type PrismaClient } from '@afis/db' import { visibleCaseWhere, visibleEntryWhere } from '@/lib/access/caseFilter' import type { EffectivePermissions, Permission } from '@/lib/auth/permissions' /** * Die Negativtests aus docs/03-auth-und-rechte.md. * * Sie laufen gegen eine echte Datenbank, weil genau das der Punkt ist: die * Prüfung muss in der Abfrage stecken, nicht in einem Filter danach. Ein Test * gegen eine ausgedachte Liste würde die Aussage nicht treffen. */ loadEnvFromRepoRoot() const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL const suite = url ? describe : describe.skip let db: PrismaClient const marke = `zugriff-${Date.now()}` function perms( userId: string, clearanceLevel: number, options: { isAdmin?: boolean; permissions?: Permission[] } = {}, ): EffectivePermissions { return { userId, clearanceLevel, permissions: new Set(options.permissions ?? []), isAdmin: options.isAdmin ?? false, hasMembership: true, } } suite('Sichtbarkeit von Akten', () => { const ids = { offen: `${marke}-offen`, geheim: `${marke}-geheim`, versiegelt: `${marke}-versiegelt`, } const nutzer = { stufe1: `${marke}-stufe1`, stufe2: `${marke}-stufe2`, stufe5: `${marke}-stufe5`, beteiligt: `${marke}-beteiligt`, admin: `${marke}-admin`, } beforeAll(async () => { db = createPrismaClient(url) const template = await db.documentTemplate.create({ data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] }, }) for (const id of Object.values(nutzer)) { await db.user.create({ data: { id, username: id, displayName: id } }) } const gemeinsam = { templateId: template.id, templateKey: template.key, createdById: nutzer.stufe5 } await db.case.create({ data: { ...gemeinsam, id: ids.offen, caseNumber: `AfiS-9001-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, title: 'Offener Vorgang', classification: 1, status: 'OPEN', }, }) await db.case.create({ data: { ...gemeinsam, id: ids.geheim, caseNumber: `AfiS-9002-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, title: 'Geheimer Vorgang', classification: 4, status: 'OPEN', }, }) await db.case.create({ data: { ...gemeinsam, id: ids.versiegelt, caseNumber: `AfiS-9003-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, title: 'Versiegelter Vorgang', classification: 1, status: 'SEALED', }, }) // Als Ermittler an der geheimen Akte, obwohl seine Stufe nicht reicht. // Die Rolle ist hier wesentlich: nur wer an der Akte arbeitet, öffnet sie. await db.caseParticipant.create({ data: { caseId: ids.geheim, userId: nutzer.beteiligt, role: 'INVESTIGATOR' }, }) // Ausgeschlossen von der offenen Akte, obwohl Stufe 5 und Admin. await db.caseExclusion.createMany({ data: [ { caseId: ids.offen, userId: nutzer.stufe5, reason: 'Betroffener' }, { caseId: ids.offen, userId: nutzer.admin, reason: 'Betroffener' }, ], }) }) afterAll(async () => { await db.$disconnect() }) async function sichtbar(p: EffectivePermissions): Promise { const rows = await db.case.findMany({ where: { AND: [visibleCaseWhere(p), { id: { in: Object.values(ids) } }] }, select: { id: true }, }) return rows.map((r) => r.id).sort() } it('zeigt einer Stufe 2 die Stufe-4-Akte nicht, auch nicht als Treffer', async () => { const p = perms(nutzer.stufe2, 2) const treffer = await sichtbar(p) expect(treffer).not.toContain(ids.geheim) expect(treffer).toContain(ids.offen) // Auch die Zählung darf sie nicht enthalten, sonst verrät die Lücke sie. const anzahl = await db.case.count({ where: { AND: [visibleCaseWhere(p), { id: { in: Object.values(ids) } }] }, }) expect(anzahl).toBe(treffer.length) }) it('gibt einem ausgeschlossenen Nutzer mit Stufe 5 keinen Zugriff', async () => { const treffer = await sichtbar(perms(nutzer.stufe5, 5)) expect(treffer).not.toContain(ids.offen) }) it('schlägt selbst den Admin, wenn er ausgeschlossen ist', async () => { // Der Ausschluss deckt den Fall ab, dass gegen einen hochrangigen // Mitarbeiter ermittelt wird. Der Administrator ist einer. const treffer = await sichtbar(perms(nutzer.admin, 5, { isAdmin: true })) expect(treffer).not.toContain(ids.offen) expect(treffer).toContain(ids.geheim) }) it('lässt einen Ermittler auch über seiner Stufe lesen', async () => { const treffer = await sichtbar(perms(nutzer.beteiligt, 1)) expect(treffer).toContain(ids.geheim) }) it('verbirgt versiegelte Akten unterhalb von Stufe 5', async () => { expect(await sichtbar(perms(nutzer.stufe2, 2))).not.toContain(ids.versiegelt) expect(await sichtbar(perms(nutzer.stufe1, 1))).not.toContain(ids.versiegelt) expect(await sichtbar(perms(nutzer.stufe5, 5))).toContain(ids.versiegelt) }) it('verbirgt Verlaufseinträge oberhalb der Stufe des Lesers', async () => { await db.caseEntry.createMany({ data: [ { id: `${marke}-eintrag-offen`, caseId: ids.offen, type: 'NOTE', body: 'Für alle lesbar', authorId: nutzer.stufe1, minClearance: 0, }, { id: `${marke}-eintrag-hoch`, caseId: ids.offen, type: 'NOTE', body: 'Nur ab Stufe 4', authorId: nutzer.stufe1, minClearance: 4, }, ], }) const p = perms(nutzer.stufe1, 1) const eintraege = await db.caseEntry.findMany({ where: { AND: [visibleEntryWhere(p), { caseId: ids.offen }] }, select: { id: true }, }) const gefunden = eintraege.map((e) => e.id) expect(gefunden).toContain(`${marke}-eintrag-offen`) expect(gefunden).not.toContain(`${marke}-eintrag-hoch`) }) it('öffnet die Akte nicht für Zeugen, Betroffene und Täter', async () => { // Die Beteiligtenliste beantwortet zwei Fragen: wer bearbeitet den Fall, // und um wen geht es darin. Nur die erste verschafft Zugang — sonst läse // der Beschuldigte die Ermittlung gegen sich selbst mit. const vorlage = await db.documentTemplate.findFirstOrThrow({ where: { key: { startsWith: marke } }, }) const akte = await db.case.create({ data: { templateId: vorlage.id, templateKey: vorlage.key, createdById: nutzer.stufe5, caseNumber: `Rol-2026-${Date.now().toString(36)}`, title: 'Ermittlung', classification: 4, status: 'OPEN', }, }) const p = perms(nutzer.stufe1, 1) for (const rolle of ['WITNESS', 'SUBJECT', 'SUSPECT', 'ACCOMPLICE'] as const) { await db.caseParticipant.create({ data: { caseId: akte.id, userId: nutzer.stufe1, role: rolle }, }) const sichtbar = await db.case.findFirst({ where: { AND: [visibleCaseWhere(p), { id: akte.id }] }, select: { id: true }, }) expect(sichtbar, `Rolle ${rolle} darf die Akte nicht öffnen`).toBeNull() await db.caseParticipant.deleteMany({ where: { caseId: akte.id, userId: nutzer.stufe1, role: rolle }, }) } // Gegenprobe: als Ermittler geht es, sonst prüft der Test nichts. await db.caseParticipant.create({ data: { caseId: akte.id, userId: nutzer.stufe1, role: 'INVESTIGATOR' }, }) const alsErmittler = await db.case.findFirst({ where: { AND: [visibleCaseWhere(p), { id: akte.id }] }, select: { id: true }, }) expect(alsErmittler).not.toBeNull() }) })