Files
AfiS-Aktensystem/web/tests/schreibgrenzen.test.ts
T
Harlekin 651f239c68 Phase 2, erster Teil: Akten anlegen, lesen, Vermerke schreiben
Fachlogik in lib/cases/service.ts, Zugriffsprüfung in lib/access/. Jede
Funktion prüft selbst und schreibt ihren Protokolleintrag in derselben
Transaktion wie die Änderung — die Server Actions daneben sind dünn.

Schreibgrenzen nach docs/03: niemand legt eine Akte über der eigenen Stufe an,
niemand stuft über die eigene Stufe hoch, archiviert und versiegelt sind
schreibgeschützt auch für Admins. Eine Herabstufung verlangt eine Begründung
und landet als sichtbarer Systemeintrag im Verlauf, nicht nur im Protokoll.

Aktenzeichen über CaseCounter mit INSERT ... ON CONFLICT DO UPDATE, geprüft mit
acht gleichzeitigen Anlagen ohne Doppelung. Wer eine Akte anlegt, wird ihr
Ermittler, sonst verliert er sie aus dem Blick, sobald jemand hochstuft.

Fallliste cursorbasiert auf (updatedAt, id), Sichtbarkeit und Trefferzahl in
derselben Abfrage. Aktenansicht mit Kopfdaten sofort und Verlauf in eigener
Suspense-Grenze. Vermerke optimistisch mit clientseitiger UUIDv7, die zugleich
Idempotenzschlüssel ist — zweimal gesendet bleibt ein Eintrag.

Lesezugriffe ab Stufe 3 werden protokolliert, gepuffert und alle zwei Sekunden
gebündelt geschrieben, damit das Protokoll die Antwort nicht ausbremst.

Die vier Startvorlagen stehen jetzt im Seed statt in Phase 3: ohne Vorlage
lässt sich keine Akte anlegen.
2026-09-01 22:57:38 +02:00

184 lines
6.5 KiB
TypeScript

