Files
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

251 lines
8.0 KiB
TypeScript

// @vitest-environment node
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createPrismaClient, loadEnvFromRepoRoot, type PrismaClient } from '@afis/db'
import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
import {
addAssignment,
createPersonnelFile,
loadBacklinks,
updatePersonnelFile,
} from '@/lib/personnel/service'
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
/**
* Rückverweise sind der häufigste Ort, an dem ein Aktensystem leckt.
*
* Eine Personalakte der Stufe 1, auf der „3 Fälle" steht, verrät die Existenz
* von Ermittlungen, die der Leser nicht sehen darf - auch dann, wenn kein
* Titel danebensteht. Deshalb prüft dieser Test nicht nur die Liste, sondern
* ausdrücklich auch die **Zahl**. Siehe docs/05-akten-und-vorlagen.md.
*/
loadEnvFromRepoRoot()
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
const suite = url ? describe : describe.skip
let db: PrismaClient
const marke = `personal-${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('Personalakten und Rückverweise', () => {
const nutzer = {
stufe1: `${marke}-stufe1`,
stufe3: `${marke}-stufe3`,
betroffen: `${marke}-betroffen`,
}
const akten = {
offen: `${marke}-fall-offen`,
geheim: `${marke}-fall-geheim`,
}
let personalakteId = ''
let hoheAkteId = ''
beforeAll(async () => {
db = createPrismaClient(url)
const template = await db.documentTemplate.create({
data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] },
})
for (const id of Object.values(nutzer)) {
await db.user.create({ data: { id, username: id, displayName: id } })
}
const person = await db.personnelFile.create({
data: {
displayName: `${marke} Betroffener`,
userId: nutzer.betroffen,
classification: 1,
createdById: nutzer.stufe3,
},
})
personalakteId = person.id
const verborgen = await db.personnelFile.create({
data: { displayName: `${marke} Verborgen`, classification: 4, createdById: nutzer.stufe3 },
})
hoheAkteId = verborgen.id
const gemeinsam = {
templateId: template.id,
templateKey: template.key,
createdById: nutzer.stufe3,
}
await db.case.create({
data: {
...gemeinsam,
id: akten.offen,
caseNumber: `${marke}-1`.slice(0, 40),
title: 'Für alle sichtbar',
classification: 1,
status: 'OPEN',
},
})
await db.case.create({
data: {
...gemeinsam,
id: akten.geheim,
caseNumber: `${marke}-2`.slice(0, 40),
title: 'Nur ab Stufe 3',
classification: 3,
status: 'OPEN',
},
})
// Dieselbe Person, einmal über die Personalakte, einmal über das Konto.
await db.caseParticipant.createMany({
data: [
{ caseId: akten.offen, personnelFileId: person.id, role: 'WITNESS' },
{ caseId: akten.geheim, personnelFileId: person.id, role: 'SUBJECT' },
{ caseId: akten.geheim, userId: nutzer.betroffen, role: 'WITNESS' },
],
})
})
afterAll(async () => {
await db.$disconnect()
})
it('zeigt einer Stufe 1 auf der Personalakte keinen Hinweis auf den Stufe-3-Fall', async () => {
const { total, byRole } = await loadBacklinks(perms(nutzer.stufe1, 1), {
id: personalakteId,
userId: nutzer.betroffen,
})
const nummern = byRole.flatMap((gruppe) => gruppe.eintraege.map((e) => e.case.id))
expect(nummern).toContain(akten.offen)
expect(nummern).not.toContain(akten.geheim)
// Die Zahl ist selbst eine Information: sie darf den verborgenen Fall
// nicht mitzählen, auch nicht als anonymen Platzhalter.
expect(total).toBe(1)
// Und die Rolle taucht gar nicht erst auf, sonst verriete "Betroffener"
// die Existenz einer Ermittlung gegen die Person.
expect(byRole.map((gruppe) => gruppe.role)).not.toContain('SUBJECT')
})
it('zeigt einer Stufe 3 beide Fälle und beide Wege zur Person', async () => {
const { total, byRole } = await loadBacklinks(perms(nutzer.stufe3, 3), {
id: personalakteId,
userId: nutzer.betroffen,
})
const rollen = byRole.map((gruppe) => gruppe.role).sort()
expect(rollen).toEqual(['SUBJECT', 'WITNESS'])
// Drei Einträge: einer über die Personalakte im offenen Fall, zwei im
// geheimen - über die Personalakte und über das Discord-Konto.
expect(total).toBe(3)
})
it('verbirgt eine Personalakte oberhalb der Stufe, auch in der Zählung', async () => {
const p = perms(nutzer.stufe1, 1)
const wo = {
AND: [visiblePersonnelWhere(p), { id: { in: [personalakteId, hoheAkteId] } }],
}
const treffer = await db.personnelFile.findMany({ where: wo, select: { id: true } })
const anzahl = await db.personnelFile.count({ where: wo })
expect(treffer.map((t) => t.id)).toEqual([personalakteId])
expect(anzahl).toBe(1)
})
it('nimmt nur Status-Etiketten an, die es wirklich gibt', async () => {
const leitung = perms(nutzer.stufe3, 3, { permissions: ['personnel.edit'] })
const akte = await createPersonnelFile(leitung, {
displayName: `${marke} Etikettentest`,
classification: 1,
// "erfunden" steht in keiner Konfiguration und darf nicht durchrutschen,
// sonst wäre die Liste im Adminbereich nur eine Empfehlung.
statusTags: ['tot', 'erfunden', 'verhaftet'],
})
const gespeichert = await db.personnelFile.findUniqueOrThrow({
where: { id: akte.id },
select: { statusTags: true },
})
expect(gespeichert.statusTags).toEqual(['verhaftet', 'tot'])
// Und die Reihenfolge kommt aus der Konfiguration, nicht aus der Eingabe:
// auf jeder Akte steht dieselbe Abfolge.
await updatePersonnelFile(leitung, akte.id, { statusTags: ['tot', 'verhaftet'] })
const danach = await db.personnelFile.findUniqueOrThrow({
where: { id: akte.id },
select: { statusTags: true },
})
expect(danach.statusTags).toEqual(['verhaftet', 'tot'])
})
it('lässt ein zugeordnetes Mitglied auch über seiner Stufe lesen', async () => {
const p = perms(nutzer.stufe1, 1)
// Vorher: die Akte der Stufe 4 ist für ihn nicht da.
expect(
await db.personnelFile.findMany({
where: { AND: [visiblePersonnelWhere(p), { id: hoheAkteId }] },
select: { id: true },
}),
).toHaveLength(0)
await addAssignment(perms(nutzer.stufe3, 3, { isAdmin: true }), {
personnelFileId: hoheAkteId,
userId: nutzer.stufe1,
role: 'INVESTIGATOR',
})
// Danach schon - das ist der ausnahmsweise Zugang, wie bei der Fallakte.
expect(
await db.personnelFile.findMany({
where: { AND: [visiblePersonnelWhere(p), { id: hoheAkteId }] },
select: { id: true },
}),
).toHaveLength(1)
})
it('gibt der betroffenen Person selbst keinen Zugriff auf ihre Akte', async () => {
const eigene = await db.personnelFile.create({
data: {
displayName: `${marke} Eigene`,
userId: nutzer.betroffen,
classification: 4,
},
})
// Eine Beurteilung auf Stufe 4 über einen Mitarbeiter der Stufe 1 wäre
// sonst genau für den einen Menschen lesbar, vor dem sie schützt.
const treffer = await db.personnelFile.findMany({
where: { AND: [visiblePersonnelWhere(perms(nutzer.betroffen, 1)), { id: eigene.id }] },
select: { id: true },
})
expect(treffer).toHaveLength(0)
})
it('gibt dem Admin die verborgene Personalakte', async () => {
const treffer = await db.personnelFile.findMany({
where: {
AND: [
visiblePersonnelWhere(perms(nutzer.stufe1, 1, { isAdmin: true })),
{ id: hoheAkteId },
],
},
select: { id: true },
})
expect(treffer).toHaveLength(1)
})
})