Files
AfiS-Aktensystem/web/tests/schreibgrenzen.test.ts
T
Harlekin cf4321a3bd Aktenzeichen-Muster je Vorlage, Startskript für die Entwicklung
Richtigstellung zuerst: der vorige Commit trägt die Nachricht "Nichts davon
gebaut", enthält aber bereits die Schema-Änderung an CaseTemplate und
CaseCounter. Ein abgebrochener Aufruf hatte die Datei schon geschrieben, und
das nachfolgende `git add -A` hat sie mitgenommen. Die Nachricht war damit
falsch. Hier ist die Änderung vollständig und geprüft.

Das Muster des Aktenzeichens gehört zur Vorlage: Präfix und Stellenzahl je
Fallart, ein Nummernkreis je Präfix und Jahr. Die Abteilung führt gewachsene
Präfixe wie AKT-PA-2026-62843 neben dem AfiS-2026-0042 aus dem Glossar.

Die Migration ist von Hand geschrieben, weil der Wechsel des Primärschlüssels
auf CaseCounter den bestehenden Zähler erhalten muss: eine zurückgesetzte
Nummer wäre ein doppeltes Aktenzeichen. Vorher und nachher geprüft.

parseCaseNumber zerlegt von hinten, damit Präfixe mit Bindestrich funktionieren.
Ein Test belegt, dass AKT-PA und AfiS getrennte Nummernkreise führen.

Dazu start.sh für die lokale Entwicklung, solange der Dockge-Stack nicht läuft:
Datenbank hochfahren, Migrationen, Grundzustand, Worker und Web. Mit
--ohne-worker für Arbeit ohne Discord und --nur-db für die Datenbank allein.
2026-09-02 12:10:34 +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.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('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.caseTemplate.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 { parseCaseNumber } = await import('@/lib/cases/caseNumber')
const eins = parseCaseNumber(ersteA.caseNumber)
const zwei = parseCaseNumber(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(parseCaseNumber(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)
})
})