Die README beschrieb das Vorgehen statt der Anwendung. Sie erklärt jetzt, was das System kann, wie es aufgebaut ist und wie man es lokal und im Betrieb fährt. Dazu tragen die acht Zusicherungen dort ihre Nummern - Berechtigungsprüfung in der Abfrage, kein DELETE im Verlauf, Protokollpflicht ab Stufe 3, Magic Bytes statt Dateiendungen, Skelette statt Spinner, Existenz als Information, keine Geheimnisse im Repository, keine Einmal-Container. Achtzehn Dateien verwiesen für ebendiese Regeln auf eine Datei, die nicht mehr mitverfolgt wird; sie zeigen jetzt auf die README. Arbeitsunterlagen bleiben auf der Platte, werden aber nicht mehr verfolgt. Die Ausschlussregel steht in .git/info/exclude statt in .gitignore, damit sie nicht selbst im Repository steht.
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' },
|
|
})
|
|
|
|
// Zusicherung 3 der README 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()
|
|
})
|
|
})
|