Files
AfiS-Aktensystem/web/tests/suche.test.ts
T
Harlekin e0b03ea336
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
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +02:00

341 lines
12 KiB
TypeScript

// @vitest-environment node
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { db } from '@/lib/db'
import {
matchingParticipants,
searchCases,
searchCounts,
searchPersonnel,
} from '@/lib/search/service'
import { parseFilters, toSearchParams } from '@/lib/search/query'
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
/**
* Die Suche ist der Ort, an dem eine Zugriffsregel am leisesten bricht: ein
* Treffer verrät schon durch seine Existenz, dass es etwas gibt.
*
* Geprüft wird deshalb dreierlei: dass höher eingestufte **Inhalte** nicht
* gefunden werden, dass geschwärzte Stellen erst ab der richtigen Stufe
* findbar sind, und dass die Facettenzähler nicht mehr zählen, als die Liste
* zeigt.
*/
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
const suite = url ? describe : describe.skip
const marke = `suche-${Date.now()}`
const wort = {
offen: `Zwiebelturm${Date.now()}`,
gestuft: `Nachtblende${Date.now()}`,
geschwaerzt: `Perreira${Date.now()}`,
}
function perms(
userId: string,
clearanceLevel: number,
options: { isAdmin?: boolean; permissions?: Permission[] } = {},
): EffectivePermissions {
return {
userId,
clearanceLevel,
permissions: new Set<Permission>(options.permissions ?? []),
isAdmin: options.isAdmin ?? false,
hasMembership: true,
}
}
function filter(q: string, rest: Partial<ReturnType<typeof parseFilters>> = {}) {
return { ...parseFilters({}), q, ...rest }
}
suite('Volltextsuche', () => {
const nutzer = { stufe1: `${marke}-stufe1`, stufe4: `${marke}-stufe4` }
let offenId = ''
let geheimId = ''
beforeAll(async () => {
const vorlage = await db.documentTemplate.create({
data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] },
})
for (const id of Object.values(nutzer)) {
await db.user.create({ data: { id, username: id, displayName: id } })
}
const gemeinsam = {
templateId: vorlage.id,
templateKey: vorlage.key,
createdById: nutzer.stufe4,
}
const offen = await db.case.create({
data: {
...gemeinsam,
caseNumber: `Such-2026-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
title: `Vorgang ${wort.offen}`,
classification: 1,
status: 'OPEN',
},
})
offenId = offen.id
const geheim = await db.case.create({
data: {
...gemeinsam,
caseNumber: `Ghm-2026-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
title: 'Unauffälliger Titel',
classification: 4,
status: 'OPEN',
},
})
geheimId = geheim.id
// Ein Eintrag auf Stufe 4 in der **offenen** Akte: die Akte darf jeder
// sehen, diesen Eintrag nicht.
await db.caseEntry.create({
data: {
id: `${marke}-hoch`,
caseId: offen.id,
type: 'NOTE',
body: `Deckname ${wort.gestuft}`,
authorId: nutzer.stufe4,
minClearance: 4,
},
})
// Und ein Eintrag mit einer geschwärzten Stelle darin.
await db.caseEntry.create({
data: {
id: `${marke}-geschwaerzt`,
caseId: offen.id,
type: 'NOTE',
body: `Zeuge war [[c3]]${wort.geschwaerzt}[[/c3]] vor Ort`,
authorId: nutzer.stufe4,
minClearance: 0,
},
})
})
afterAll(async () => {
await db.$disconnect()
})
it('findet, was auf der eigenen Stufe steht', async () => {
const ergebnis = await searchCases(perms(nutzer.stufe1, 1), filter(wort.offen))
expect(ergebnis.hits.map((h) => h.id)).toContain(offenId)
expect(ergebnis.total).toBe(1)
})
it('findet einen höher eingestuften Eintrag nicht, obwohl die Akte sichtbar ist', async () => {
// Der klassische Leck: die Akte darf er sehen, den Eintrag nicht - und
// ein Treffer würde verraten, dass darin etwas über "Nachtblende" steht.
const fuerStufe1 = await searchCases(perms(nutzer.stufe1, 1), filter(wort.gestuft))
expect(fuerStufe1.hits).toHaveLength(0)
expect(fuerStufe1.total).toBe(0)
const fuerStufe4 = await searchCases(perms(nutzer.stufe4, 4), filter(wort.gestuft))
expect(fuerStufe4.hits.map((h) => h.id)).toContain(offenId)
})
it('findet eine geschwärzte Stelle erst ab der Stufe, ab der sie lesbar ist', async () => {
// Der Eintrag selbst ist für alle lesbar, die Stelle darin nicht. Wer sie
// auf der Seite als Balken sieht, soll sie auch nicht finden können.
for (const stufe of [0, 1, 2]) {
const ergebnis = await searchCases(perms(nutzer.stufe1, stufe), filter(wort.geschwaerzt))
expect(ergebnis.total).toBe(0)
}
for (const stufe of [3, 4, 5]) {
const ergebnis = await searchCases(perms(nutzer.stufe4, stufe), filter(wort.geschwaerzt))
expect(ergebnis.hits.map((h) => h.id)).toContain(offenId)
}
})
it('zeigt eine zu hoch eingestufte Akte auch bei passendem Titel nicht', async () => {
const ergebnis = await searchCases(perms(nutzer.stufe1, 1), filter('Unauffälliger'))
expect(ergebnis.hits.map((h) => h.id)).not.toContain(geheimId)
expect(await zaehlerStimmt(ergebnis)).toBe(true)
})
it('zählt in den Facetten nicht mehr, als die Liste zeigt', async () => {
const p = perms(nutzer.stufe1, 1)
const ergebnis = await searchCases(p, filter(''))
// Die Summe je Facette muss der Trefferzahl entsprechen. Ein Zähler, der
// verborgene Akten mitzählt, wäre selbst eine Information.
for (const facette of Object.values(ergebnis.facets)) {
const summe = facette.reduce((a, f) => a + f.count, 0)
expect(summe).toBe(ergebnis.total)
}
// Und die Stufe-4-Akte taucht in keiner Facette auf.
expect(ergebnis.facets.classification.map((f) => f.value)).not.toContain('4')
})
it('findet Personalakten über Name und Dienstgrad', async () => {
const person = await db.personnelFile.create({
data: { displayName: `${marke} Marlene Vogt`, rank: 'Inspektorin', classification: 1 },
})
const ergebnis = await searchPersonnel(
perms(nutzer.stufe1, 1, { permissions: ['personnel.read'] }),
{ ...filter('Inspektorin'), kind: 'personnel' },
)
expect(ergebnis.hits.map((h) => h.id)).toContain(person.id)
})
it('findet Personalakten nicht ohne das Recht dafür', async () => {
// Die Freigabestufe allein reicht nicht: Personalakten setzen
// personnel.read voraus, und die Suche ist kein Weg daran vorbei.
const person = await db.personnelFile.create({
data: { displayName: `${marke} Heikle Person`, rank: 'Inspektorin', classification: 1 },
})
const ohneRecht = await searchPersonnel(perms(nutzer.stufe4, 4), {
...filter('Inspektorin'),
kind: 'personnel',
})
expect(ohneRecht.hits.map((h) => h.id)).not.toContain(person.id)
// Auch die Trefferzahl darf nichts verraten.
expect(ohneRecht.total).toBe(0)
})
it('sucht ohne Angabe über alle Arten', async () => {
// Der Auslieferungszustand: wer einen Namen eintippt, will wissen, wo er
// vorkommt, und nicht zuerst entscheiden, wonach er sucht.
expect(parseFilters({}).kind).toBe('all')
expect(parseFilters({ kind: 'personnel' }).kind).toBe('personnel')
})
it('zählt die Treffer aller Arten, damit man sie nicht sucht', async () => {
// Der Anlass: wer nach einem Namen sucht, landet auf den Fallakten und
// sieht nicht, dass es zu demselben Namen auch eine Personalakte gibt.
const name = `Sichtbarkeit${Date.now().toString(36)}`
await db.personnelFile.create({ data: { displayName: `${name} Alvarez`, classification: 1 } })
const mitRecht = await searchCounts(
perms(nutzer.stufe1, 1, { permissions: ['personnel.read'] }),
filter(name),
)
expect(mitRecht.personnel).toBe(1)
// Ohne das Recht steht dort keine Zahl, nicht einmal eine Null mit
// Hinweischarakter: die Art taucht gar nicht auf.
const ohneRecht = await searchCounts(perms(nutzer.stufe1, 1), filter(name))
expect(ohneRecht.personnel).toBeNull()
})
it('findet eine Akte über den Namen eines Beteiligten', async () => {
// Der eigentliche Fall: der Name steht weder im Titel noch im Text, die
// Person ist nur als Betroffene eingetragen.
const name = `Jonathan Feldmann ${Date.now().toString(36)}`
const person = await db.personnelFile.create({
data: { displayName: name, classification: 1 },
})
const vorlage = await db.documentTemplate.findFirstOrThrow({
where: { key: { startsWith: marke } },
})
const akte = await db.case.create({
data: {
templateId: vorlage.id,
templateKey: vorlage.key,
createdById: nutzer.stufe4,
caseNumber: `Btl-2026-${Date.now().toString(36)}`,
title: 'Vorgang ohne Namen im Titel',
classification: 1,
status: 'OPEN',
},
})
// Vorher: nichts zu finden.
const vorher = await searchCases(perms(nutzer.stufe1, 1), filter(name))
expect(vorher.hits.map((h) => h.id)).not.toContain(akte.id)
await db.caseParticipant.create({
data: { caseId: akte.id, personnelFileId: person.id, role: 'SUBJECT' },
})
// Nachher: der Auslöser hat den Index nachgezogen.
const nachher = await searchCases(perms(nutzer.stufe1, 1), filter(name))
expect(nachher.hits.map((h) => h.id)).toContain(akte.id)
// Und die Trefferzeile kann sagen, warum.
const warum = await matchingParticipants([akte.id], name)
expect(warum.get(akte.id)).toEqual([{ name, role: 'SUBJECT' }])
// Wird die Beteiligung wieder aufgehoben, verschwindet der Treffer.
await db.caseParticipant.deleteMany({ where: { caseId: akte.id, personnelFileId: person.id } })
const danach = await searchCases(perms(nutzer.stufe1, 1), filter(name))
expect(danach.hits.map((h) => h.id)).not.toContain(akte.id)
})
it('macht über einen Beteiligten keine zu hoch eingestufte Akte auffindbar', async () => {
// Der Name eines Beteiligten steht in jeder Akte, die man lesen darf -
// aber die Akte selbst muss man lesen dürfen. Sonst wäre die Suche nach
// einem Namen ein Weg, die Existenz einer Akte zu erraten.
const name = `Verdeckte Person ${Date.now().toString(36)}`
const person = await db.personnelFile.create({
data: { displayName: name, classification: 1 },
})
await db.caseParticipant.create({
data: { caseId: geheimId, personnelFileId: person.id, role: 'WITNESS' },
})
const niedrig = await searchCases(perms(nutzer.stufe1, 1), filter(name))
expect(niedrig.hits.map((h) => h.id)).not.toContain(geheimId)
expect(niedrig.total).toBe(0)
const hoch = await searchCases(perms(nutzer.stufe4, 4), filter(name))
expect(hoch.hits.map((h) => h.id)).toContain(geheimId)
})
it('aktualisiert den Index, wenn ein Eintrag geändert wird', async () => {
const neu = `Umgeschrieben${Date.now()}`
await db.caseEntry.update({
where: { id: `${marke}-hoch` },
data: { body: `Deckname ${neu}` },
})
// Sonst fände die Suche nach einer nachträglichen Änderung den alten Text.
const alt = await searchCases(perms(nutzer.stufe4, 4), filter(wort.gestuft))
expect(alt.total).toBe(0)
const jetzt = await searchCases(perms(nutzer.stufe4, 4), filter(neu))
expect(jetzt.hits.map((h) => h.id)).toContain(offenId)
})
async function zaehlerStimmt(ergebnis: {
total: number
facets: Record<string, { count: number }[]>
}): Promise<boolean> {
return Object.values(ergebnis.facets).every(
(facette) => facette.reduce((a, f) => a + f.count, 0) === ergebnis.total,
)
}
})
describe('Filter als Adresse', () => {
it('überlebt den Weg durch die Adresse unverändert', () => {
const original = {
q: 'Korruption',
kind: 'case' as const,
status: 'OPEN',
classification: 3,
mine: true,
}
const zurueck = parseFilters(new URLSearchParams(toSearchParams(original)))
expect(zurueck.q).toBe('Korruption')
expect(zurueck.status).toBe('OPEN')
expect(zurueck.classification).toBe(3)
expect(zurueck.mine).toBe(true)
})
it('lässt leere Filter aus der Adresse heraus', () => {
expect(toSearchParams({ q: '', kind: 'case', mine: false })).toBe('kind=case')
})
it('nimmt keine erfundene Stufe und keine erfundene Art an', () => {
const gefiltert = parseFilters({ classification: '9', kind: 'unfug' })
expect(gefiltert.classification).toBeUndefined()
// Unsinn faellt auf den Auslieferungszustand zurueck, und der ist „alle".
expect(gefiltert.kind).toBe('all')
})
})