// @vitest-environment node import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { db } from '@/lib/db' import { visibleCaseWhere } from '@/lib/access/caseFilter' import { createRenderToken, verifyRenderToken } from '@/lib/export/token' import { exportCacheKey } from '@/lib/export/cacheKey' import { createZip } from '@/lib/export/zip' import { requestExport } from '@/lib/export/service' import { permissionsFromRenderToken } from '@/lib/export/renderAccess' import { AccessError } from '@/lib/errors' import type { EffectivePermissions, Permission } from '@/lib/auth/permissions' /** * Ein Export trägt Aktenwissen aus dem System heraus. Die drei Zusicherungen * aus docs/08 werden hier geprüft: die Stufe des Abrufers steckt im * Zwischenspeicherschlüssel, ab Stufe 3 braucht es ein zweites Augenpaar, und * das Zeichen für den PDF-Dienst taugt zu nichts anderem. */ const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL const suite = url ? describe : describe.skip const marke = `export-${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('Zwischenspeicherschlüssel', () => { const basis = { kind: 'CASE', targetId: 'abc', updatedAt: new Date('2026-03-14T10:00:00Z'), clearanceLevel: 2, templateVersion: 1, } it('unterscheidet zwei Stufen', () => { // Sonst bekäme ein Leser der Stufe 1 das PDF, das für Stufe 4 gerendert // wurde. Das ist der teuerste denkbare Zwischenspeicherfehler. expect(exportCacheKey(basis)).not.toBe(exportCacheKey({ ...basis, clearanceLevel: 4 })) }) it('unterscheidet zwei Stände derselben Akte', () => { expect(exportCacheKey(basis)).not.toBe( exportCacheKey({ ...basis, updatedAt: new Date('2026-03-14T10:00:01Z') }), ) }) it('gibt bei gleicher Eingabe denselben Schlüssel', () => { expect(exportCacheKey(basis)).toBe(exportCacheKey({ ...basis })) }) }) describe('Zeichen für den Druckabruf', () => { it('überlebt Ausstellen und Prüfen', () => { const token = createRenderToken({ jobId: 'j1', userId: 'u1', clearanceLevel: 3 }) const claim = verifyRenderToken(token) expect(claim).toMatchObject({ jobId: 'j1', userId: 'u1', clearanceLevel: 3 }) }) it('lehnt ein verändertes Zeichen ab', () => { const token = createRenderToken({ jobId: 'j1', userId: 'u1', clearanceLevel: 1 }) const [nutzlast, unterschrift] = token.split('.') // Stufe hochschrauben und die alte Unterschrift dranlassen. const gefaelscht = Buffer.from( JSON.stringify({ jobId: 'j1', userId: 'u1', clearanceLevel: 5, expiresAt: Date.now() + 60_000, }), ).toString('base64url') expect(verifyRenderToken(`${gefaelscht}.${unterschrift}`)).toBeNull() expect(verifyRenderToken(`${nutzlast}.${'A'.repeat(unterschrift!.length)}`)).toBeNull() expect(verifyRenderToken('unfug')).toBeNull() expect(verifyRenderToken(null)).toBeNull() }) it('lehnt ein abgelaufenes Zeichen ab', () => { const abgelaufen = createRenderToken({ jobId: 'j1', userId: 'u1', clearanceLevel: 1 }) const [nutzlast] = abgelaufen.split('.') const claim = JSON.parse(Buffer.from(nutzlast!, 'base64url').toString('utf8')) as { expiresAt: number } expect(claim.expiresAt).toBeGreaterThan(Date.now()) // Ein Zeichen mit vergangener Frist wird nicht angenommen, auch mit // gültiger Unterschrift nicht — die entsteht hier gar nicht erst. expect(verifyRenderToken(`${nutzlast}.zuwenig`)).toBeNull() }) }) describe('Sammelexport als ZIP', () => { it('schreibt einen lesbaren Behälter', () => { const zip = createZip([ { name: 'a.txt', content: new TextEncoder().encode('eins') }, { name: 'b.txt', content: new TextEncoder().encode('zwei') }, ]) const puffer = Buffer.from(zip) // Lokaler Kopf am Anfang, zentrales Verzeichnis am Ende. expect(puffer.readUInt32LE(0)).toBe(0x04034b50) expect(puffer.readUInt32LE(puffer.length - 22)).toBe(0x06054b50) expect(puffer.readUInt16LE(puffer.length - 22 + 10)).toBe(2) expect(puffer.includes(Buffer.from('a.txt'))).toBe(true) expect(puffer.includes(Buffer.from('eins'))).toBe(true) }) it('kommt mit einem leeren Sammelexport zurecht', () => { const zip = createZip([]) expect(Buffer.from(zip).readUInt32LE(0)).toBe(0x06054b50) }) }) suite('Freigabeverfahren', () => { const nutzer = { anfrager: `${marke}-anfrager`, pruefer: `${marke}-pruefer`, } let offenNummer = '' let geheimNummer = '' 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.anfrager, } const offen = await db.case.create({ data: { ...gemeinsam, caseNumber: `Exp-2026-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, title: 'Harmlos', classification: 1, status: 'OPEN', }, }) const geheim = await db.case.create({ data: { ...gemeinsam, caseNumber: `Exg-2026-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, title: 'Streng', classification: 4, status: 'OPEN', }, }) offenNummer = offen.caseNumber geheimNummer = geheim.caseNumber }) afterAll(async () => { await db.$disconnect() }) it('reicht einen Export unterhalb der Schwelle sofort durch', async () => { const auftrag = await requestExport(perms(nutzer.anfrager, 4), { kind: 'CASE', caseNumber: offenNummer, }) expect(auftrag.status).toBe('QUEUED') }) it('reicht auch eine hoch eingestufte Akte durch, wenn die Stufe reicht', async () => { // Kein Vier-Augen-Verfahren mehr: wer die Stufe hat, exportiert. Die // Kontrolle leistet das Protokoll, nicht eine zweite Unterschrift. const auftrag = await requestExport(perms(nutzer.anfrager, 4), { kind: 'CASE', caseNumber: geheimNummer, }) expect(auftrag.status).toBe('QUEUED') }) it('protokolliert jeden Export, auch den aus dem Zwischenspeicher', async () => { const vorher = await db.auditLog.count({ where: { actorId: nutzer.anfrager, action: 'export.pdf' }, }) await requestExport(perms(nutzer.anfrager, 4), { kind: 'CASE', caseNumber: offenNummer }) const nachher = await db.auditLog.count({ where: { actorId: nutzer.anfrager, action: 'export.pdf' }, }) expect(nachher).toBeGreaterThan(vorher) }) it('gibt eine Akte über der eigenen Stufe gar nicht erst zum Export', async () => { await expect( requestExport(perms(nutzer.pruefer, 1), { kind: 'CASE', caseNumber: geheimNummer }), ).rejects.toThrow(AccessError) }) it('lässt einen Beteiligten unterhalb der Stufe lesen, aber nicht exportieren', async () => { // Der Unterschied, auf den es ankommt: ausnahmsweiser Zugang öffnet die // Akte, gibt sie aber nicht ausser Haus. const geheim = await db.case.findFirstOrThrow({ where: { caseNumber: geheimNummer } }) await db.caseParticipant.create({ data: { caseId: geheim.id, userId: nutzer.pruefer, role: 'INVESTIGATOR' }, }) const p = perms(nutzer.pruefer, 1) const lesbar = await db.case.findFirst({ where: { AND: [visibleCaseWhere(p), { id: geheim.id }] }, select: { id: true }, }) expect(lesbar).not.toBeNull() await expect( requestExport(p, { kind: 'CASE', caseNumber: geheimNummer }), ).rejects.toThrow(AccessError) }) it('lässt das Zeichen nur solange gelten, wie der Auftrag läuft', async () => { const auftrag = await requestExport(perms(nutzer.anfrager, 4), { kind: 'CASE', caseNumber: offenNummer, }) const token = createRenderToken({ jobId: auftrag.jobId, userId: nutzer.anfrager, clearanceLevel: 4, }) // Der Auftrag steht auf QUEUED, nicht auf RUNNING: das Zeichen zieht nicht. expect(await permissionsFromRenderToken(token)).toBeNull() await db.exportJob.update({ where: { id: auftrag.jobId }, data: { status: 'RUNNING' } }) const zugang = await permissionsFromRenderToken(token) expect(zugang?.permissions.userId).toBe(nutzer.anfrager) // Nach dem Abschluss ist es wieder wertlos. await db.exportJob.update({ where: { id: auftrag.jobId }, data: { status: 'READY' } }) expect(await permissionsFromRenderToken(token)).toBeNull() }) it('deckelt die Stufe des Zeichens auf die tatsächlichen Rechte', async () => { const auftrag = await db.exportJob.create({ data: { kind: 'CASE', targetId: 'egal', requestedById: nutzer.anfrager, clearanceAtTime: 5, cacheKey: `${marke}-deckel`, status: 'RUNNING', }, select: { id: true }, }) // Das Zeichen behauptet Stufe 5, der Nutzer hat ohne Rollen aber 0. const token = createRenderToken({ jobId: auftrag.id, userId: nutzer.anfrager, clearanceLevel: 5, }) const zugang = await permissionsFromRenderToken(token) expect(zugang?.permissions.clearanceLevel).toBe(0) }) })