Files
AfiS-Aktensystem/web/tests/schreibgrenzen.test.ts
T
Harlekin e0b03ea336
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +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)
})
})