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.
242 lines
8.5 KiB
TypeScript
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)
|
|
})
|
|
})
|