Files
AfiS-Aktensystem/web/tests/eintragsaenderung.test.ts
T
Harlekin e0b03ea336
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +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.documentTemplate.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)
})
})