// @vitest-environment node import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { db } from '@/lib/db' import { assignToDepartment, caseTypesOfDepartment, casesPerDepartment, listDepartmentCases, listDepartments, listMembers, } from '@/lib/departments/service' import { createEntry, editEntry, listEntries, retractEntry } from '@/lib/departments/entries' import { AccessError } from '@/lib/errors' import type { EffectivePermissions, Permission } from '@/lib/auth/permissions' /** * Die Abteilung ist die Ebene über der Personalakte, und sie fasst zusammen, * was einzeln bereits geschützt ist. Genau darin liegt die Gefahr: eine * Zusammenfassung, die nicht filtert, verrät mehr als jede Einzelansicht. * Geprüft wird deshalb jede Liste und jede Zahl gegen einen zu niedrig * eingestuften Leser. */ const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL const suite = url ? describe : describe.skip const marke = `abt-${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('Abteilungen', () => { const nutzer = { stufe1: `${marke}-s1`, stufe4: `${marke}-s4` } let abteilungId = '' let abteilungKey = '' let geheimeAbteilungKey = '' let personOffenId = '' let personGeheimId = '' let offenId = '' let geheimId = '' beforeAll(async () => { for (const id of Object.values(nutzer)) { await db.user.create({ data: { id, username: id, displayName: id } }) } const vorlage = await db.documentTemplate.create({ data: { key: `${marke}-vorlage`, version: 1, name: 'Korruption', fields: [] }, }) const vorlage2 = await db.documentTemplate.create({ data: { key: `${marke}-bussgeld`, version: 1, name: 'Bussgeld', fields: [] }, }) const abteilung = await db.department.create({ data: { key: `${marke}-SHD`.slice(0, 21).toUpperCase(), name: 'Sicherheitsdienst' }, }) abteilungId = abteilung.id abteilungKey = abteilung.key const geheim = await db.department.create({ data: { key: `${marke}-GEH`.slice(0, 21).toUpperCase(), name: 'Verdeckt', minClearance: 4 }, }) geheimeAbteilungKey = geheim.key // Zwei Mitglieder: eines für jeden sichtbar, eines auf Stufe 4. const offenePerson = await db.personnelFile.create({ data: { displayName: `${marke} Offene Person`, classification: 1, departmentId: abteilung.id, }, }) const geheimePerson = await db.personnelFile.create({ data: { displayName: `${marke} Geheime Person`, classification: 4, departmentId: abteilung.id, }, }) personOffenId = offenePerson.id personGeheimId = geheimePerson.id const gemeinsam = { createdById: nutzer.stufe4 } const offen = await db.case.create({ data: { ...gemeinsam, templateId: vorlage.id, templateKey: vorlage.key, caseNumber: `Abt-2026-${Date.now().toString(36)}o`, title: 'Offener Vorgang', classification: 1, status: 'OPEN', }, }) const geheimerFall = await db.case.create({ data: { ...gemeinsam, templateId: vorlage2.id, templateKey: vorlage2.key, caseNumber: `Abt-2026-${Date.now().toString(36)}g`, title: 'Streng vertraulich', classification: 4, status: 'OPEN', }, }) offenId = offen.id geheimId = geheimerFall.id // Beide Fälle hängen über eine Person an der Abteilung. await db.caseParticipant.create({ data: { caseId: offen.id, personnelFileId: offenePerson.id, role: 'SUBJECT' }, }) await db.caseParticipant.create({ data: { caseId: geheimerFall.id, personnelFileId: offenePerson.id, role: 'SUSPECT' }, }) }) afterAll(async () => { await db.$disconnect() }) it('verbirgt eine Abteilung über der eigenen Stufe', async () => { const niedrig = await listDepartments(perms(nutzer.stufe1, 1)) expect(niedrig.map((a) => a.key)).not.toContain(geheimeAbteilungKey) const hoch = await listDepartments(perms(nutzer.stufe4, 4)) expect(hoch.map((a) => a.key)).toContain(geheimeAbteilungKey) }) it('zählt als Mitglieder nur, was der Fragende sehen darf', async () => { const mitRecht = (stufe: number) => listDepartments(perms(nutzer.stufe1, stufe, { permissions: ['personnel.read'] })) const niedrig = (await mitRecht(1)).find((a) => a.key === abteilungKey) const hoch = (await mitRecht(4)).find((a) => a.key === abteilungKey) // Sonst verriete die Zahl, wie viele Personalakten über seiner Stufe liegen. expect(niedrig?.memberCount).toBe(1) expect(hoch?.memberCount).toBe(2) // Ohne das Recht auf Personalakten steht dort keine Zahl, auch keine Null: // eine Null wäre die Auskunft „hier ist niemand". const ohneRecht = await listDepartments(perms(nutzer.stufe4, 4)) expect(ohneRecht.find((a) => a.key === abteilungKey)?.memberCount).toBeNull() }) it('zeigt Mitglieder nur mit dem Recht auf Personalakten', async () => { const ohneRecht = await listMembers(perms(nutzer.stufe4, 4), abteilungId) expect(ohneRecht).toHaveLength(0) const mitRecht = await listMembers( perms(nutzer.stufe4, 4, { permissions: ['personnel.read'] }), abteilungId, ) expect(mitRecht.map((m) => m.id)).toEqual( expect.arrayContaining([personOffenId, personGeheimId]), ) }) it('hält eine zu hoch eingestufte Akte aus Liste und Diagramm heraus', async () => { const niedrig = perms(nutzer.stufe1, 1) const liste = await listDepartmentCases(niedrig, abteilungId) expect(liste.map((a) => a.id)).toContain(offenId) expect(liste.map((a) => a.id)).not.toContain(geheimId) // Und die Zahl im Diagramm stimmt mit der Liste überein - ein Zähler, der // mehr zählt als die Liste zeigt, wäre selbst eine Auskunft. const verteilung = await caseTypesOfDepartment(niedrig, abteilungId) const summe = verteilung.reduce((s, stueck) => s + stueck.value, 0) expect(summe).toBe(liste.length) expect(verteilung.map((s) => s.label)).not.toContain(`${marke}-bussgeld`) // Stufe 4 sieht beide. const hoch = await caseTypesOfDepartment(perms(nutzer.stufe4, 4), abteilungId) expect(hoch.reduce((s, stueck) => s + stueck.value, 0)).toBe(2) }) it('zählt auch über Abteilungen hinweg nur Sichtbares', async () => { const niedrig = await casesPerDepartment(perms(nutzer.stufe1, 1), 'Ohne Freigabe') const eintrag = niedrig.find((s) => s.label === 'Sicherheitsdienst') expect(eintrag?.value).toBe(1) const hoch = await casesPerDepartment(perms(nutzer.stufe4, 4), 'Ohne Freigabe') expect(hoch.find((s) => s.label === 'Sicherheitsdienst')?.value).toBe(2) }) it('führt eine Abteilung ohne Freigabe geschwärzt, statt sie wegzulassen', async () => { // Der Fall, den es zu prüfen gilt: ein Fall, den der Leser sehen darf, // gehört zu einer Abteilung, die er nicht sehen darf. const geheim = await db.department.findFirstOrThrow({ where: { key: geheimeAbteilungKey } }) const person = await db.personnelFile.create({ data: { displayName: `${marke}-verdeckt`, classification: 1, departmentId: geheim.id, }, }) await db.caseParticipant.create({ data: { caseId: offenId, personnelFileId: person.id, role: 'WITNESS' }, }) const niedrig = await casesPerDepartment(perms(nutzer.stufe1, 1), 'Ohne Freigabe') // Der Name der Abteilung steht nicht da … expect(niedrig.map((s) => s.label)).not.toContain('Verdeckt') // … aber der Anteil fehlt auch nicht, sonst summierte sich das Diagramm // nicht auf die Zahl, die daneben steht. expect(niedrig.find((s) => s.label === 'Ohne Freigabe')?.value).toBe(1) // Mit Freigabe steht der Name da. const hoch = await casesPerDepartment(perms(nutzer.stufe4, 4), 'Ohne Freigabe') expect(hoch.map((s) => s.label)).toContain('Verdeckt') expect(hoch.map((s) => s.label)).not.toContain('Ohne Freigabe') }) it('lässt nur zuordnen, wer die Personalakte auch ändern darf', async () => { await expect( assignToDepartment(perms(nutzer.stufe1, 1, { permissions: ['personnel.read'] }), { personnelFileId: personOffenId, departmentId: abteilungId, }), ).rejects.toThrow(AccessError) await assignToDepartment( perms(nutzer.stufe4, 4, { permissions: ['personnel.read', 'personnel.edit'] }), { personnelFileId: personOffenId, departmentId: null }, ) const danach = await db.personnelFile.findUniqueOrThrow({ where: { id: personOffenId } }) expect(danach.departmentId).toBeNull() // Wieder zurück, damit die übrigen Prüfungen ihre Grundlage behalten. await assignToDepartment( perms(nutzer.stufe4, 4, { permissions: ['personnel.read', 'personnel.edit'] }), { personnelFileId: personOffenId, departmentId: abteilungId }, ) }) describe('Vermerke', () => { const schreiber = () => perms(nutzer.stufe4, 4, { permissions: ['entry.create', 'entry.edit_own', 'entry.retract'], }) it('verbirgt einen Vermerk über der Stufe des Lesers', async () => { const id = `${marke}-v1` await createEntry(schreiber(), { id, departmentKey: abteilungKey, body: 'Nur für die Leitung', minClearance: 4, }) const niedrig = await listEntries(perms(nutzer.stufe1, 1), abteilungId) expect(niedrig.map((v) => v.id)).not.toContain(id) const hoch = await listEntries(perms(nutzer.stufe4, 4), abteilungId) expect(hoch.map((v) => v.id)).toContain(id) }) it('lässt niemanden über seiner eigenen Stufe schreiben', async () => { await expect( createEntry(perms(nutzer.stufe1, 1, { permissions: ['entry.create'] }), { id: `${marke}-v2`, departmentKey: abteilungKey, body: 'Zu hoch gegriffen', minClearance: 4, }), ).rejects.toThrow(AccessError) }) it('bewahrt bei einer Änderung den vorherigen Stand', async () => { const id = `${marke}-v3` await createEntry(schreiber(), { id, departmentKey: abteilungKey, body: 'Erster Stand', minClearance: 0, }) await editEntry(schreiber(), { id, body: 'Zweiter Stand' }) const fassungen = await db.departmentEntryVersion.findMany({ where: { entryId: id } }) expect(fassungen).toHaveLength(1) expect(fassungen[0]?.body).toBe('Erster Stand') const jetzt = await db.departmentEntry.findUniqueOrThrow({ where: { id } }) expect(jetzt.body).toBe('Zweiter Stand') expect(jetzt.version).toBe(2) }) it('lässt beim Zurückziehen einen Grabstein stehen', async () => { const id = `${marke}-v4` await createEntry(schreiber(), { id, departmentKey: abteilungKey, body: 'Wird zurückgezogen', minClearance: 0, }) await retractEntry(schreiber(), { id, reason: 'Verwechslung mit einem anderen Vorgang' }) const danach = await db.departmentEntry.findUniqueOrThrow({ where: { id } }) expect(danach.retractedAt).not.toBeNull() expect(danach.retractionReason).toContain('Verwechslung') // Und er ist weiterhin da: es gibt kein Löschen. await db.departmentEntry.deleteMany({ where: { id } }) expect(await db.departmentEntry.findUnique({ where: { id } })).not.toBeNull() }) }) })