// @vitest-environment node import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { db } from '@/lib/db' import { collectPersonnelDossier, departmentMembers, effectiveCap, personnelDossierStand, } from '@/lib/export/dossier' import { createElement } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { OmittedNote } from '@/components/print/DossierBody' import { exportCacheKey } from '@/lib/export/cacheKey' import { requestExport } from '@/lib/export/service' import { AccessError } from '@/lib/errors' import type { EffectivePermissions, Permission } from '@/lib/auth/permissions' /** * Das Dossier: eine Personalakte mit allem, was daran hängt. * * Zwei Grenzen wirken darin, und der ganze Wert dieser Prüfungen liegt darin, * sie auseinanderzuhalten: * * - Die **Freigabestufe des Abrufers** ist der Schutz. Was darüber liegt, * darf nicht im Abzug stehen, nicht gezählt werden und nicht als * weggelassen erwähnt werden - harte Regel 6. * - Die **gewählte Höchststufe** ist eine Entscheidung über den Umfang. Was * sie ausschliesst, muss im Abzug vermerkt sein, sonst hält der Empfänger * eine gekürzte Mappe für eine vollständige. * * Ein Deckel, der nach oben wirkt, wäre die schlimmste denkbare Verwechslung. * Dafür steht die erste Gruppe. */ const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL const suite = url ? describe : describe.skip const marke = `dossier-${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, } } describe('Der Deckel wirkt nur nach unten', () => { it('lässt niemanden über die eigene Stufe hinaus', () => { // Der Kern. Wäre das je anders, wäre der Deckel ein Aufzug. expect(effectiveCap(perms('u', 2), 5)).toBe(2) expect(effectiveCap(perms('u', 0), 4)).toBe(0) }) it('nimmt eine niedrigere Wahl an', () => { expect(effectiveCap(perms('u', 4), 1)).toBe(1) }) it('fällt bei Unsinn auf die eigene Stufe zurück, nicht nach oben', () => { expect(effectiveCap(perms('u', 3), Number.NaN)).toBe(3) expect(effectiveCap(perms('u', 3), -7)).toBe(0) }) it('trennt zwei Abzüge mit verschiedenem Deckel im Zwischenspeicher', () => { // Sonst bekäme ein Abruf bis Stufe 1 die Datei eines Abrufs bis Stufe 4. const basis = { kind: 'PERSONNEL', targetId: 'abc', updatedAt: new Date('2026-03-14T10:00:00Z'), clearanceLevel: 4, templateVersion: 0, } expect(exportCacheKey({ ...basis, maxLevel: 1 })).not.toBe( exportCacheKey({ ...basis, maxLevel: 4 }), ) expect(exportCacheKey({ ...basis, maxLevel: 2 })).toBe( exportCacheKey({ ...basis, maxLevel: 2 }), ) }) }) describe('Der Vermerk über das Weggelassene', () => { /** * Wirklich gerendert und nicht nur gezählt: der Vermerk ist das einzige, was * dem Empfänger sagt, dass er eine gekürzte Mappe hält. Fehlte er im Markup, * wäre die Zählung dahinter wertlos. */ const markup = (inhalt: Parameters[0]['inhalt'], stufe = 4) => renderToStaticMarkup(createElement(OmittedNote, { inhalt, eigeneStufe: stufe })) it('nennt Stufe und Anzahl', () => { const html = markup({ omitted: [{ level: 3, documents: 2, cases: 1 }], omittedTotal: 3, truncated: false, }) expect(html).toContain('Stufe 3') expect(html).toContain('2 Dokumente') expect(html).toContain('1 Fallakten') }) it('schweigt, wenn nichts weggelassen wurde', () => { // Ein Kasten "nichts weggelassen" auf jedem Abzug wäre Lärm; fehlt der // Kasten, ist der Abzug vollständig. expect(markup({ omitted: [], omittedTotal: 0, truncated: false })).toBe('') }) it('sagt es, wenn der Abzug abgeschnitten wurde', () => { const html = markup({ omitted: [], omittedTotal: 0, truncated: true }) expect(html).toContain('abgeschnitten') }) }) suite('Was ins Dossier kommt', () => { const abrufer = `${marke}-abrufer` const fremd = `${marke}-fremd` let personId = '' let abteilungId = '' const stufen = [1, 3, 5] as const beforeAll(async () => { await db.user.createMany({ data: [abrufer, fremd].map((id) => ({ id, username: id, displayName: id })), }) const vorlage = await db.documentTemplate.create({ data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] }, }) const abteilung = await db.department.create({ data: { // Kürzel: Grossbuchstaben, Ziffern, Bindestrich - und je Lauf anders, // weil die Zeile stehen bleibt. key: `PRUEF-${Date.now().toString(36).toUpperCase()}`.slice(0, 21), name: 'Prüfabteilung', minClearance: 0, }, }) abteilungId = abteilung.id const person = await db.personnelFile.create({ data: { displayName: `${marke}-person`, classification: 1, departmentId: abteilung.id }, }) personId = person.id // Je eine Personalakte, ein Dokument und eine Fallakte auf Stufe 1, 3, 5. for (const stufe of stufen) { await db.document.create({ data: { documentNumber: `${marke}-D${stufe}`, title: `Dokument Stufe ${stufe}`, templateId: vorlage.id, templateKey: vorlage.key, templateVersion: 1, classification: stufe, personnelFileId: person.id, createdById: abrufer, }, }) const akte = await db.case.create({ data: { caseNumber: `${marke}-F${stufe}`, title: `Fall Stufe ${stufe}`, templateId: vorlage.id, templateKey: vorlage.key, classification: stufe, status: 'OPEN', createdById: abrufer, }, }) await db.caseParticipant.create({ data: { caseId: akte.id, personnelFileId: person.id, role: 'SUBJECT' }, }) } // Eine Fallakte ohne diese Person - sie darf nie auftauchen. await db.case.create({ data: { caseNumber: `${marke}-Fremd`, title: 'Ohne die Person', templateId: vorlage.id, templateKey: vorlage.key, classification: 1, status: 'OPEN', createdById: abrufer, }, }) // Mitglieder der Abteilung auf verschiedenen Stufen. await db.personnelFile.createMany({ data: stufen.map((stufe) => ({ displayName: `${marke}-mitglied-${stufe}`, classification: stufe, departmentId: abteilung.id, })), }) }) // Kein Aufräumen: Akten, Dokumente und Protokolleinträge lassen sich in // diesem System nicht löschen - Regeln in der Datenbank verbieten es (harte // Regel 2). Jeder Lauf arbeitet deshalb unter einer eigenen Marke. afterAll(async () => { await db.$disconnect() }) it('nimmt alles bis zur gewählten Stufe auf', async () => { const inhalt = await collectPersonnelDossier( perms(abrufer, 5), { id: personId, userId: null }, 3, ) expect(inhalt.documents.map((d) => d.classification).sort()).toEqual([1, 3]) expect(inhalt.cases.map((c) => c.classification).sort()).toEqual([1, 3]) }) it('vermerkt, was der Abrufer selbst weggelassen hat', async () => { const inhalt = await collectPersonnelDossier( perms(abrufer, 5), { id: personId, userId: null }, 1, ) // Stufe 3 und 5 dürfte er sehen, hat sie aber ausgeschlossen: beides muss // im Abzug stehen, sonst wirkt die Mappe vollständig. expect(inhalt.omitted).toEqual([ { level: 3, documents: 1, cases: 1 }, { level: 5, documents: 1, cases: 1 }, ]) expect(inhalt.omittedTotal).toBe(4) }) it('zählt nicht mit, was über der eigenen Stufe liegt', async () => { // Der wichtigste negative Fall: Stufe 3 wählt Deckel 3. Was auf Stufe 5 // liegt, darf weder im Abzug stehen noch als weggelassen gezählt werden - // die Zahl allein verriete, dass es etwas gibt. const inhalt = await collectPersonnelDossier( perms(abrufer, 3), { id: personId, userId: null }, 3, ) expect(inhalt.documents.map((d) => d.classification).sort()).toEqual([1, 3]) expect(inhalt.omitted).toEqual([]) expect(inhalt.omittedTotal).toBe(0) }) it('zählt auch dann nichts Höheres, wenn der Deckel niedriger liegt', async () => { const inhalt = await collectPersonnelDossier( perms(abrufer, 3), { id: personId, userId: null }, 1, ) // Nur Stufe 3 fehlt sichtbar. Stufe 5 bleibt unerwähnt. expect(inhalt.omitted).toEqual([{ level: 3, documents: 1, cases: 1 }]) }) it('nimmt keine Fallakte auf, in der die Person nicht vorkommt', async () => { const inhalt = await collectPersonnelDossier( perms(abrufer, 5), { id: personId, userId: null }, 5, ) expect(inhalt.cases.map((c) => c.caseNumber)).not.toContain(`${marke}-Fremd`) expect(inhalt.cases).toHaveLength(3) }) it('gibt einem Abrufer ohne Stufe gar nichts', async () => { const inhalt = await collectPersonnelDossier( perms(fremd, 0), { id: personId, userId: null }, 5, ) expect(inhalt.documents).toEqual([]) expect(inhalt.cases).toEqual([]) expect(inhalt.omitted).toEqual([]) }) it('führt den Stand über das ganze Dossier, nicht nur über die Kopfdaten', async () => { const akte = await db.personnelFile.findUniqueOrThrow({ where: { id: personId } }) const vorher = await personnelDossierStand( perms(abrufer, 5), { id: personId, userId: null, updatedAt: akte.updatedAt }, 5, ) // Ein Dokument ändern, die Personalakte nicht anfassen. await db.document.update({ where: { documentNumber: `${marke}-D1` }, data: { title: 'Geändert' }, }) const nachher = await personnelDossierStand( perms(abrufer, 5), { id: personId, userId: null, updatedAt: akte.updatedAt }, 5, ) // Ohne das gäbe der Zwischenspeicher den alten Abzug heraus. expect(nachher.getTime()).toBeGreaterThan(vorher.getTime()) }) it('lässt Mitglieder über der eigenen Stufe aus der Abteilung verschwinden', async () => { const { members, omittedMembers } = await departmentMembers(perms(abrufer, 3), abteilungId, 3) const stufenDerMitglieder = members.map((m) => m.classification).sort() expect(stufenDerMitglieder).toEqual([1, 1, 3]) // Das Mitglied auf Stufe 5 wird auch nicht gezählt. expect(omittedMembers).toBe(0) }) it('zählt Mitglieder, die der Abrufer weggelassen hat', async () => { const { members, omittedMembers } = await departmentMembers(perms(abrufer, 5), abteilungId, 1) expect(members.map((m) => m.classification).sort()).toEqual([1, 1]) expect(omittedMembers).toBe(2) }) it('lädt die vollständigen Kopfdaten der Mitglieder', async () => { // Sonst stünde im Abteilungsabzug bei jedem "Kein Status gesetzt", auch bei // jemandem, der einen trägt. const { members } = await departmentMembers(perms(abrufer, 5), abteilungId, 5) for (const mitglied of members) { expect(mitglied).toHaveProperty('statusTags') expect(mitglied).toHaveProperty('note') expect(mitglied.createdAt).toBeInstanceOf(Date) } }) }) suite('Der Auftrag selbst', () => { const abrufer = `${marke}-auftrag` let personId = '' let hochId = '' beforeAll(async () => { await db.user.create({ data: { id: abrufer, username: abrufer, displayName: abrufer } }) const person = await db.personnelFile.create({ data: { displayName: `${marke}-auftrag-person`, classification: 1 }, }) personId = person.id const hoch = await db.personnelFile.create({ data: { displayName: `${marke}-auftrag-hoch`, classification: 4 }, }) hochId = hoch.id }) afterAll(async () => { await db.$disconnect() }) it('legt einen Auftrag mit gedeckelter Höchststufe an', async () => { // Angefragt wird 5, erlaubt ist 2. Im Auftrag muss 2 stehen - und nicht 5, // auch nicht "erstmal 5 und später gedeckelt". const auftrag = await requestExport(perms(abrufer, 2), { kind: 'PERSONNEL', personnelFileId: personId, maxLevel: 5, }) const zeile = await db.exportJob.findUniqueOrThrow({ where: { id: auftrag.jobId } }) expect(zeile.kind).toBe('PERSONNEL') expect(zeile.maxLevel).toBe(2) expect(zeile.clearanceAtTime).toBe(2) }) it('protokolliert den Abzug mit seinem Deckel', async () => { await requestExport(perms(abrufer, 3), { kind: 'PERSONNEL', personnelFileId: personId, maxLevel: 1, }) const eintrag = await db.auditLog.findFirst({ where: { actorId: abrufer, action: 'export.pdf', targetType: 'PersonnelFile' }, orderBy: { at: 'desc' }, }) // Ohne den Deckel im Protokoll steht dort nicht, wie viel das Haus // verlassen hat. expect(eintrag?.meta).toMatchObject({ kind: 'PERSONNEL', maxLevel: 1 }) }) it('weist ab, wer die Stufe der Personalakte nicht hat', async () => { // Lesen darf man eine Akte unter Umständen auch als Zugeordneter. Ausser // Haus geben nicht. await expect( requestExport(perms(abrufer, 2), { kind: 'PERSONNEL', personnelFileId: hochId, maxLevel: 2, }), ).rejects.toBeInstanceOf(AccessError) }) it('gibt es nicht, wenn der Abrufer die Akte gar nicht sehen darf', async () => { // 404 statt 403: dass es die Akte gibt, geht ihn nichts an. await expect( requestExport(perms(abrufer, 0), { kind: 'PERSONNEL', personnelFileId: hochId, maxLevel: 0, }), ).rejects.toMatchObject({ code: 'not-found' }) }) })