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
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
242 lines
8.4 KiB
TypeScript
242 lines
8.4 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)
|
|
})
|
|
})
|