Files
AfiS-Aktensystem/packages/db/tests/regeln.test.ts
T
Harlekin 2608c7587f Datenbank- und Worker-Tests auf den Chat in der Akte nachgezogen
Die Regel „eine Nachricht gehört zu genau einem Ort" wird jetzt stärker
geprüft als vorher: zwei Orte, kein Ort und genau einer. Vorher hing sie
allein an der Anwendung, jetzt an einem CHECK.

Der Worker-Test prüft ausdrücklich, dass die Meldung den Namen der Person
nicht nennt.
2026-09-03 15:19:04 +02:00

302 lines
10 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.documentTemplate.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-${Date.now().toString(36)}`,
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 nur an genau einem Ort hängen', async () => {
const person = await db.personnelFile.create({
data: { displayName: `${marke}-person`, classification: 1 },
})
// Zwei Orte: geht nicht.
await expect(
db.chatMessage.create({
data: {
id: `${marke}-nachricht-zwei`,
caseId,
personnelFileId: person.id,
authorId: `${marke}-nutzer`,
body: 'gehört an zwei Stellen',
},
}),
).rejects.toThrow()
// Gar kein Ort: geht auch nicht. Eine Nachricht ohne Akte wäre ein
// Gespräch ohne Vorgang, und genau das soll es nicht mehr geben.
await expect(
db.chatMessage.create({
data: {
id: `${marke}-nachricht-null`,
authorId: `${marke}-nutzer`,
body: 'gehört nirgends hin',
},
}),
).rejects.toThrow()
// Einer: geht.
const gut = await db.chatMessage.create({
data: {
id: `${marke}-nachricht-eins`,
personnelFileId: person.id,
authorId: `${marke}-nutzer`,
body: 'gehört zur Personalakte',
},
})
expect(gut.personnelFileId).toBe(person.id)
})
it('lässt ein Dokument nicht löschen', async () => {
const doc = await db.document.create({
data: {
documentNumber: `PRF-9999-${Date.now().toString(36)}`,
title: 'Prüfdokument',
templateId: (await db.documentTemplate.findFirstOrThrow({
where: { key: `${marke}-vorlage` },
})).id,
templateKey: `${marke}-vorlage`,
templateVersion: 1,
caseId,
createdById: `${marke}-nutzer`,
},
})
// Die Regel schluckt das DELETE, statt zu werfen: so verhält sich
// DO INSTEAD NOTHING. Entscheidend ist, dass die Zeile stehen bleibt.
await db.document.deleteMany({ where: { id: doc.id } })
expect(await db.document.findUnique({ where: { id: doc.id } })).not.toBeNull()
})
it('lässt ein Dokument nicht an zwei Besitzern zugleich hängen', async () => {
const person = await db.personnelFile.create({
data: { displayName: `${marke} Person`, classification: 1 },
})
await expect(
db.document.create({
data: {
documentNumber: `PRF-9998-${Date.now().toString(36)}`,
title: 'Zwei Besitzer',
templateId: (await db.documentTemplate.findFirstOrThrow({
where: { key: `${marke}-vorlage` },
})).id,
templateKey: `${marke}-vorlage`,
templateVersion: 1,
caseId,
personnelFileId: person.id,
createdById: `${marke}-nutzer`,
},
}),
).rejects.toThrow()
})
it('lässt den Besitzer eines Dokuments nicht nachträglich wechseln', async () => {
const doc = await db.document.create({
data: {
documentNumber: `PRF-9997-${Date.now().toString(36)}`,
title: 'Unverrückbar',
templateId: (await db.documentTemplate.findFirstOrThrow({
where: { key: `${marke}-vorlage` },
})).id,
templateKey: `${marke}-vorlage`,
templateVersion: 1,
caseId,
createdById: `${marke}-nutzer`,
},
})
// Ein Vertrag, der nachträglich an einer anderen Person hängt, wäre eine
// Fälschung. Der Trigger lässt das nicht zu.
await expect(
db.document.update({ where: { id: doc.id }, data: { caseId: null } }),
).rejects.toThrow()
})
it('löst Schwärzungen im Suchindex stufenweise auf', async () => {
// Der Vektor zur Stufe N muss enthalten, was diese Stufe lesen darf — und
// nichts darüber. Sonst findet ein Leser der Stufe 4 einen Namen nicht,
// den er auf der Seite sieht, oder ein Leser der Stufe 1 einen, den er
// dort nur als Balken sieht.
const [zeile] = await db.$queryRawUnsafe<{ tief: string; hoch: string }[]>(
`SELECT strip_redactions_above($1, 1) AS tief,
strip_redactions_above($1, 4) AS hoch`,
'Offen [[c4]]Perreira[[/c4]] Schluss',
)
expect(zeile?.tief).not.toContain('Perreira')
expect(zeile?.tief).toContain('Offen')
expect(zeile?.hoch).toContain('Perreira')
// Die Marker selbst gehören nie in den Index.
expect(zeile?.hoch).not.toContain('[[c4]]')
})
it('schreibt für jede Stufe eine Indexzeile und zieht sie nach', async () => {
const stufen = await db.$queryRawUnsafe<{ minClearance: number }[]>(
`SELECT "minClearance" FROM "SearchIndex"
WHERE "targetType" = 'case' AND "targetId" = $1 ORDER BY "minClearance"`,
caseId,
)
expect(stufen.map((s) => s.minClearance)).toEqual([0, 1, 2, 3, 4, 5])
// Eine Änderung am Titel muss durchschlagen.
await db.case.update({ where: { id: caseId }, data: { title: 'Umbenannter Prüffall' } })
const [treffer] = await db.$queryRawUnsafe<{ gefunden: boolean }[]>(
`SELECT "vector" @@ websearch_to_tsquery('german', 'Umbenannter') AS gefunden
FROM "SearchIndex"
WHERE "targetType" = 'case' AND "targetId" = $1 AND "minClearance" = 0`,
caseId,
)
expect(treffer?.gefunden).toBe(true)
})
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)
})
})