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
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
275 lines
9.5 KiB
TypeScript
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)
|
|
})
|
|
})
|