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.
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.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)
|
|
})
|
|
})
|