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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
230 lines
7.7 KiB
TypeScript
230 lines
7.7 KiB
TypeScript
// @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<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-${String(Date.now()).slice(-4)}`,
|
|
title: `Vorgang ${wort.offen}`,
|
|
classification: 1,
|
|
status: 'OPEN',
|
|
},
|
|
})
|
|
offenId = offen.id
|
|
|
|
const geheim = await db.case.create({
|
|
data: {
|
|
...gemeinsam,
|
|
caseNumber: `Ghm-2026-${String(Date.now()).slice(-4)}`,
|
|
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<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()
|
|
expect(gefiltert.kind).toBe('case')
|
|
})
|
|
})
|