// @vitest-environment node import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { createPrismaClient, type PrismaClient } from '@afis/db' import type { EffectivePermissions, Permission } from '@/lib/auth/permissions' /** * Die Schreibgrenzen aus docs/03-auth-und-rechte.md, geprüft gegen die * Fachlogik selbst — nicht gegen die Oberfläche. Eine Server Action ist ein * Endpunkt: was hier durchgeht, geht auch durch, wenn jemand das Formular * umgeht. */ const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL const suite = url ? describe : describe.skip let db: PrismaClient const marke = `schreib-${Date.now()}` function perms( clearanceLevel: number, permissions: Permission[], options: { isAdmin?: boolean; userId?: string } = {}, ): EffectivePermissions { return { userId: options.userId ?? `${marke}-n${clearanceLevel}`, clearanceLevel, permissions: new Set(permissions), isAdmin: options.isAdmin ?? false, hasMembership: true, } } const VOLL: Permission[] = ['case.create', 'case.edit', 'case.close', 'case.reopen', 'entry.create'] suite('Schreibgrenzen der Freigabestufe', () => { beforeAll(async () => { db = createPrismaClient(url) await db.caseTemplate.create({ data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] }, }) for (const level of [1, 2, 4, 5]) { const id = `${marke}-n${level}` await db.user.create({ data: { id, username: id, displayName: id } }) } }) afterAll(async () => { await db.$disconnect() }) it('lässt Stufe 2 keine Stufe-4-Akte anlegen', async () => { const { createCase, AccessError } = await import('@/lib/cases/service') await expect( createCase(perms(2, VOLL), { title: 'Zu hoch gegriffen', classification: 4, templateKey: `${marke}-vorlage`, }), ).rejects.toBeInstanceOf(AccessError) }) it('lässt Stufe 2 eine Stufe-2-Akte anlegen und vergibt ein Aktenzeichen', async () => { const { createCase } = await import('@/lib/cases/service') const angelegt = await createCase(perms(2, VOLL), { title: 'Passt', classification: 2, templateKey: `${marke}-vorlage`, }) expect(angelegt.caseNumber).toMatch(/^AfiS-\d{4}-\d{4}$/) // Der Anleger ist Ermittler, sonst verliert er die Akte beim Hochstufen. const beteiligt = await db.caseParticipant.count({ where: { caseId: angelegt.id, userId: `${marke}-n2`, role: 'LEAD' }, }) expect(beteiligt).toBe(1) }) it('vergibt Aktenzeichen ohne Doppelung, auch bei gleichzeitigem Anlegen', async () => { const { createCase } = await import('@/lib/cases/service') const ergebnisse = await Promise.all( Array.from({ length: 8 }, (_, i) => createCase(perms(5, VOLL), { title: `Parallel ${i}`, classification: 1, templateKey: `${marke}-vorlage`, }), ), ) const nummern = ergebnisse.map((r) => r.caseNumber) expect(new Set(nummern).size).toBe(nummern.length) }) it('gibt einer zu niedrigen Stufe nicht einmal die Existenz preis', async () => { const { createCase, loadVisibleCase } = await import('@/lib/cases/service') const geheim = await createCase(perms(5, VOLL), { title: 'Streng geheim', classification: 4, templateKey: `${marke}-vorlage`, }) // Stufe 2 bekommt null, nicht "verboten": daraus wird 404, nicht 403. expect(await loadVisibleCase(perms(2, VOLL), geheim.caseNumber)).toBeNull() expect(await loadVisibleCase(perms(5, VOLL), geheim.caseNumber)).not.toBeNull() }) it('lässt niemanden über die eigene Stufe hochstufen', async () => { const { createCase, reclassify, AccessError } = await import('@/lib/cases/service') const akte = await createCase(perms(2, VOLL), { title: 'Bleibt auf 2', classification: 2, templateKey: `${marke}-vorlage`, }) await expect( reclassify(perms(2, VOLL), { caseNumber: akte.caseNumber, to: 4 }), ).rejects.toBeInstanceOf(AccessError) const unveraendert = await db.case.findUnique({ where: { id: akte.id } }) expect(unveraendert?.classification).toBe(2) }) it('verlangt für eine Herabstufung eine Begründung und hält sie im Verlauf fest', async () => { const { createCase, reclassify, AccessError } = await import('@/lib/cases/service') const akte = await createCase(perms(5, VOLL), { title: 'Wird herabgestuft', classification: 4, templateKey: `${marke}-vorlage`, }) await expect( reclassify(perms(5, VOLL), { caseNumber: akte.caseNumber, to: 1, reason: 'kurz' }), ).rejects.toBeInstanceOf(AccessError) await reclassify(perms(5, VOLL), { caseNumber: akte.caseNumber, to: 1, reason: 'Ermittlung abgeschlossen, keine Verschlusssache mehr', }) const eintraege = await db.caseEntry.findMany({ where: { caseId: akte.id, type: 'SYSTEM' }, orderBy: { createdAt: 'desc' }, }) expect(eintraege[0]?.body).toContain('herabgesetzt') expect(eintraege[0]?.body).toContain('Ermittlung abgeschlossen') const protokoll = await db.auditLog.findFirst({ where: { targetId: akte.id, action: 'case.reclassify' }, orderBy: { at: 'desc' }, }) expect(protokoll?.meta).toMatchObject({ from: 4, to: 1, lowering: true }) }) it('lässt eine archivierte Akte nicht mehr verändern, auch nicht als Admin', async () => { const { createCase, addEntry, AccessError } = await import('@/lib/cases/service') const admin = perms(5, VOLL, { isAdmin: true, userId: `${marke}-n5` }) const akte = await createCase(admin, { title: 'Archiviert', classification: 1, templateKey: `${marke}-vorlage`, }) await db.case.update({ where: { id: akte.id }, data: { status: 'ARCHIVED' } }) await expect( addEntry(admin, { id: crypto.randomUUID(), caseNumber: akte.caseNumber, body: 'Nachtrag' }), ).rejects.toBeInstanceOf(AccessError) }) it('behandelt denselben Vermerk zweimal gesendet als einen', async () => { const { createCase, addEntry } = await import('@/lib/cases/service') const akte = await createCase(perms(5, VOLL), { title: 'Doppelt gesendet', classification: 1, templateKey: `${marke}-vorlage`, }) const id = crypto.randomUUID() await addEntry(perms(5, VOLL), { id, caseNumber: akte.caseNumber, body: 'Einmal' }) await addEntry(perms(5, VOLL), { id, caseNumber: akte.caseNumber, body: 'Einmal' }) const anzahl = await db.caseEntry.count({ where: { caseId: akte.id, type: 'NOTE' } }) expect(anzahl).toBe(1) }) })