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.
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user