Files
AfiS-Aktensystem/web/tests/eintragsaenderung.test.ts
T
HarlekinandClaude Opus 5 0a7fd15ab6 Phase 2 abgeschlossen: nachträgliche Änderungen, Grabstein, Richtlinie
Der Kern von harter Regel 2. Jede Änderung an einem Verlaufseintrag schreibt
den vorherigen Stand nach CaseEntryVersion und erhöht die Versionsnummer.
Autor und Entstehungszeit bleiben unangetastet, geprüft gegen die Datenbank.

Die Karenzzeit wird serverseitig aus AppSetting gelesen, nie aus dem Request.
Eine Änderung darin wird versioniert und protokolliert, aber nicht sichtbar
markiert — ein Tippfehler, zwei Minuten später korrigiert, soll die Akte nicht
mit "bearbeitet"-Marken zupflastern. Alles danach ist markiert und verlangt
eine Begründung, ebenso jede Änderung an einem fremden Eintrag.

Die Stelle, an der eine naive Umsetzung leckt, hat einen eigenen Test: wird
minClearance eines Eintrags angehoben, erben alle bestehenden Versionen den
neuen Wert. Sonst liest jemand mit niedrigerer Stufe den ursprünglichen Text
einfach in der Historie.

Zurückziehen setzt einen Grabstein mit Pflichtbegründung, der Text bleibt in
der Datenbank. Rückgängig machen geht und wird wiederum protokolliert.
Systemeinträge sind von jeder Änderung ausgenommen.

Dazu die Änderungsübersicht als Reiter an der Akte — sichtbar für alle, die
die Akte lesen dürfen, nicht nur für die Leitung — und die Adminseite für die
Änderungsrichtlinie.

Zwei Testprobleme dabei behoben: AppSetting ist eine einzige Zeile für die
ganze Anwendung, deshalb laufen die Web-Tests jetzt nacheinander, und die
Testdatenbank startet bei jedem Lauf im Auslieferungszustand. Vorher hing das
Ergebnis davon ab, was der vorige Lauf hinterlassen hatte.

63 Tests, zweimal hintereinander grün. Bündel bei 110,5 kB von 120.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 23:11:42 +02:00

274 lines
10 KiB
TypeScript

// @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<string> {
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)
})
})