Files
AfiS-Aktensystem/web/tests/schreibgrenzen.test.ts
T
HarlekinandClaude Opus 5 d65cc95eb2 Phase 4 abgeschlossen: Personalakten, Dokumente, Beteiligte, Verknüpfungen
Die Dokumentebene aus docs/13 ist gebaut, additiv: Document, DocumentSection
und DocumentSectionVersion neben Case, CaseSection und CaseEntry. Ein Dokument
hängt an einer Fallakte, an einer Personalakte oder an nichts, höchstens an
einem — gesichert durch eine CHECK-Bedingung. Es gibt kein DELETE; eine
Zurücknahme lässt einen Grabstein stehen, der auch für den Admin zu ist.

Eine neue Dokumentart ist damit ein Datensatz, kein Deployment. Im Seed stehen
vier: vermerk, vorfallsbericht, sonderarbeitsvertrag (mehrseitiger Vertragstext
mit Platzhaltern und Unterschriftenzeilen) und bussgeld.

Zwei Umbenennungen, beide von Hand migriert, damit die vorhandenen Zeilen und
Zählerstände stehen bleiben:
- CaseTemplate -> DocumentTemplate, plus uses[] und layout
- CaseCounter -> NumberSeries, ein Nummernkreis je Präfix für Aktenzeichen
  und Dokumentnummern

Die Regeln für Abschnitte stehen jetzt einmal in lib/sections/render.ts und
gelten für Fallakten und Dokumente gleichermaßen. Die Frage aus docs/13, ob
CaseSection langfristig in die Dokumentebene wandert, ist damit verneint und
begründet.

Rückverweise auf der Personalakte laufen durch visibleCaseWhere, die Zahl
daneben entsteht aus dem gefilterten Ergebnis. Auch die Rollengruppe
verschwindet mit, sonst verriete "Betroffener" ohne Eintrag die Ermittlung.
Die eigene Personalakte ist keine Ausnahme von der Freigabestufe.

Vier neue Rechte: document.create, .edit, .finalize, .retract. Das letzte
steht bei den zurückgehaltenen, es greift in Geschriebenes ein.

91 Tests im Web-Paket, 15 in der Datenbank, 28 e2e. Größte Route 118 kB
gegen das Budget von 120 kB — die Luft wird knapp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 14:15:16 +02:00

229 lines
8.1 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.documentTemplate.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('führt je Präfix einen eigenen Nummernkreis', async () => {
// Die Abteilung nutzt gewachsene Präfixe wie AKT-PA neben dem AfiS aus dem
// Glossar. Zwei Fallarten dürfen sich dabei nicht in die Quere kommen.
const { createCase } = await import('@/lib/cases/service')
await db.documentTemplate.create({
data: {
key: `${marke}-personal`,
version: 1,
name: 'Personalvorgang',
fields: [],
numberPrefix: 'AKT-PA',
numberPadding: 5,
},
})
const ersteA = await createCase(perms(5, VOLL), {
title: 'Personalvorgang eins',
classification: 1,
templateKey: `${marke}-personal`,
})
const zweiteA = await createCase(perms(5, VOLL), {
title: 'Personalvorgang zwei',
classification: 1,
templateKey: `${marke}-personal`,
})
const jahr = new Date().getFullYear()
expect(ersteA.caseNumber).toMatch(new RegExp(`^AKT-PA-${jahr}-\\d{5}$`))
expect(zweiteA.caseNumber).toMatch(new RegExp(`^AKT-PA-${jahr}-\\d{5}$`))
const { parseNumber } = await import('@/lib/numbering')
const eins = parseNumber(ersteA.caseNumber)
const zwei = parseNumber(zweiteA.caseNumber)
expect(eins?.prefix).toBe('AKT-PA')
expect(zwei!.number).toBe(eins!.number + 1)
// Der AfiS-Kreis läuft davon unberührt weiter.
const afis = await createCase(perms(5, VOLL), {
title: 'Ermittlung',
classification: 1,
templateKey: `${marke}-vorlage`,
})
expect(parseNumber(afis.caseNumber)?.prefix).toBe('AfiS')
})
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)
})
})