// @vitest-environment node import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { db } from '@/lib/db' import { createRenderToken, verifyRenderToken } from '@/lib/export/token' import { exportCacheKey } from '@/lib/export/cacheKey' import { createZip } from '@/lib/export/zip' import { approveExport, requestExport, APPROVAL_FROM_LEVEL } 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-${String(Date.now()).slice(-4)}`, title: 'Harmlos', classification: 1, status: 'OPEN', }, }) const geheim = await db.case.create({ data: { ...gemeinsam, caseNumber: `Exg-2026-${String(Date.now()).slice(-4)}`, title: 'Streng', classification: APPROVAL_FROM_LEVEL, 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('hält einen Export ab Stufe 3 zur Freigabe zurück', async () => { const auftrag = await requestExport(perms(nutzer.anfrager, 4), { kind: 'CASE', caseNumber: geheimNummer, }) expect(auftrag.status).toBe('PENDING_APPROVAL') // Vier Augen heisst vier Augen: der Anfragende gibt nicht selbst frei, // auch nicht mit dem Recht dazu. await expect( approveExport( perms(nutzer.anfrager, 4, { permissions: ['export.approve'] }), auftrag.jobId, ), ).rejects.toThrow(AccessError) // Und ohne das Recht auch nicht. await expect(approveExport(perms(nutzer.pruefer, 4), auftrag.jobId)).rejects.toThrow( AccessError, ) await approveExport( perms(nutzer.pruefer, 4, { permissions: ['export.approve'] }), auftrag.jobId, ) const danach = await db.exportJob.findUniqueOrThrow({ where: { id: auftrag.jobId } }) expect(danach.status).toBe('QUEUED') expect(danach.approvedById).toBe(nutzer.pruefer) }) 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 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) }) })