Files
AfiS-Aktensystem/packages/db/tests/regeln.test.ts
T
Harlekin 8f0644e053 Workspace und Datenmodell
pnpm-Workspace mit web/, worker/, pdf/ und packages/db. Das Datenbankpaket ist
eigenständig, weil Web-App und Worker denselben Prisma-Client brauchen.

Schema nach docs/04-datenmodell.md, mit drei Abweichungen, die dort begründet
sind: CaseParticipant bekommt einen eigenen Schlüssel statt eines
zusammengesetzten mit NULL-Spalten, die Aktenzeichen laufen über CaseCounter
statt über eine Sequenz pro Jahr, und ServiceHeartbeat kommt dazu, damit
/api/health den Gateway-Zustand kennt, ohne Discord zu fragen.

Die zweite Migration enthält, was Prisma nicht ausdrücken kann:
- kein DELETE auf CaseEntry, CaseEntryVersion und AuditLog
- authorId, createdAt, caseId und type eines Eintrags sind unveränderlich
- an einer Eintragsversion darf nur minClearance angehoben werden
- Volltextsuche mit deutscher Konfiguration; geschwärzte Bereiche werden vor
  dem Indizieren entfernt, sonst verrät ein Treffer den Inhalt
- pg_notify bei Rollen- und Mapping-Änderungen

Acht Tests prüfen diese Regeln gegen eine echte Postgres-Instanz, nicht gegen
die Anwendungslogik.
2026-09-01 00:01:29 +02:00

