// @vitest-environment node import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { createPrismaClient, loadEnvFromRepoRoot, type PrismaClient } from '@afis/db' import { visibleDocumentWhere } from '@/lib/access/personnelFilter' import { canCreateDocument, canEditDocument, canRetractDocument, } from '@/lib/access/documentWrite' import type { EffectivePermissions, Permission } from '@/lib/auth/permissions' /** * Dokumente erben die Sichtbarkeit ihres Besitzers **und** haben eine eigene. * Beides muss zutreffen, sonst leckt entweder die Existenz der Akte oder der * Inhalt des Dokuments. */ loadEnvFromRepoRoot() const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL const suite = url ? describe : describe.skip let db: PrismaClient const marke = `dok-${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('Sichtbarkeit von Dokumenten', () => { const nutzer = { stufe1: `${marke}-stufe1`, stufe4: `${marke}-stufe4`, beteiligt: `${marke}-beteiligt`, } const dok = { imOffenenFall: '', hochImOffenenFall: '', imGeheimenFall: '', anVerborgenerPerson: '', freistehend: '', } beforeAll(async () => { db = createPrismaClient(url) const template = await db.documentTemplate.create({ data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [], uses: ['CASE_DOCUMENT', 'PERSON_DOCUMENT', 'STANDALONE'], }, }) for (const id of Object.values(nutzer)) { await db.user.create({ data: { id, username: id, displayName: id } }) } const gemeinsam = { templateId: template.id, templateKey: template.key, createdById: nutzer.stufe4, } const offen = await db.case.create({ data: { ...gemeinsam, caseNumber: `${marke}-offen`.slice(0, 40), title: 'Offen', classification: 1, status: 'OPEN', }, }) const geheim = await db.case.create({ data: { ...gemeinsam, caseNumber: `${marke}-geheim`.slice(0, 40), title: 'Geheim', classification: 4, status: 'OPEN', }, }) await db.caseParticipant.create({ data: { caseId: geheim.id, userId: nutzer.beteiligt, role: 'INVESTIGATOR' }, }) const person = await db.personnelFile.create({ data: { displayName: `${marke} Verborgen`, classification: 4 }, }) const anlegen = async ( nummer: string, classification: number, besitzer: { caseId?: string; personnelFileId?: string }, ) => ( await db.document.create({ data: { documentNumber: `${marke}-${nummer}`, title: nummer, templateId: template.id, templateKey: template.key, templateVersion: 1, classification, createdById: nutzer.stufe4, ...besitzer, }, select: { id: true }, }) ).id dok.imOffenenFall = await anlegen('offen-niedrig', 1, { caseId: offen.id }) dok.hochImOffenenFall = await anlegen('offen-hoch', 4, { caseId: offen.id }) dok.imGeheimenFall = await anlegen('geheim', 1, { caseId: geheim.id }) dok.anVerborgenerPerson = await anlegen('person', 1, { personnelFileId: person.id }) dok.freistehend = await anlegen('frei', 1, {}) }) afterAll(async () => { await db.$disconnect() }) async function sichtbar(p: EffectivePermissions): Promise { const rows = await db.document.findMany({ where: { AND: [visibleDocumentWhere(p), { id: { in: Object.values(dok) } }] }, select: { id: true }, }) return rows.map((r) => r.id).sort() } it('zeigt einer Stufe 1 nur, was Besitzer und Dokument zugleich erlauben', async () => { const p = perms(nutzer.stufe1, 1) const treffer = await sichtbar(p) expect(treffer).toContain(dok.imOffenenFall) expect(treffer).toContain(dok.freistehend) // Das Dokument ist zu hoch eingestuft, obwohl die Akte offen liegt. expect(treffer).not.toContain(dok.hochImOffenenFall) // Das Dokument wäre lesbar, aber die Akte darüber nicht - die // Dokumentenliste verriete sonst ihre Existenz. expect(treffer).not.toContain(dok.imGeheimenFall) expect(treffer).not.toContain(dok.anVerborgenerPerson) const anzahl = await db.document.count({ where: { AND: [visibleDocumentWhere(p), { id: { in: Object.values(dok) } }] }, }) expect(anzahl).toBe(treffer.length) }) it('lässt einen Beteiligten die Dokumente seiner Akte lesen', async () => { // Ausnahmsweiser Zugang gilt auch für die Dokumente der Akte, sonst wäre // das Hinzuziehen wirkungslos. Siehe docs/03-auth-und-rechte.md. const treffer = await sichtbar(perms(nutzer.beteiligt, 1)) expect(treffer).toContain(dok.imGeheimenFall) // Er bleibt aber ein Stufe-1-Nutzer: an fremden Personalakten ändert das nichts. expect(treffer).not.toContain(dok.anVerborgenerPerson) }) it('zeigt der Stufe 4 alles', async () => { expect(await sichtbar(perms(nutzer.stufe4, 4))).toHaveLength(Object.values(dok).length) }) }) describe('Schreibgrenzen für Dokumente', () => { const alle: Permission[] = [ 'document.create', 'document.edit', 'document.finalize', 'document.retract', ] it('lässt niemanden ein Dokument über der eigenen Stufe anlegen', () => { const p = perms('a', 2, { permissions: alle }) expect(canCreateDocument(p, 2).ok).toBe(true) expect(canCreateDocument(p, 3)).toEqual({ ok: false, reason: 'clearance-too-low', required: 3, }) }) it('verlangt für das Ändern eines ausgefertigten Dokuments das Ausfertigungsrecht', () => { const nurEdit = perms('b', 5, { permissions: ['document.edit'] }) expect(canEditDocument(nurEdit, { status: 'DRAFT', classification: 1 }).ok).toBe(true) expect(canEditDocument(nurEdit, { status: 'FINAL', classification: 1 })).toEqual({ ok: false, reason: 'closed', }) const mitFinalize = perms('c', 5, { permissions: ['document.edit', 'document.finalize'] }) expect(canEditDocument(mitFinalize, { status: 'FINAL', classification: 1 }).ok).toBe(true) }) it('hält ein zurückgezogenes Dokument auch für den Admin geschlossen', () => { // Der Grabstein bleibt stehen, wie er ist. Sonst liesse sich ein // zurückgezogenes Dokument nachträglich zu etwas anderem machen. const admin = perms('d', 5, { isAdmin: true }) expect(canEditDocument(admin, { status: 'RETRACTED', classification: 1 })).toEqual({ ok: false, reason: 'read-only', status: 'RETRACTED', }) expect(canRetractDocument(admin, { status: 'RETRACTED', classification: 1 }).ok).toBe(false) }) it('verlangt für das Zurückziehen ein eigenes Recht', () => { const ohne = perms('e', 5, { permissions: ['document.edit'] }) expect(canRetractDocument(ohne, { status: 'FINAL', classification: 1 })).toEqual({ ok: false, reason: 'no-permission', permission: 'document.retract', }) }) })