// @vitest-environment node import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' import { createPrismaClient, type PrismaClient } from '@afis/db' import type { EffectivePermissions, Permission } from '@/lib/auth/permissions' /** * Nachträgliche Änderungen an Verlaufseinträgen, harte Regel 2. * Grundlage: docs/05-akten-und-vorlagen.md. */ const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL const suite = url ? describe : describe.skip let db: PrismaClient const marke = `edit-${Date.now()}` const AUTOR = `${marke}-autor` const FREMD = `${marke}-fremd` function perms(userId: string, level: number, permissions: Permission[]): EffectivePermissions { return { userId, clearanceLevel: level, permissions: new Set(permissions), isAdmin: false, hasMembership: true, } } const BASIS: Permission[] = ['case.create', 'case.edit', 'entry.create'] let caseId = '' let caseNumber = '' suite('Nachträgliche Änderungen am Verlauf', () => { beforeAll(async () => { db = createPrismaClient(url) await db.caseTemplate.create({ data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] }, }) for (const id of [AUTOR, FREMD]) { await db.user.create({ data: { id, username: id, displayName: id } }) } const { createCase } = await import('@/lib/cases/service') const akte = await createCase(perms(AUTOR, 5, BASIS), { title: 'Änderungsprüfung', classification: 1, templateKey: `${marke}-vorlage`, }) caseId = akte.id caseNumber = akte.caseNumber }) afterAll(async () => { // Die Änderungsrichtlinie ist eine einzige Zeile für die ganze Anwendung. // Wer sie im Test umstellt, stellt sie zurück, sonst kippt sie anderen // Testdateien den Auslieferungszustand um. await db.appSetting.update({ where: { id: 1 }, data: { entryEditPolicy: 'GRACE_WINDOW', entryEditGraceMinutes: 15, entryEditMarkGrace: false }, }) await db.$disconnect() }) beforeEach(async () => { await db.appSetting.update({ where: { id: 1 }, data: { entryEditPolicy: 'GRACE_WINDOW', entryEditGraceMinutes: 15, entryEditMarkGrace: false }, }) }) async function neuerEintrag(body: string, minClearance = 0): Promise { const { addEntry } = await import('@/lib/cases/service') const id = crypto.randomUUID() await addEntry(perms(AUTOR, 5, BASIS), { id, caseNumber, body, minClearance }) return id } it('versioniert eine Änderung und lässt Autor und Entstehungszeit unangetastet', async () => { const { editEntry } = await import('@/lib/cases/entryService') const id = await neuerEintrag('Ursprünglicher Wortlaut') const vorher = await db.caseEntry.findUniqueOrThrow({ where: { id } }) await editEntry(perms(AUTOR, 5, BASIS), { entryId: id, body: 'Korrigierter Wortlaut' }) const nachher = await db.caseEntry.findUniqueOrThrow({ where: { id } }) expect(nachher.body).toBe('Korrigierter Wortlaut') expect(nachher.version).toBe(2) expect(nachher.authorId).toBe(vorher.authorId) expect(nachher.createdAt.getTime()).toBe(vorher.createdAt.getTime()) // Der vorherige Stand steht in der Versionstabelle. const versionen = await db.caseEntryVersion.findMany({ where: { entryId: id } }) expect(versionen).toHaveLength(1) expect(versionen[0]?.body).toBe('Ursprünglicher Wortlaut') }) it('markiert eine Änderung in der Karenzzeit nicht sichtbar, protokolliert sie aber', async () => { const { editEntry } = await import('@/lib/cases/entryService') const id = await neuerEintrag('Tipfehler') await editEntry(perms(AUTOR, 5, BASIS), { entryId: id, body: 'Tippfehler' }) const eintrag = await db.caseEntry.findUniqueOrThrow({ where: { id } }) expect(eintrag.editVisible).toBe(false) expect(eintrag.editedAt).not.toBeNull() const protokoll = await db.auditLog.findFirst({ where: { targetId: id, action: 'entry.edit' }, orderBy: { at: 'desc' }, }) expect(protokoll).not.toBeNull() expect(protokoll?.meta).toMatchObject({ withinGrace: true, markVisible: false }) }) it('verlangt nach Ablauf der Karenzzeit das Recht entry.edit_own', async () => { const { editEntry } = await import('@/lib/cases/entryService') const { AccessError } = await import('@/lib/cases/service') const id = await neuerEintrag('Alter Eintrag') // Karenzzeit serverseitig auf null setzen — nicht über den Request. await db.appSetting.update({ where: { id: 1 }, data: { entryEditGraceMinutes: 0 } }) await expect( editEntry(perms(AUTOR, 5, BASIS), { entryId: id, body: 'Zu spät', changeReason: 'Nachträgliche Korrektur des Sachverhalts', }), ).rejects.toBeInstanceOf(AccessError) // Mit dem Recht geht es, und die Änderung ist sichtbar markiert. await editEntry(perms(AUTOR, 5, [...BASIS, 'entry.edit_own']), { entryId: id, body: 'Zu spät, aber erlaubt', changeReason: 'Nachträgliche Korrektur des Sachverhalts', }) const eintrag = await db.caseEntry.findUniqueOrThrow({ where: { id } }) expect(eintrag.editVisible).toBe(true) }) it('lässt einen fremden Eintrag ohne entry.edit_any nicht ändern', async () => { const { editEntry } = await import('@/lib/cases/entryService') const { AccessError } = await import('@/lib/cases/service') const id = await neuerEintrag('Vom Autor geschrieben') await expect( editEntry(perms(FREMD, 5, [...BASIS, 'entry.edit_own']), { entryId: id, body: 'Fremdzugriff', changeReason: 'Ich finde das besser so formuliert', }), ).rejects.toBeInstanceOf(AccessError) }) it('verlangt bei einem fremden Eintrag eine Begründung', async () => { const { editEntry } = await import('@/lib/cases/entryService') const { AccessError } = await import('@/lib/cases/service') const id = await neuerEintrag('Vom Autor geschrieben') await expect( editEntry(perms(FREMD, 5, [...BASIS, 'entry.edit_any']), { entryId: id, body: 'Ohne Grund' }), ).rejects.toBeInstanceOf(AccessError) await editEntry(perms(FREMD, 5, [...BASIS, 'entry.edit_any']), { entryId: id, body: 'Mit Grund', changeReason: 'Sachverhalt war falsch wiedergegeben', }) const version = await db.caseEntryVersion.findFirstOrThrow({ where: { entryId: id }, orderBy: { version: 'desc' }, }) expect(version.changeReason).toBe('Sachverhalt war falsch wiedergegeben') }) it('lässt einen Systemeintrag nicht ändern', async () => { const { editEntry } = await import('@/lib/cases/entryService') const { AccessError } = await import('@/lib/cases/service') const system = await db.caseEntry.findFirstOrThrow({ where: { caseId, type: 'SYSTEM' }, }) await expect( editEntry(perms(AUTOR, 5, [...BASIS, 'entry.edit_any']), { entryId: system.id, body: 'Umgeschrieben', changeReason: 'Ich will die Historie schönen', }), ).rejects.toBeInstanceOf(AccessError) }) it('zieht die Höherstufung auf alle bestehenden Versionen nach', async () => { // Das ist die Stelle, an der eine naive Umsetzung leckt: sonst liest // jemand mit niedrigerer Stufe den ursprünglichen Text in der Historie. const { editEntry } = await import('@/lib/cases/entryService') const id = await neuerEintrag('Zunächst harmlos', 0) await editEntry(perms(AUTOR, 5, [...BASIS, 'entry.edit_own']), { entryId: id, body: 'Immer noch harmlos', changeReason: 'Formulierung geglättet', }) await editEntry(perms(AUTOR, 5, [...BASIS, 'entry.edit_own']), { entryId: id, body: 'Jetzt vertraulich', minClearance: 4, changeReason: 'Enthält den Namen eines Zeugen', }) const versionen = await db.caseEntryVersion.findMany({ where: { entryId: id } }) expect(versionen.length).toBeGreaterThanOrEqual(2) for (const version of versionen) { expect(version.minClearance).toBe(4) } // Gegenprobe über die Sichtbarkeitsabfrage: Stufe 1 findet nichts davon. const { visibleEntryWhere } = await import('@/lib/access/caseFilter') const sichtbar = await db.caseEntry.findMany({ where: { AND: [visibleEntryWhere(perms('wer-auch-immer', 1, [])), { id }] }, }) expect(sichtbar).toHaveLength(0) }) it('zieht einen Eintrag mit Grabstein zurück und stellt ihn wieder her', async () => { const { retractEntry, restoreEntry } = await import('@/lib/cases/entryService') const { AccessError } = await import('@/lib/cases/service') const id = await neuerEintrag('Gehört in einen anderen Fall') await expect( retractEntry(perms(AUTOR, 5, BASIS), { entryId: id, reason: 'Falscher Fall' }), ).rejects.toBeInstanceOf(AccessError) await retractEntry(perms(AUTOR, 5, [...BASIS, 'entry.retract']), { entryId: id, reason: 'Gehört in den Fall AfiS-2026-0002', }) const zurueckgezogen = await db.caseEntry.findUniqueOrThrow({ where: { id } }) expect(zurueckgezogen.retractedAt).not.toBeNull() expect(zurueckgezogen.retractionReason).toContain('AfiS-2026-0002') // Der ursprüngliche Text bleibt in der Datenbank stehen. expect(zurueckgezogen.body).toBe('Gehört in einen anderen Fall') await restoreEntry(perms(AUTOR, 5, [...BASIS, 'entry.retract']), { entryId: id, reason: 'Zurückziehen war ein Versehen', }) const wiederhergestellt = await db.caseEntry.findUniqueOrThrow({ where: { id } }) expect(wiederhergestellt.retractedAt).toBeNull() const aktionen = await db.auditLog.findMany({ where: { targetId: id }, orderBy: { at: 'asc' }, select: { action: true }, }) expect(aktionen.map((a) => a.action)).toContain('entry.retract') expect(aktionen.map((a) => a.action)).toContain('entry.restore') }) it('lässt bei APPEND_ONLY gar keine Änderung zu', async () => { const { editEntry } = await import('@/lib/cases/entryService') const { AccessError } = await import('@/lib/cases/service') const id = await neuerEintrag('Unveränderlich') await db.appSetting.update({ where: { id: 1 }, data: { entryEditPolicy: 'APPEND_ONLY' } }) await expect( editEntry(perms(AUTOR, 5, [...BASIS, 'entry.edit_own', 'entry.edit_any']), { entryId: id, body: 'Doch geändert', changeReason: 'Sollte nicht durchgehen', }), ).rejects.toBeInstanceOf(AccessError) }) })