Die restlichen zwei Änderungswünsche. **Protokoll.** „Wer was wo macht" stand seit dem ersten Tag in AuditLog — harte Regel 3 sorgt dafür. Was fehlte, war die Ansicht darauf. /admin/protokoll ist genau das und keine zweite Aufzeichnung: filterbar nach Person, Aktion, Art und Zeitraum, rückwärts blätternd. Nur für die Leitung. Eine Zeile nennt Aktenzeichen und Namen, und schon die Existenz einer Zeile verrät, dass es einen Vorgang gibt; ein gefiltertes Protokoll für alle wäre ein Weg, die Sichtbarkeitsregeln von hinten aufzurollen. Aus demselben Grund werden Kennungen ohne Sichtbarkeitsfilter zu Aktenzeichen aufgelöst — ein Protokoll aus UUIDs liest niemand, und eines, das niemand liest, ist keine Kontrolle. **Speichern.** Jedes Feld trug seinen eigenen Knopf: ein Arbeitsvertrag mit zwölf Feldern hiess zwölfmal speichern. Der Knopf steht jetzt am Blatt. Geschrieben wird trotzdem je Feld und nicht als Transaktion, und das folgt aus dem Datenmodell: Abschnitte sind einzeln versioniert, weil mehrere Leute gleichzeitig an einer Akte arbeiten. Als Transaktion blockierte ein einziges zwischenzeitlich geändertes Feld das ganze Blatt — elf gute Änderungen verloren, weil jemand das zwölfte angefasst hat. So geht durch, was durchgeht, und die Rückmeldung sagt, was offen blieb und wo. Lichtbilder bleiben ausgenommen: ein Upload mit Prüfkette ist keine Feldänderung. lib/audit.ts liegt jetzt als lib/audit/write.ts neben lib/audit/query.ts.
127 lines
4.4 KiB
TypeScript
127 lines
4.4 KiB
TypeScript
// @vitest-environment node
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
import { db } from '@/lib/db'
|
|
import { writeAudit } from '@/lib/audit/write'
|
|
import { queryAuditLog, AUDIT_PAGE } from '@/lib/audit/query'
|
|
|
|
/**
|
|
* Das Protokoll ist die Gegenprobe zum ganzen System: hier soll stehen, was
|
|
* passiert ist, vollständig und unverändert. Geprüft wird deshalb zweierlei —
|
|
* dass die Filter genau schneiden, und dass eine Zeile nicht verschwinden
|
|
* kann, wenn ihr Ziel verschwindet.
|
|
*/
|
|
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
|
|
const suite = url ? describe : describe.skip
|
|
|
|
const marke = `prot-${Date.now()}`
|
|
|
|
suite('Zugriffsprotokoll', () => {
|
|
const akteur = `${marke}-akteur`
|
|
const anderer = `${marke}-anderer`
|
|
let fallId = ''
|
|
let fallNummer = ''
|
|
|
|
beforeAll(async () => {
|
|
const vorlage = await db.documentTemplate.create({
|
|
data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] },
|
|
})
|
|
for (const id of [akteur, anderer]) {
|
|
await db.user.create({ data: { id, username: id, displayName: `Name ${id}` } })
|
|
}
|
|
|
|
const akte = await db.case.create({
|
|
data: {
|
|
templateId: vorlage.id,
|
|
templateKey: vorlage.key,
|
|
createdById: akteur,
|
|
caseNumber: `Prot-2026-${Date.now().toString(36)}`,
|
|
title: 'Vorgang',
|
|
classification: 1,
|
|
status: 'OPEN',
|
|
},
|
|
})
|
|
fallId = akte.id
|
|
fallNummer = akte.caseNumber
|
|
|
|
await writeAudit(db, {
|
|
actorId: akteur,
|
|
action: 'case.create',
|
|
targetType: 'Case',
|
|
targetId: fallId,
|
|
clearanceAtTime: 4,
|
|
})
|
|
await writeAudit(db, {
|
|
actorId: anderer,
|
|
action: 'case.read',
|
|
targetType: 'Case',
|
|
targetId: fallId,
|
|
clearanceAtTime: 1,
|
|
})
|
|
await writeAudit(db, {
|
|
actorId: akteur,
|
|
action: 'export.pdf',
|
|
targetType: 'ExportJob',
|
|
targetId: `${marke}-auftrag`,
|
|
clearanceAtTime: 4,
|
|
})
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await db.$disconnect()
|
|
})
|
|
|
|
it('filtert nach Person und schneidet dabei genau', async () => {
|
|
const { rows } = await queryAuditLog({ actorId: akteur })
|
|
const eigene = rows.filter((zeile) => zeile.actorId === akteur)
|
|
expect(eigene).toHaveLength(rows.length)
|
|
expect(rows.some((zeile) => zeile.action === 'case.create')).toBe(true)
|
|
expect(rows.some((zeile) => zeile.actorId === anderer)).toBe(false)
|
|
})
|
|
|
|
it('filtert nach Aktion und nach Ziel', async () => {
|
|
const nurLesen = await queryAuditLog({ action: 'case.read', targetId: fallId })
|
|
expect(nurLesen.rows).toHaveLength(1)
|
|
expect(nurLesen.rows[0]?.actorId).toBe(anderer)
|
|
|
|
const nurExport = await queryAuditLog({ targetType: 'ExportJob', actorId: akteur })
|
|
expect(nurExport.rows.every((zeile) => zeile.targetType === 'ExportJob')).toBe(true)
|
|
})
|
|
|
|
it('macht aus Kennungen Aktenzeichen und lässt Unauflösbares stehen', async () => {
|
|
const { rows } = await queryAuditLog({ actorId: akteur })
|
|
|
|
const zurAkte = rows.find((zeile) => zeile.targetId === fallId)
|
|
expect(zurAkte?.targetLabel).toBe(fallNummer)
|
|
expect(zurAkte?.targetHref).toBe(`/faelle/${fallNummer}`)
|
|
|
|
// Ein Auftrag ist kein Datensatz, den das Protokoll auflösen kann. Die
|
|
// Zeile bleibt trotzdem stehen — ein Protokoll mit Lücken wäre keins.
|
|
const zumAuftrag = rows.find((zeile) => zeile.targetType === 'ExportJob')
|
|
expect(zumAuftrag).toBeDefined()
|
|
expect(zumAuftrag?.targetLabel).toBeNull()
|
|
})
|
|
|
|
it('blättert rückwärts ohne Zeile doppelt oder gar nicht zu zeigen', async () => {
|
|
const erste = await queryAuditLog({})
|
|
expect(erste.rows.length).toBeLessThanOrEqual(AUDIT_PAGE)
|
|
|
|
if (erste.more) {
|
|
const zweite = await queryAuditLog({ before: BigInt(erste.rows[erste.rows.length - 1]!.id) })
|
|
const kennungen = new Set(erste.rows.map((zeile) => zeile.id))
|
|
expect(zweite.rows.some((zeile) => kennungen.has(zeile.id))).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('lässt sich nicht ändern und nicht entfernen', async () => {
|
|
const zeile = await db.auditLog.findFirstOrThrow({
|
|
where: { actorId: akteur, action: 'case.create' },
|
|
})
|
|
|
|
// Harte Regel 3 aus CLAUDE.md steht als Regel in der Datenbank, nicht nur
|
|
// in der Anwendung: das Löschen läuft ins Leere statt zu wirken.
|
|
await db.auditLog.deleteMany({ where: { id: zeile.id } })
|
|
const danach = await db.auditLog.findUnique({ where: { id: zeile.id } })
|
|
expect(danach).not.toBeNull()
|
|
})
|
|
})
|