Files
AfiS-Aktensystem/packages/db/tests/regeln.test.ts
T
Harlekin a49f4b5956 Phase 7 abgeschlossen: Suche mit gestuftem Index, Facetten, Palette
Der bisherige Suchvektor enthielt nur, was jeder sehen darf. Das war
leck-sicher und unbrauchbar zugleich: ab Stufe 1 fand die Suche nichts von
dem, was einen Fall ausmacht.

SearchIndex hält jetzt sechs Zeilen je Ziel, eine pro Freigabestufe. Die Zeile
zur Stufe N enthält alles, was ein Leser dieser Stufe sehen darf. Eine Suche
nimmt genau diese eine Zeile und kann deshalb nichts finden, was sie nicht
lesen darf — auch keinen höher eingestuften Eintrag in einer sichtbaren Akte.

Schwärzungen werden dabei stufenweise aufgelöst: strip_redactions_above
entfernt nur, was über der Stufe liegt. Sonst fände ein Leser der Stufe 4
einen Namen nicht, den er auf der Seite im Klartext sieht, und ein Leser der
Stufe 1 einen, den er dort nur als Balken sieht.

Die Suchbedingung ist nicht die Zugriffsprüfung: der Volltext liefert
Kandidaten, die Sichtbarkeit kommt aus visibleCaseWhere — derselben Funktion
wie in der Fallliste, und Treffer, Trefferzahl und Facettenzähler entstehen
gemeinsam in einer Abfrage. Eine in SQL nachgebaute Sichtbarkeitsregel wäre
die Sorte Duplikat, die auseinanderläuft.

Filter stehen in der Adresse. Damit ist jeder Zustand teilbar, der
Zurück-Knopf tut das Richtige, und eine gespeicherte Ansicht ist ein
Lesezeichen. Die mitgelieferten Ansichten stehen im Programm, weil sie von der
Gegenwart abhängen.

Strg+K öffnet die Kommandopalette. Im Grundgerüst steckt nur das
Tastaturereignis, die Palette selbst wird beim Öffnen geholt — das kostet die
anderen Seiten rund hundert Byte.

Offen geblieben: "Zuletzt geöffnet" als Ansicht. Dafür müsste jeder Aufruf
jeder Akte mitgeschrieben werden; das wäre eine Überwachungsfunktion, die
niemand bestellt hat. In TASKS vermerkt.

165 Tests im Web-Paket, 17 in der Datenbank, 7 im Worker, 34 e2e.
Grösste Route bei 119,7 kB gegen 120 erlaubte.
2026-09-02 21:23:01 +02:00

277 lines
9.9 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-${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('lässt ein Dokument nicht löschen', async () => {
const doc = await db.document.create({
data: {
documentNumber: `PRF-9999-${String(Date.now()).slice(-6)}`,
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-${String(Date.now()).slice(-6)}`,
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-${String(Date.now()).slice(-6)}`,
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)
})
})