Files
AfiS-Aktensystem/packages/db/tests/regeln.test.ts
T
Harlekin 319ef6a997
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
README erklärt das Projekt, Zusicherungen unter Nummern
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.
2026-09-04 08:42:07 +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 der README 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)
})
})