// @vitest-environment node
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createPrismaClient, type PrismaClient } from '@afis/db'
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
/**
* Die Schreibgrenzen aus docs/03-auth-und-rechte.md, geprüft gegen die
* Fachlogik selbst — nicht gegen die Oberfläche. Eine Server Action ist ein
* Endpunkt: was hier durchgeht, geht auch durch, wenn jemand das Formular
* umgeht.
*/
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
const suite = url ? describe : describe.skip
let db: PrismaClient
const marke = `schreib-${Date.now()}`
function perms(
clearanceLevel: number,
permissions: Permission[],
options: { isAdmin?: boolean; userId?: string } = {},
): EffectivePermissions {
return {
userId: options.userId ?? `${marke}-n${clearanceLevel}`,
clearanceLevel,
permissions: new Set(permissions),
isAdmin: options.isAdmin ?? false,
hasMembership: true,
}
}
const VOLL: Permission[] = ['case.create', 'case.edit', 'case.close', 'case.reopen', 'entry.create']
suite('Schreibgrenzen der Freigabestufe', () => {
beforeAll(async () => {
db = createPrismaClient(url)
await db.caseTemplate.create({
data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] },
})
for (const level of [1, 2, 4, 5]) {
const id = `${marke}-n${level}`
await db.user.create({ data: { id, username: id, displayName: id } })
}
})
afterAll(async () => {
await db.$disconnect()
})
it('lässt Stufe 2 keine Stufe-4-Akte anlegen', async () => {
const { createCase, AccessError } = await import('@/lib/cases/service')
await expect(
createCase(perms(2, VOLL), {
title: 'Zu hoch gegriffen',
classification: 4,
templateKey: `${marke}-vorlage`,
}),
).rejects.toBeInstanceOf(AccessError)
})
it('lässt Stufe 2 eine Stufe-2-Akte anlegen und vergibt ein Aktenzeichen', async () => {
const { createCase } = await import('@/lib/cases/service')
const angelegt = await createCase(perms(2, VOLL), {
title: 'Passt',
classification: 2,
templateKey: `${marke}-vorlage`,
})
expect(angelegt.caseNumber).toMatch(/^AfiS-\d{4}-\d{4}$/)
// Der Anleger ist Ermittler, sonst verliert er die Akte beim Hochstufen.
const beteiligt = await db.caseParticipant.count({
where: { caseId: angelegt.id, userId: `${marke}-n2`, role: 'LEAD' },
})
expect(beteiligt).toBe(1)
})
it('vergibt Aktenzeichen ohne Doppelung, auch bei gleichzeitigem Anlegen', async () => {
const { createCase } = await import('@/lib/cases/service')
const ergebnisse = await Promise.all(
Array.from({ length: 8 }, (_, i) =>
createCase(perms(5, VOLL), {
title: `Parallel ${i}`,
classification: 1,
templateKey: `${marke}-vorlage`,
}),
),
)
const nummern = ergebnisse.map((r) => r.caseNumber)
expect(new Set(nummern).size).toBe(nummern.length)
})
it('gibt einer zu niedrigen Stufe nicht einmal die Existenz preis', async () => {
const { createCase, loadVisibleCase } = await import('@/lib/cases/service')
const geheim = await createCase(perms(5, VOLL), {
title: 'Streng geheim',
classification: 4,
templateKey: `${marke}-vorlage`,
})
// Stufe 2 bekommt null, nicht "verboten": daraus wird 404, nicht 403.
expect(await loadVisibleCase(perms(2, VOLL), geheim.caseNumber)).toBeNull()
expect(await loadVisibleCase(perms(5, VOLL), geheim.caseNumber)).not.toBeNull()
})
it('lässt niemanden über die eigene Stufe hochstufen', async () => {
const { createCase, reclassify, AccessError } = await import('@/lib/cases/service')
const akte = await createCase(perms(2, VOLL), {
title: 'Bleibt auf 2',
classification: 2,
templateKey: `${marke}-vorlage`,
})
await expect(
reclassify(perms(2, VOLL), { caseNumber: akte.caseNumber, to: 4 }),
).rejects.toBeInstanceOf(AccessError)
const unveraendert = await db.case.findUnique({ where: { id: akte.id } })
expect(unveraendert?.classification).toBe(2)
})
it('verlangt für eine Herabstufung eine Begründung und hält sie im Verlauf fest', async () => {
const { createCase, reclassify, AccessError } = await import('@/lib/cases/service')
const akte = await createCase(perms(5, VOLL), {
title: 'Wird herabgestuft',
classification: 4,
templateKey: `${marke}-vorlage`,
})
await expect(
reclassify(perms(5, VOLL), { caseNumber: akte.caseNumber, to: 1, reason: 'kurz' }),
).rejects.toBeInstanceOf(AccessError)
await reclassify(perms(5, VOLL), {
caseNumber: akte.caseNumber,
to: 1,
reason: 'Ermittlung abgeschlossen, keine Verschlusssache mehr',
})
const eintraege = await db.caseEntry.findMany({
where: { caseId: akte.id, type: 'SYSTEM' },
orderBy: { createdAt: 'desc' },
})
expect(eintraege[0]?.body).toContain('herabgesetzt')
expect(eintraege[0]?.body).toContain('Ermittlung abgeschlossen')
const protokoll = await db.auditLog.findFirst({
where: { targetId: akte.id, action: 'case.reclassify' },
orderBy: { at: 'desc' },
})
expect(protokoll?.meta).toMatchObject({ from: 4, to: 1, lowering: true })
})
it('lässt eine archivierte Akte nicht mehr verändern, auch nicht als Admin', async () => {
const { createCase, addEntry, AccessError } = await import('@/lib/cases/service')
const admin = perms(5, VOLL, { isAdmin: true, userId: `${marke}-n5` })
const akte = await createCase(admin, {
title: 'Archiviert',
classification: 1,
templateKey: `${marke}-vorlage`,
})
await db.case.update({ where: { id: akte.id }, data: { status: 'ARCHIVED' } })
await expect(
addEntry(admin, { id: crypto.randomUUID(), caseNumber: akte.caseNumber, body: 'Nachtrag' }),
).rejects.toBeInstanceOf(AccessError)
})
it('behandelt denselben Vermerk zweimal gesendet als einen', async () => {
const { createCase, addEntry } = await import('@/lib/cases/service')
const akte = await createCase(perms(5, VOLL), {
title: 'Doppelt gesendet',
classification: 1,
templateKey: `${marke}-vorlage`,
})
const id = crypto.randomUUID()
await addEntry(perms(5, VOLL), { id, caseNumber: akte.caseNumber, body: 'Einmal' })
await addEntry(perms(5, VOLL), { id, caseNumber: akte.caseNumber, body: 'Einmal' })
const anzahl = await db.caseEntry.count({ where: { caseId: akte.id, type: 'NOTE' } })
expect(anzahl).toBe(1)
})
})