Files
AfiS-Aktensystem/web/tests/abteilungen.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

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