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