Die Dokumentebene aus docs/13 ist gebaut, additiv: Document, DocumentSection und DocumentSectionVersion neben Case, CaseSection und CaseEntry. Ein Dokument hängt an einer Fallakte, an einer Personalakte oder an nichts, höchstens an einem — gesichert durch eine CHECK-Bedingung. Es gibt kein DELETE; eine Zurücknahme lässt einen Grabstein stehen, der auch für den Admin zu ist. Eine neue Dokumentart ist damit ein Datensatz, kein Deployment. Im Seed stehen vier: vermerk, vorfallsbericht, sonderarbeitsvertrag (mehrseitiger Vertragstext mit Platzhaltern und Unterschriftenzeilen) und bussgeld. Zwei Umbenennungen, beide von Hand migriert, damit die vorhandenen Zeilen und Zählerstände stehen bleiben: - CaseTemplate -> DocumentTemplate, plus uses[] und layout - CaseCounter -> NumberSeries, ein Nummernkreis je Präfix für Aktenzeichen und Dokumentnummern Die Regeln für Abschnitte stehen jetzt einmal in lib/sections/render.ts und gelten für Fallakten und Dokumente gleichermaßen. Die Frage aus docs/13, ob CaseSection langfristig in die Dokumentebene wandert, ist damit verneint und begründet. Rückverweise auf der Personalakte laufen durch visibleCaseWhere, die Zahl daneben entsteht aus dem gefilterten Ergebnis. Auch die Rollengruppe verschwindet mit, sonst verriete "Betroffener" ohne Eintrag die Ermittlung. Die eigene Personalakte ist keine Ausnahme von der Freigabestufe. Vier neue Rechte: document.create, .edit, .finalize, .retract. Das letzte steht bei den zurückgehaltenen, es greift in Geschriebenes ein. 91 Tests im Web-Paket, 15 in der Datenbank, 28 e2e. Größte Route 118 kB gegen das Budget von 120 kB — die Luft wird knapp. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
274 lines
10 KiB
TypeScript
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)
|
|
})
|
|
})
|