174 lines
6.0 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createPrismaClient, type PrismaClient } from '../src/index.js'
/**
* Prüft die harten Regeln aus CLAUDE.md dort, wo sie wirklich gelten: in der
* Datenbank. Die Anwendung kann man umgehen, diese Regeln nicht.
*
* Läuft gegen eine echte Postgres-Instanz. Ohne DATABASE_URL wird übersprungen.
*/
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
const suite = url ? describe : describe.skip
let db: PrismaClient
const marke = `test-${Date.now()}`
suite('Datenbankregeln', () => {
let caseId: string
let entryId: string
beforeAll(async () => {
db = createPrismaClient(url)
const template = await db.caseTemplate.create({
data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] },
})
const user = await db.user.create({
data: { id: `${marke}-nutzer`, username: 'pruef', displayName: 'Prüfer' },
})
const fall = await db.case.create({
data: {
caseNumber: `AfiS-9999-${String(Date.now()).slice(-4)}`,
title: 'Prüffall',
templateId: template.id,
templateKey: template.key,
createdById: user.id,
classification: 1,
},
})
caseId = fall.id
const eintrag = await db.caseEntry.create({
data: {
id: `${marke}-eintrag`,
caseId,
type: 'NOTE',
body: 'Ursprünglicher Text',
authorId: user.id,
minClearance: 0,
},
})
entryId = eintrag.id
})
afterAll(async () => {
// CaseEntry lässt sich nicht löschen, deshalb bleibt der Prüffall stehen.
// Aufgeräumt wird, was aufräumbar ist.
await db.caseSection.deleteMany({ where: { caseId } }).catch(() => undefined)
await db.$disconnect()
})
it('lässt einen Verlaufseintrag nicht löschen, auch nicht über rohes SQL', async () => {
await db.$executeRawUnsafe(`DELETE FROM "CaseEntry" WHERE id = $1`, entryId)
const danach = await db.caseEntry.findUnique({ where: { id: entryId } })
expect(danach).not.toBeNull()
expect(danach?.body).toBe('Ursprünglicher Text')
})
it('lässt Autor und Entstehungszeit eines Eintrags nicht ändern', async () => {
await expect(
db.$executeRawUnsafe(`UPDATE "CaseEntry" SET "authorId" = 'jemand-anders' WHERE id = $1`, entryId),
).rejects.toThrow(/unveränderlich/)
await expect(
db.$executeRawUnsafe(`UPDATE "CaseEntry" SET "createdAt" = now() WHERE id = $1`, entryId),
).rejects.toThrow(/unveränderlich/)
})
it('erlaubt an einer Eintragsversion nur das Anheben von minClearance', async () => {
const version = await db.caseEntryVersion.create({
data: { entryId, version: 1, body: 'Vorheriger Stand', minClearance: 0, editedById: `${marke}-nutzer` },
})
// Umklassifizierung wirkt rückwirkend: das muss durchgehen.
await db.caseEntryVersion.update({ where: { id: version.id }, data: { minClearance: 4 } })
const angehoben = await db.caseEntryVersion.findUnique({ where: { id: version.id } })
expect(angehoben?.minClearance).toBe(4)
// Absenken nicht, sonst leckt der alte Text über die Historie.
await expect(
db.caseEntryVersion.update({ where: { id: version.id }, data: { minClearance: 1 } }),
).rejects.toThrow(/nur angehoben/)
// Der Text bleibt unantastbar.
await expect(
db.caseEntryVersion.update({ where: { id: version.id }, data: { body: 'umgeschrieben' } }),
).rejects.toThrow(/anhängend/)
// Und gelöscht wird sie auch nicht.
await db.$executeRawUnsafe(`DELETE FROM "CaseEntryVersion" WHERE id = $1`, version.id)
expect(await db.caseEntryVersion.findUnique({ where: { id: version.id } })).not.toBeNull()
})
it('lässt einen Protokolleintrag weder ändern noch löschen', async () => {
const eintrag = await db.auditLog.create({
data: {
actorId: `${marke}-nutzer`,
action: 'case.read',
targetType: 'Case',
targetId: caseId,
clearanceAtTime: 1,
},
})
await db.$executeRawUnsafe(`DELETE FROM "AuditLog" WHERE id = ${eintrag.id}`)
await db.$executeRawUnsafe(`UPDATE "AuditLog" SET action = 'gefälscht' WHERE id = ${eintrag.id}`)
const danach = await db.auditLog.findUnique({ where: { id: eintrag.id } })
expect(danach?.action).toBe('case.read')
})
it('weist Freigabestufen ausserhalb von 0 bis 5 ab', async () => {
await expect(
db.$executeRawUnsafe(`UPDATE "Case" SET classification = 9 WHERE id = $1`, caseId),
).rejects.toThrow()
})
it('verlangt für einen Beteiligten mindestens eine Zuordnung', async () => {
await expect(
db.$executeRawUnsafe(
`INSERT INTO "CaseParticipant" (id, "caseId", role) VALUES ('x-${marke}', $1, 'WITNESS')`,
caseId,
),
).rejects.toThrow()
})
it('lässt eine Chatnachricht nicht zugleich an Akte und Kanal hängen', async () => {
const kanal = await db.channel.create({
data: { key: `${marke}-kanal`, name: 'Prüfkanal', createdById: `${marke}-nutzer` },
})
await expect(
db.chatMessage.create({
data: {
id: `${marke}-nachricht`,
caseId,
channelId: kanal.id,
authorId: `${marke}-nutzer`,
body: 'gehört nirgends hin',
},
}),
).rejects.toThrow()
})
it('nimmt geschwärzte Bereiche nicht in den Suchvektor auf', async () => {
await db.caseEntry.create({
data: {
id: `${marke}-geschwaerzt`,
caseId,
type: 'NOTE',
body: 'Offener Teil [[c4]]Zeugenname Perreira[[/c4]] Schluss',
authorId: `${marke}-nutzer`,
minClearance: 0,
},
})
const [treffer] = await db.$queryRawUnsafe<{ offen: boolean; geheim: boolean }[]>(
`SELECT "searchVector" @@ websearch_to_tsquery('german', 'Offener') AS offen,
"searchVector" @@ websearch_to_tsquery('german', 'Perreira') AS geheim
FROM "Case" WHERE id = $1`,
caseId,
)
expect(treffer?.offen).toBe(true)
expect(treffer?.geheim).toBe(false)
})
})