Files
AfiS-Aktensystem/web/tests/export.test.ts
T
Harlekin 54176f17ba Runde Bedienelemente, Admin zusammengelegt, Kanäle entfallen
Vier der acht Änderungswünsche.

**Knöpfe.** Rund vierzig der siebzig Knöpfe waren blosser Text in Akzentfarbe,
von einem Link nicht zu unterscheiden. Jeder trägt jetzt Rahmen und
Grundfläche, über die Klasse .btn an einer Stelle definiert statt siebzigmal
als Klassenkette. Beim Drücken zieht der Knopf sich zusammen und bekommt einen
inneren Ring — er wirkt kurz dicker, ohne dass die Rahmenbreite sich ändert und
das Umfeld springt; die Rückmeldung setzt ohne Verzögerung ein und klingt
langsam ab, sonst bliebe ein kurzer Klick unsichtbar.

Dazu die neue Regel: Flächen bleiben eckig, alles Bedienbare ist rund. Damit
ist die Rundung eine Auskunft und keine Verzierung.

**Verwaltung.** Rollen, Vorlagen und Änderungsrichtlinie waren drei Einträge in
der Kopfzeile für etwas, das man selten und dann zusammenhängend braucht. Jetzt
ein Eintrag /admin mit Reiterleiste, dazu eine gemeinsame Tabs-Komponente, die
auch der Fallakte zeigt, welcher Reiter offen ist.

**Kanäle.** Ersatzlos entfernt, mitsamt Tabelle. Der Chat gehört dorthin, wo
der Vorgang steht; ein eigener Reiter mit freien Räumen war der Rückweg nach
Discord. Die Personalakte hat jetzt denselben Chat wie die Fallakte — ohne
„als Vermerk übernehmen", denn dafür braucht es einen Verlauf, und Meldungen
gehen nur an Erwähnte, nie in den allgemeinen Kanal: dass es zu jemandem eine
Personalakte gibt, ist selbst eine Auskunft. Ein CHECK erzwingt jetzt, dass
eine Nachricht zu genau einem Ort gehört.

**Kopfzeile.** Suchbalken statt Suchlink, als gewöhnliches Formular ohne ein
Byte im Bündel. Thema, Benachrichtigungen und Abmelden sitzen hinter dem
Profilbild, das von Discord kommt; wer keins hat, bekommt seine Initialen.

Nebenbei: Aktenzeichen in Tests waren nur auf vier Stellen eindeutig und
kollidierten zwischen Läufen, weil Akten nicht löschbar sind.
2026-09-03 15:07:43 +02:00

263 lines
8.8 KiB
TypeScript

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