Files
AfiS-Aktensystem/web/tests/export.test.ts
T
Harlekin e0b03ea336
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +02:00

275 lines
9.5 KiB
TypeScript

// @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<Permission>(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)
})
})