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
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
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)
|
|
})
|
|
})
|