// @vitest-environment node import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { createPrismaClient, loadEnvFromRepoRoot, type PrismaClient } from '@afis/db' import { visiblePersonnelWhere } from '@/lib/access/personnelFilter' import { addAssignment, createPersonnelFile, loadBacklinks, updatePersonnelFile, } from '@/lib/personnel/service' import type { EffectivePermissions, Permission } from '@/lib/auth/permissions' /** * Rückverweise sind der häufigste Ort, an dem ein Aktensystem leckt. * * Eine Personalakte der Stufe 1, auf der „3 Fälle" steht, verrät die Existenz * von Ermittlungen, die der Leser nicht sehen darf - auch dann, wenn kein * Titel danebensteht. Deshalb prüft dieser Test nicht nur die Liste, sondern * ausdrücklich auch die **Zahl**. Siehe docs/05-akten-und-vorlagen.md. */ loadEnvFromRepoRoot() const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL const suite = url ? describe : describe.skip let db: PrismaClient const marke = `personal-${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, } } suite('Personalakten und Rückverweise', () => { const nutzer = { stufe1: `${marke}-stufe1`, stufe3: `${marke}-stufe3`, betroffen: `${marke}-betroffen`, } const akten = { offen: `${marke}-fall-offen`, geheim: `${marke}-fall-geheim`, } let personalakteId = '' let hoheAkteId = '' beforeAll(async () => { db = createPrismaClient(url) const template = 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 person = await db.personnelFile.create({ data: { displayName: `${marke} Betroffener`, userId: nutzer.betroffen, classification: 1, createdById: nutzer.stufe3, }, }) personalakteId = person.id const verborgen = await db.personnelFile.create({ data: { displayName: `${marke} Verborgen`, classification: 4, createdById: nutzer.stufe3 }, }) hoheAkteId = verborgen.id const gemeinsam = { templateId: template.id, templateKey: template.key, createdById: nutzer.stufe3, } await db.case.create({ data: { ...gemeinsam, id: akten.offen, caseNumber: `${marke}-1`.slice(0, 40), title: 'Für alle sichtbar', classification: 1, status: 'OPEN', }, }) await db.case.create({ data: { ...gemeinsam, id: akten.geheim, caseNumber: `${marke}-2`.slice(0, 40), title: 'Nur ab Stufe 3', classification: 3, status: 'OPEN', }, }) // Dieselbe Person, einmal über die Personalakte, einmal über das Konto. await db.caseParticipant.createMany({ data: [ { caseId: akten.offen, personnelFileId: person.id, role: 'WITNESS' }, { caseId: akten.geheim, personnelFileId: person.id, role: 'SUBJECT' }, { caseId: akten.geheim, userId: nutzer.betroffen, role: 'WITNESS' }, ], }) }) afterAll(async () => { await db.$disconnect() }) it('zeigt einer Stufe 1 auf der Personalakte keinen Hinweis auf den Stufe-3-Fall', async () => { const { total, byRole } = await loadBacklinks(perms(nutzer.stufe1, 1), { id: personalakteId, userId: nutzer.betroffen, }) const nummern = byRole.flatMap((gruppe) => gruppe.eintraege.map((e) => e.case.id)) expect(nummern).toContain(akten.offen) expect(nummern).not.toContain(akten.geheim) // Die Zahl ist selbst eine Information: sie darf den verborgenen Fall // nicht mitzählen, auch nicht als anonymen Platzhalter. expect(total).toBe(1) // Und die Rolle taucht gar nicht erst auf, sonst verriete "Betroffener" // die Existenz einer Ermittlung gegen die Person. expect(byRole.map((gruppe) => gruppe.role)).not.toContain('SUBJECT') }) it('zeigt einer Stufe 3 beide Fälle und beide Wege zur Person', async () => { const { total, byRole } = await loadBacklinks(perms(nutzer.stufe3, 3), { id: personalakteId, userId: nutzer.betroffen, }) const rollen = byRole.map((gruppe) => gruppe.role).sort() expect(rollen).toEqual(['SUBJECT', 'WITNESS']) // Drei Einträge: einer über die Personalakte im offenen Fall, zwei im // geheimen - über die Personalakte und über das Discord-Konto. expect(total).toBe(3) }) it('verbirgt eine Personalakte oberhalb der Stufe, auch in der Zählung', async () => { const p = perms(nutzer.stufe1, 1) const wo = { AND: [visiblePersonnelWhere(p), { id: { in: [personalakteId, hoheAkteId] } }], } const treffer = await db.personnelFile.findMany({ where: wo, select: { id: true } }) const anzahl = await db.personnelFile.count({ where: wo }) expect(treffer.map((t) => t.id)).toEqual([personalakteId]) expect(anzahl).toBe(1) }) it('nimmt nur Status-Etiketten an, die es wirklich gibt', async () => { const leitung = perms(nutzer.stufe3, 3, { permissions: ['personnel.edit'] }) const akte = await createPersonnelFile(leitung, { displayName: `${marke} Etikettentest`, classification: 1, // "erfunden" steht in keiner Konfiguration und darf nicht durchrutschen, // sonst wäre die Liste im Adminbereich nur eine Empfehlung. statusTags: ['tot', 'erfunden', 'verhaftet'], }) const gespeichert = await db.personnelFile.findUniqueOrThrow({ where: { id: akte.id }, select: { statusTags: true }, }) expect(gespeichert.statusTags).toEqual(['verhaftet', 'tot']) // Und die Reihenfolge kommt aus der Konfiguration, nicht aus der Eingabe: // auf jeder Akte steht dieselbe Abfolge. await updatePersonnelFile(leitung, akte.id, { statusTags: ['tot', 'verhaftet'] }) const danach = await db.personnelFile.findUniqueOrThrow({ where: { id: akte.id }, select: { statusTags: true }, }) expect(danach.statusTags).toEqual(['verhaftet', 'tot']) }) it('lässt ein zugeordnetes Mitglied auch über seiner Stufe lesen', async () => { const p = perms(nutzer.stufe1, 1) // Vorher: die Akte der Stufe 4 ist für ihn nicht da. expect( await db.personnelFile.findMany({ where: { AND: [visiblePersonnelWhere(p), { id: hoheAkteId }] }, select: { id: true }, }), ).toHaveLength(0) await addAssignment(perms(nutzer.stufe3, 3, { isAdmin: true }), { personnelFileId: hoheAkteId, userId: nutzer.stufe1, role: 'INVESTIGATOR', }) // Danach schon - das ist der ausnahmsweise Zugang, wie bei der Fallakte. expect( await db.personnelFile.findMany({ where: { AND: [visiblePersonnelWhere(p), { id: hoheAkteId }] }, select: { id: true }, }), ).toHaveLength(1) }) it('gibt der betroffenen Person selbst keinen Zugriff auf ihre Akte', async () => { const eigene = await db.personnelFile.create({ data: { displayName: `${marke} Eigene`, userId: nutzer.betroffen, classification: 4, }, }) // Eine Beurteilung auf Stufe 4 über einen Mitarbeiter der Stufe 1 wäre // sonst genau für den einen Menschen lesbar, vor dem sie schützt. const treffer = await db.personnelFile.findMany({ where: { AND: [visiblePersonnelWhere(perms(nutzer.betroffen, 1)), { id: eigene.id }] }, select: { id: true }, }) expect(treffer).toHaveLength(0) }) it('gibt dem Admin die verborgene Personalakte', async () => { const treffer = await db.personnelFile.findMany({ where: { AND: [ visiblePersonnelWhere(perms(nutzer.stufe1, 1, { isAdmin: true })), { id: hoheAkteId }, ], }, select: { id: true }, }) expect(treffer).toHaveLength(1) }) })