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
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
341 lines
12 KiB
TypeScript
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')
|
|
})
|
|
})
|