Files
AfiS-Aktensystem/web/tests/chat.test.ts
T
HarlekinandClaude Opus 5 5f9f5d99fb Protokoll im Adminbereich, ein Knopf für das ganze Blatt
Die restlichen zwei Änderungswünsche.

**Protokoll.** „Wer was wo macht" stand seit dem ersten Tag in AuditLog —
harte Regel 3 sorgt dafür. Was fehlte, war die Ansicht darauf. /admin/protokoll
ist genau das und keine zweite Aufzeichnung: filterbar nach Person, Aktion,
Art und Zeitraum, rückwärts blätternd.

Nur für die Leitung. Eine Zeile nennt Aktenzeichen und Namen, und schon die
Existenz einer Zeile verrät, dass es einen Vorgang gibt; ein gefiltertes
Protokoll für alle wäre ein Weg, die Sichtbarkeitsregeln von hinten
aufzurollen. Aus demselben Grund werden Kennungen ohne Sichtbarkeitsfilter zu
Aktenzeichen aufgelöst — ein Protokoll aus UUIDs liest niemand, und eines, das
niemand liest, ist keine Kontrolle.

**Speichern.** Jedes Feld trug seinen eigenen Knopf: ein Arbeitsvertrag mit
zwölf Feldern hiess zwölfmal speichern. Der Knopf steht jetzt am Blatt.

Geschrieben wird trotzdem je Feld und nicht als Transaktion, und das folgt aus
dem Datenmodell: Abschnitte sind einzeln versioniert, weil mehrere Leute
gleichzeitig an einer Akte arbeiten. Als Transaktion blockierte ein einziges
zwischenzeitlich geändertes Feld das ganze Blatt — elf gute Änderungen verloren,
weil jemand das zwölfte angefasst hat. So geht durch, was durchgeht, und die
Rückmeldung sagt, was offen blieb und wo. Lichtbilder bleiben ausgenommen: ein
Upload mit Prüfkette ist keine Feldänderung.

lib/audit.ts liegt jetzt als lib/audit/write.ts neben lib/audit/query.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:16:10 +02:00

242 lines
8.5 KiB
TypeScript

