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.
229 lines
8.1 KiB
TypeScript
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)
|
|
})
|
|
})
|