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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
263 lines
8.8 KiB
TypeScript
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)
|
|
})
|
|
})
|