// @vitest-environment node
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { db } from '@/lib/db'
import { visibleMessageWhere } from '@/lib/access/chatFilter'
import { adoptAsEntry, listMessages, sendMessage } from '@/lib/chat/service'
import { resolveCasePreviews } from '@/lib/chat/preview'
import { AccessError } from '@/lib/errors'
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
/**
* Der Chat erbt die Sichtbarkeit seines Ortes. Der heikelste Teil ist die
* Vorschaukarte: ein Aktenzeichen im Text darf einem zu niedrig eingestuften
* Leser nichts über die Akte verraten — auch nicht, ob es sie gibt.
*/
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
const suite = url ? describe : describe.skip
const marke = `chat-${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('Aktenchat', () => {
const nutzer = { stufe1: `${marke}-stufe1`, stufe4: `${marke}-stufe4` }
let offenNummer = ''
let geheimNummer = ''
let offenId = ''
let geheimId = ''
let personOffen = ''
let personGeheim = ''
beforeAll(async () => {
const vorlage = 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 gemeinsam = {
templateId: vorlage.id,
templateKey: vorlage.key,
createdById: nutzer.stufe4,
}
const offen = await db.case.create({
data: {
...gemeinsam,
caseNumber: `Chat-2026-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
title: 'Offener Vorgang',
classification: 1,
status: 'OPEN',
},
})
const geheim = await db.case.create({
data: {
...gemeinsam,
caseNumber: `Gehm-2026-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
title: 'Streng vertraulich',
classification: 4,
status: 'OPEN',
},
})
offenNummer = offen.caseNumber
geheimNummer = geheim.caseNumber
offenId = offen.id
geheimId = geheim.id
const p1 = await db.personnelFile.create({
data: { displayName: `${marke}-offen`, classification: 1, createdById: nutzer.stufe4 },
})
const p2 = await db.personnelFile.create({
data: { displayName: `${marke}-geheim`, classification: 4, createdById: nutzer.stufe4 },
})
personOffen = p1.id
personGeheim = p2.id
})
afterAll(async () => {
await db.$disconnect()
})
it('verbirgt den Chat einer zu hoch eingestuften Akte', async () => {
await sendMessage(perms(nutzer.stufe4, 4), {
id: `${marke}-geheim-1`,
scope: { kind: 'case', caseNumber: geheimNummer },
body: 'Interner Stand',
})
// Nicht "leere Liste", sondern: es gibt den Ort für ihn nicht.
await expect(
listMessages(perms(nutzer.stufe1, 1), { kind: 'case', caseNumber: geheimNummer }),
).rejects.toThrow(AccessError)
const sichtbar = await db.chatMessage.findMany({
where: { AND: [visibleMessageWhere(perms(nutzer.stufe1, 1)), { caseId: geheimId }] },
select: { id: true },
})
expect(sichtbar).toHaveLength(0)
})
it('verbirgt den Chat einer zu hoch eingestuften Personalakte', async () => {
const leiter = perms(nutzer.stufe4, 4, { permissions: ['personnel.read'] })
await sendMessage(leiter, {
id: `${marke}-person-geheim`,
scope: { kind: 'personnel', fileId: personGeheim },
body: 'Bewertung liegt vor',
})
// Zu niedrige Stufe: den Ort gibt es für ihn nicht.
await expect(
listMessages(perms(nutzer.stufe1, 1, { permissions: ['personnel.read'] }), {
kind: 'personnel',
fileId: personGeheim,
}),
).rejects.toThrow(AccessError)
})
it('lässt ohne das Recht auf Personalakten auch deren Chat nicht zu', async () => {
// Die Stufe allein reicht nicht: Personalakten setzen personnel.read
// zusätzlich voraus, und das gilt für ihren Chat genauso.
await expect(
listMessages(perms(nutzer.stufe4, 4), { kind: 'personnel', fileId: personOffen }),
).rejects.toThrow(AccessError)
const ohneRecht = await db.chatMessage.findMany({
where: {
AND: [visibleMessageWhere(perms(nutzer.stufe4, 4)), { personnelFileId: personGeheim }],
},
select: { id: true },
})
expect(ohneRecht).toHaveLength(0)
})
it('verrät über die Vorschaukarte nichts über eine verborgene Akte', async () => {
const fuerStufe1 = await resolveCasePreviews(perms(nutzer.stufe1, 1), [
offenNummer,
geheimNummer,
'AfiS-1999-0001',
])
// Sichtbar: mit Titel. Verborgen: ohne alles — und genauso wie eine Akte,
// die es gar nicht gibt.
expect(fuerStufe1.get(offenNummer)).toMatchObject({ visible: true, title: 'Offener Vorgang' })
expect(fuerStufe1.get(geheimNummer)).toEqual({ caseNumber: geheimNummer, visible: false })
expect(fuerStufe1.get('AfiS-1999-0001')).toEqual({
caseNumber: 'AfiS-1999-0001',
visible: false,
})
const fuerStufe4 = await resolveCasePreviews(perms(nutzer.stufe4, 4), [geheimNummer])
expect(fuerStufe4.get(geheimNummer)).toMatchObject({ visible: true })
})
it('behandelt dieselbe Kennung zweimal gesendet als eine Nachricht', async () => {
const id = `${marke}-doppelt`
const scope = { kind: 'case' as const, caseNumber: offenNummer }
await sendMessage(perms(nutzer.stufe1, 1), { id, scope, body: 'Einmal' })
await sendMessage(perms(nutzer.stufe1, 1), { id, scope, body: 'Nochmal' })
const treffer = await db.chatMessage.findMany({ where: { id }, select: { body: true } })
expect(treffer).toHaveLength(1)
// Der erste Text bleibt: das zweite Absenden war ein Wiederholungsversuch,
// keine Änderung.
expect(treffer[0]?.body).toBe('Einmal')
})
it('löst Erwähnungen serverseitig auf', async () => {
const id = `${marke}-erwaehnung`
await sendMessage(perms(nutzer.stufe4, 4), {
id,
scope: { kind: 'case', caseNumber: offenNummer },
// Der zweite Name gibt es nicht — er darf nicht in mentions landen.
body: `Bitte schauen @${nutzer.stufe1} und @niemand`,
})
const nachricht = await db.chatMessage.findUniqueOrThrow({
where: { id },
select: { mentions: true },
})
expect(nachricht.mentions).toEqual([nutzer.stufe1])
})
it('macht aus einer Nachricht einen Vermerk und lässt sie stehen', async () => {
const id = `${marke}-uebernahme`
const p = perms(nutzer.stufe4, 4, { permissions: ['entry.create', 'case.edit'] })
await sendMessage(p, {
id,
scope: { kind: 'case', caseNumber: offenNummer },
body: 'Der Zeuge widerruft seine Aussage.',
})
const { entryId } = await adoptAsEntry(p, id)
const eintrag = await db.caseEntry.findUniqueOrThrow({ where: { id: entryId } })
expect(eintrag.body).toBe('Der Zeuge widerruft seine Aussage.')
// Der Vermerk gehört dem Verfasser, nicht dem, der ihn übernommen hat.
expect(eintrag.authorId).toBe(nutzer.stufe4)
expect(eintrag.type).toBe('NOTE')
const nachricht = await db.chatMessage.findUniqueOrThrow({ where: { id } })
expect(nachricht.adoptedEntryId).toBe(entryId)
// Ein Systemeintrag nennt den Ursprung, damit später niemand denkt, der
// Wortlaut sei für die Akte geschrieben worden.
const hinweis = await db.caseEntry.findFirst({
where: { caseId: offenId, type: 'SYSTEM', body: { contains: 'Aktenchat' } },
select: { id: true },
})
expect(hinweis).not.toBeNull()
// Zweimal übernehmen erzeugt keinen zweiten Vermerk.
expect((await adoptAsEntry(p, id)).entryId).toBe(entryId)
})
it('macht aus einer Nachricht der Personalakte keinen Vermerk', async () => {
const id = `${marke}-person`
const p = perms(nutzer.stufe4, 4, {
permissions: ['entry.create', 'case.edit', 'personnel.read'],
})
await sendMessage(p, {
id,
scope: { kind: 'personnel', fileId: personOffen },
body: 'Absprache',
})
// Ein Vermerk gehört in den Verlauf, und den führt nur die Fallakte.
await expect(adoptAsEntry(p, id)).rejects.toThrow(AccessError)
})
})