// @vitest-environment node import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { db } from '@/lib/db' import { searchCases, 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(options.permissions ?? []), isAdmin: options.isAdmin ?? false, hasMembership: true, } } function filter(q: string, rest: Partial> = {}) { 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), { ...filter('Inspektorin'), kind: 'personnel', }) expect(ergebnis.hits.map((h) => h.id)).toContain(person.id) }) 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 }): Promise { 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() expect(gefiltert.kind).toBe('case') }) })