**Export.** Das Vier-Augen-Verfahren ab Stufe 3 ist weg. Wer exportiert, entscheidet jetzt allein die Freigabestufe: mindestens die des Ziels. Das ist strenger als das Lesen - wer als Beteiligter ausnahmsweise in eine höher eingestufte Akte darf, darf sie deshalb noch nicht ausser Haus geben. Dabei fiel eine Lücke auf: hängte man sich an einen laufenden Auftrag, entstand kein Protokolleintrag. „Jeder Export wird protokolliert" heisst jeder Abruf, nicht jeder Rendervorgang - wer dieselbe Datei bekommt, muss genauso darin stehen. **Fristen.** dueAt wurde überall gelesen - Übersicht, Suchfilter, Ausdruck - und nirgends gesetzt. Es gab keine Stelle, an der man ein Datum einträgt, deshalb stand unter „Fristen" immer „Nichts steht an". Jetzt im Kopf der Akte, mit Protokolleintrag und ohne Verlaufseintrag: eine Wiedervorlage ist eine Merkhilfe, keine Aussage über den Fall. **Statuswechsel.** Aus einem Entwurf führt laut Lebenszyklus nur „Offen" heraus, deshalb standen dort nie mehr als zwei Ziele. Das sah aus wie ein Fehler und war keiner - jetzt steht die Erklärung daneben und an jeder Option. **Druckansicht.** Sie hatte kein eigenes Layout: das weisse Blatt lag auf dem dunklen Thema und es gab keinen Weg zurück. Jetzt heller Grund, eine Leiste mit Zurück und Drucken, beides beim Drucken ausgeblendet. Das Ziel wird aus dem Pfad abgeleitet, damit es auch beim direkten Aufruf stimmt. **Abteilungsstatistik.** Eine Abteilung ohne Freigabe wird als „Ohne Freigabe" geführt statt weggelassen - sonst summierten sich die Anteile nicht auf die Zahl daneben. Mehrere ergeben ein Stück, sonst stünde dort, wie viele es sind. Dazu: Dienstgrad in eckigen Klammern vor dem Namen und in dessen Grösse, in einer eigenen Spalte; Knopfform für „Personalakte anlegen"; die abgeschnittene Anzahl im Kopf der Personalaktenliste; „Zuletzt geöffnet" ohne die zwei Hinweistexte, leer als Zeichen statt als Satz.
319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
// @vitest-environment node
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
import { db } from '@/lib/db'
|
|
import {
|
|
assignToDepartment,
|
|
caseTypesOfDepartment,
|
|
casesPerDepartment,
|
|
listDepartmentCases,
|
|
listDepartments,
|
|
listMembers,
|
|
} from '@/lib/departments/service'
|
|
import { createEntry, editEntry, listEntries, retractEntry } from '@/lib/departments/entries'
|
|
import { AccessError } from '@/lib/errors'
|
|
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
|
|
|
|
/**
|
|
* Die Abteilung ist die Ebene über der Personalakte, und sie fasst zusammen,
|
|
* was einzeln bereits geschützt ist. Genau darin liegt die Gefahr: eine
|
|
* Zusammenfassung, die nicht filtert, verrät mehr als jede Einzelansicht.
|
|
* Geprüft wird deshalb jede Liste und jede Zahl gegen einen zu niedrig
|
|
* eingestuften Leser.
|
|
*/
|
|
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
|
|
const suite = url ? describe : describe.skip
|
|
|
|
const marke = `abt-${Date.now()}`
|
|
|
|
function perms(
|
|
userId: string,
|
|
clearanceLevel: number,
|
|
options: { isAdmin?: boolean; permissions?: Permission[] } = {},
|
|
): EffectivePermissions {
|
|
return {
|
|
userId,
|
|
clearanceLevel,
|
|
permissions: new Set<Permission>(options.permissions ?? []),
|
|
isAdmin: options.isAdmin ?? false,
|
|
hasMembership: true,
|
|
}
|
|
}
|
|
|
|
suite('Abteilungen', () => {
|
|
const nutzer = { stufe1: `${marke}-s1`, stufe4: `${marke}-s4` }
|
|
let abteilungId = ''
|
|
let abteilungKey = ''
|
|
let geheimeAbteilungKey = ''
|
|
let personOffenId = ''
|
|
let personGeheimId = ''
|
|
let offenId = ''
|
|
let geheimId = ''
|
|
|
|
beforeAll(async () => {
|
|
for (const id of Object.values(nutzer)) {
|
|
await db.user.create({ data: { id, username: id, displayName: id } })
|
|
}
|
|
const vorlage = await db.documentTemplate.create({
|
|
data: { key: `${marke}-vorlage`, version: 1, name: 'Korruption', fields: [] },
|
|
})
|
|
const vorlage2 = await db.documentTemplate.create({
|
|
data: { key: `${marke}-bussgeld`, version: 1, name: 'Bussgeld', fields: [] },
|
|
})
|
|
|
|
const abteilung = await db.department.create({
|
|
data: { key: `${marke}-SHD`.slice(0, 21).toUpperCase(), name: 'Sicherheitsdienst' },
|
|
})
|
|
abteilungId = abteilung.id
|
|
abteilungKey = abteilung.key
|
|
|
|
const geheim = await db.department.create({
|
|
data: { key: `${marke}-GEH`.slice(0, 21).toUpperCase(), name: 'Verdeckt', minClearance: 4 },
|
|
})
|
|
geheimeAbteilungKey = geheim.key
|
|
|
|
// Zwei Mitglieder: eines für jeden sichtbar, eines auf Stufe 4.
|
|
const offenePerson = await db.personnelFile.create({
|
|
data: {
|
|
displayName: `${marke} Offene Person`,
|
|
classification: 1,
|
|
departmentId: abteilung.id,
|
|
},
|
|
})
|
|
const geheimePerson = await db.personnelFile.create({
|
|
data: {
|
|
displayName: `${marke} Geheime Person`,
|
|
classification: 4,
|
|
departmentId: abteilung.id,
|
|
},
|
|
})
|
|
personOffenId = offenePerson.id
|
|
personGeheimId = geheimePerson.id
|
|
|
|
const gemeinsam = { createdById: nutzer.stufe4 }
|
|
const offen = await db.case.create({
|
|
data: {
|
|
...gemeinsam,
|
|
templateId: vorlage.id,
|
|
templateKey: vorlage.key,
|
|
caseNumber: `Abt-2026-${Date.now().toString(36)}o`,
|
|
title: 'Offener Vorgang',
|
|
classification: 1,
|
|
status: 'OPEN',
|
|
},
|
|
})
|
|
const geheimerFall = await db.case.create({
|
|
data: {
|
|
...gemeinsam,
|
|
templateId: vorlage2.id,
|
|
templateKey: vorlage2.key,
|
|
caseNumber: `Abt-2026-${Date.now().toString(36)}g`,
|
|
title: 'Streng vertraulich',
|
|
classification: 4,
|
|
status: 'OPEN',
|
|
},
|
|
})
|
|
offenId = offen.id
|
|
geheimId = geheimerFall.id
|
|
|
|
// Beide Fälle hängen über eine Person an der Abteilung.
|
|
await db.caseParticipant.create({
|
|
data: { caseId: offen.id, personnelFileId: offenePerson.id, role: 'SUBJECT' },
|
|
})
|
|
await db.caseParticipant.create({
|
|
data: { caseId: geheimerFall.id, personnelFileId: offenePerson.id, role: 'SUSPECT' },
|
|
})
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await db.$disconnect()
|
|
})
|
|
|
|
it('verbirgt eine Abteilung über der eigenen Stufe', async () => {
|
|
const niedrig = await listDepartments(perms(nutzer.stufe1, 1))
|
|
expect(niedrig.map((a) => a.key)).not.toContain(geheimeAbteilungKey)
|
|
|
|
const hoch = await listDepartments(perms(nutzer.stufe4, 4))
|
|
expect(hoch.map((a) => a.key)).toContain(geheimeAbteilungKey)
|
|
})
|
|
|
|
it('zählt als Mitglieder nur, was der Fragende sehen darf', async () => {
|
|
const mitRecht = (stufe: number) =>
|
|
listDepartments(perms(nutzer.stufe1, stufe, { permissions: ['personnel.read'] }))
|
|
|
|
const niedrig = (await mitRecht(1)).find((a) => a.key === abteilungKey)
|
|
const hoch = (await mitRecht(4)).find((a) => a.key === abteilungKey)
|
|
|
|
// Sonst verriete die Zahl, wie viele Personalakten über seiner Stufe liegen.
|
|
expect(niedrig?.memberCount).toBe(1)
|
|
expect(hoch?.memberCount).toBe(2)
|
|
|
|
// Ohne das Recht auf Personalakten steht dort keine Zahl, auch keine Null:
|
|
// eine Null wäre die Auskunft „hier ist niemand".
|
|
const ohneRecht = await listDepartments(perms(nutzer.stufe4, 4))
|
|
expect(ohneRecht.find((a) => a.key === abteilungKey)?.memberCount).toBeNull()
|
|
})
|
|
|
|
it('zeigt Mitglieder nur mit dem Recht auf Personalakten', async () => {
|
|
const ohneRecht = await listMembers(perms(nutzer.stufe4, 4), abteilungId)
|
|
expect(ohneRecht).toHaveLength(0)
|
|
|
|
const mitRecht = await listMembers(
|
|
perms(nutzer.stufe4, 4, { permissions: ['personnel.read'] }),
|
|
abteilungId,
|
|
)
|
|
expect(mitRecht.map((m) => m.id)).toEqual(
|
|
expect.arrayContaining([personOffenId, personGeheimId]),
|
|
)
|
|
})
|
|
|
|
it('hält eine zu hoch eingestufte Akte aus Liste und Diagramm heraus', async () => {
|
|
const niedrig = perms(nutzer.stufe1, 1)
|
|
|
|
const liste = await listDepartmentCases(niedrig, abteilungId)
|
|
expect(liste.map((a) => a.id)).toContain(offenId)
|
|
expect(liste.map((a) => a.id)).not.toContain(geheimId)
|
|
|
|
// Und die Zahl im Diagramm stimmt mit der Liste überein — ein Zähler, der
|
|
// mehr zählt als die Liste zeigt, wäre selbst eine Auskunft.
|
|
const verteilung = await caseTypesOfDepartment(niedrig, abteilungId)
|
|
const summe = verteilung.reduce((s, stueck) => s + stueck.value, 0)
|
|
expect(summe).toBe(liste.length)
|
|
expect(verteilung.map((s) => s.label)).not.toContain(`${marke}-bussgeld`)
|
|
|
|
// Stufe 4 sieht beide.
|
|
const hoch = await caseTypesOfDepartment(perms(nutzer.stufe4, 4), abteilungId)
|
|
expect(hoch.reduce((s, stueck) => s + stueck.value, 0)).toBe(2)
|
|
})
|
|
|
|
it('zählt auch über Abteilungen hinweg nur Sichtbares', async () => {
|
|
const niedrig = await casesPerDepartment(perms(nutzer.stufe1, 1), 'Ohne Freigabe')
|
|
const eintrag = niedrig.find((s) => s.label === 'Sicherheitsdienst')
|
|
expect(eintrag?.value).toBe(1)
|
|
|
|
const hoch = await casesPerDepartment(perms(nutzer.stufe4, 4), 'Ohne Freigabe')
|
|
expect(hoch.find((s) => s.label === 'Sicherheitsdienst')?.value).toBe(2)
|
|
})
|
|
|
|
it('führt eine Abteilung ohne Freigabe geschwärzt, statt sie wegzulassen', async () => {
|
|
// Der Fall, den es zu prüfen gilt: ein Fall, den der Leser sehen darf,
|
|
// gehört zu einer Abteilung, die er nicht sehen darf.
|
|
const geheim = await db.department.findFirstOrThrow({ where: { key: geheimeAbteilungKey } })
|
|
const person = await db.personnelFile.create({
|
|
data: {
|
|
displayName: `${marke}-verdeckt`,
|
|
classification: 1,
|
|
departmentId: geheim.id,
|
|
},
|
|
})
|
|
await db.caseParticipant.create({
|
|
data: { caseId: offenId, personnelFileId: person.id, role: 'WITNESS' },
|
|
})
|
|
|
|
const niedrig = await casesPerDepartment(perms(nutzer.stufe1, 1), 'Ohne Freigabe')
|
|
// Der Name der Abteilung steht nicht da …
|
|
expect(niedrig.map((s) => s.label)).not.toContain('Verdeckt')
|
|
// … aber der Anteil fehlt auch nicht, sonst summierte sich das Diagramm
|
|
// nicht auf die Zahl, die daneben steht.
|
|
expect(niedrig.find((s) => s.label === 'Ohne Freigabe')?.value).toBe(1)
|
|
|
|
// Mit Freigabe steht der Name da.
|
|
const hoch = await casesPerDepartment(perms(nutzer.stufe4, 4), 'Ohne Freigabe')
|
|
expect(hoch.map((s) => s.label)).toContain('Verdeckt')
|
|
expect(hoch.map((s) => s.label)).not.toContain('Ohne Freigabe')
|
|
})
|
|
|
|
it('lässt nur zuordnen, wer die Personalakte auch ändern darf', async () => {
|
|
await expect(
|
|
assignToDepartment(perms(nutzer.stufe1, 1, { permissions: ['personnel.read'] }), {
|
|
personnelFileId: personOffenId,
|
|
departmentId: abteilungId,
|
|
}),
|
|
).rejects.toThrow(AccessError)
|
|
|
|
await assignToDepartment(
|
|
perms(nutzer.stufe4, 4, { permissions: ['personnel.read', 'personnel.edit'] }),
|
|
{ personnelFileId: personOffenId, departmentId: null },
|
|
)
|
|
const danach = await db.personnelFile.findUniqueOrThrow({ where: { id: personOffenId } })
|
|
expect(danach.departmentId).toBeNull()
|
|
|
|
// Wieder zurück, damit die übrigen Prüfungen ihre Grundlage behalten.
|
|
await assignToDepartment(
|
|
perms(nutzer.stufe4, 4, { permissions: ['personnel.read', 'personnel.edit'] }),
|
|
{ personnelFileId: personOffenId, departmentId: abteilungId },
|
|
)
|
|
})
|
|
|
|
describe('Vermerke', () => {
|
|
const schreiber = () =>
|
|
perms(nutzer.stufe4, 4, {
|
|
permissions: ['entry.create', 'entry.edit_own', 'entry.retract'],
|
|
})
|
|
|
|
it('verbirgt einen Vermerk über der Stufe des Lesers', async () => {
|
|
const id = `${marke}-v1`
|
|
await createEntry(schreiber(), {
|
|
id,
|
|
departmentKey: abteilungKey,
|
|
body: 'Nur für die Leitung',
|
|
minClearance: 4,
|
|
})
|
|
|
|
const niedrig = await listEntries(perms(nutzer.stufe1, 1), abteilungId)
|
|
expect(niedrig.map((v) => v.id)).not.toContain(id)
|
|
|
|
const hoch = await listEntries(perms(nutzer.stufe4, 4), abteilungId)
|
|
expect(hoch.map((v) => v.id)).toContain(id)
|
|
})
|
|
|
|
it('lässt niemanden über seiner eigenen Stufe schreiben', async () => {
|
|
await expect(
|
|
createEntry(perms(nutzer.stufe1, 1, { permissions: ['entry.create'] }), {
|
|
id: `${marke}-v2`,
|
|
departmentKey: abteilungKey,
|
|
body: 'Zu hoch gegriffen',
|
|
minClearance: 4,
|
|
}),
|
|
).rejects.toThrow(AccessError)
|
|
})
|
|
|
|
it('bewahrt bei einer Änderung den vorherigen Stand', async () => {
|
|
const id = `${marke}-v3`
|
|
await createEntry(schreiber(), {
|
|
id,
|
|
departmentKey: abteilungKey,
|
|
body: 'Erster Stand',
|
|
minClearance: 0,
|
|
})
|
|
await editEntry(schreiber(), { id, body: 'Zweiter Stand' })
|
|
|
|
const fassungen = await db.departmentEntryVersion.findMany({ where: { entryId: id } })
|
|
expect(fassungen).toHaveLength(1)
|
|
expect(fassungen[0]?.body).toBe('Erster Stand')
|
|
|
|
const jetzt = await db.departmentEntry.findUniqueOrThrow({ where: { id } })
|
|
expect(jetzt.body).toBe('Zweiter Stand')
|
|
expect(jetzt.version).toBe(2)
|
|
})
|
|
|
|
it('lässt beim Zurückziehen einen Grabstein stehen', async () => {
|
|
const id = `${marke}-v4`
|
|
await createEntry(schreiber(), {
|
|
id,
|
|
departmentKey: abteilungKey,
|
|
body: 'Wird zurückgezogen',
|
|
minClearance: 0,
|
|
})
|
|
await retractEntry(schreiber(), { id, reason: 'Verwechslung mit einem anderen Vorgang' })
|
|
|
|
const danach = await db.departmentEntry.findUniqueOrThrow({ where: { id } })
|
|
expect(danach.retractedAt).not.toBeNull()
|
|
expect(danach.retractionReason).toContain('Verwechslung')
|
|
|
|
// Und er ist weiterhin da: es gibt kein Löschen.
|
|
await db.departmentEntry.deleteMany({ where: { id } })
|
|
expect(await db.departmentEntry.findUnique({ where: { id } })).not.toBeNull()
|
|
})
|
|
})
|
|
})
|