import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { createPrismaClient, type PrismaClient } from '../src/index.js' /** * Prüft die harten Regeln der README dort, wo sie wirklich gelten: in der * Datenbank. Die Anwendung kann man umgehen, diese Regeln nicht. * * Läuft gegen eine echte Postgres-Instanz. Ohne DATABASE_URL wird übersprungen. */ const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL const suite = url ? describe : describe.skip let db: PrismaClient const marke = `test-${Date.now()}` suite('Datenbankregeln', () => { let caseId: string let entryId: string beforeAll(async () => { db = createPrismaClient(url) const template = await db.documentTemplate.create({ data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] }, }) const user = await db.user.create({ data: { id: `${marke}-nutzer`, username: 'pruef', displayName: 'Prüfer' }, }) const fall = await db.case.create({ data: { caseNumber: `AfiS-9999-${Date.now().toString(36)}`, title: 'Prüffall', templateId: template.id, templateKey: template.key, createdById: user.id, classification: 1, }, }) caseId = fall.id const eintrag = await db.caseEntry.create({ data: { id: `${marke}-eintrag`, caseId, type: 'NOTE', body: 'Ursprünglicher Text', authorId: user.id, minClearance: 0, }, }) entryId = eintrag.id }) afterAll(async () => { // CaseEntry lässt sich nicht löschen, deshalb bleibt der Prüffall stehen. // Aufgeräumt wird, was aufräumbar ist. await db.caseSection.deleteMany({ where: { caseId } }).catch(() => undefined) await db.$disconnect() }) it('lässt einen Verlaufseintrag nicht löschen, auch nicht über rohes SQL', async () => { await db.$executeRawUnsafe(`DELETE FROM "CaseEntry" WHERE id = $1`, entryId) const danach = await db.caseEntry.findUnique({ where: { id: entryId } }) expect(danach).not.toBeNull() expect(danach?.body).toBe('Ursprünglicher Text') }) it('lässt Autor und Entstehungszeit eines Eintrags nicht ändern', async () => { await expect( db.$executeRawUnsafe(`UPDATE "CaseEntry" SET "authorId" = 'jemand-anders' WHERE id = $1`, entryId), ).rejects.toThrow(/unveränderlich/) await expect( db.$executeRawUnsafe(`UPDATE "CaseEntry" SET "createdAt" = now() WHERE id = $1`, entryId), ).rejects.toThrow(/unveränderlich/) }) it('erlaubt an einer Eintragsversion nur das Anheben von minClearance', async () => { const version = await db.caseEntryVersion.create({ data: { entryId, version: 1, body: 'Vorheriger Stand', minClearance: 0, editedById: `${marke}-nutzer` }, }) // Umklassifizierung wirkt rückwirkend: das muss durchgehen. await db.caseEntryVersion.update({ where: { id: version.id }, data: { minClearance: 4 } }) const angehoben = await db.caseEntryVersion.findUnique({ where: { id: version.id } }) expect(angehoben?.minClearance).toBe(4) // Absenken nicht, sonst leckt der alte Text über die Historie. await expect( db.caseEntryVersion.update({ where: { id: version.id }, data: { minClearance: 1 } }), ).rejects.toThrow(/nur angehoben/) // Der Text bleibt unantastbar. await expect( db.caseEntryVersion.update({ where: { id: version.id }, data: { body: 'umgeschrieben' } }), ).rejects.toThrow(/anhängend/) // Und gelöscht wird sie auch nicht. await db.$executeRawUnsafe(`DELETE FROM "CaseEntryVersion" WHERE id = $1`, version.id) expect(await db.caseEntryVersion.findUnique({ where: { id: version.id } })).not.toBeNull() }) it('lässt einen Protokolleintrag weder ändern noch löschen', async () => { const eintrag = await db.auditLog.create({ data: { actorId: `${marke}-nutzer`, action: 'case.read', targetType: 'Case', targetId: caseId, clearanceAtTime: 1, }, }) await db.$executeRawUnsafe(`DELETE FROM "AuditLog" WHERE id = ${eintrag.id}`) await db.$executeRawUnsafe(`UPDATE "AuditLog" SET action = 'gefälscht' WHERE id = ${eintrag.id}`) const danach = await db.auditLog.findUnique({ where: { id: eintrag.id } }) expect(danach?.action).toBe('case.read') }) it('weist Freigabestufen ausserhalb von 0 bis 5 ab', async () => { await expect( db.$executeRawUnsafe(`UPDATE "Case" SET classification = 9 WHERE id = $1`, caseId), ).rejects.toThrow() }) it('verlangt für einen Beteiligten mindestens eine Zuordnung', async () => { await expect( db.$executeRawUnsafe( `INSERT INTO "CaseParticipant" (id, "caseId", role) VALUES ('x-${marke}', $1, 'WITNESS')`, caseId, ), ).rejects.toThrow() }) it('lässt eine Chatnachricht nur an genau einem Ort hängen', async () => { const person = await db.personnelFile.create({ data: { displayName: `${marke}-person`, classification: 1 }, }) // Zwei Orte: geht nicht. await expect( db.chatMessage.create({ data: { id: `${marke}-nachricht-zwei`, caseId, personnelFileId: person.id, authorId: `${marke}-nutzer`, body: 'gehört an zwei Stellen', }, }), ).rejects.toThrow() // Gar kein Ort: geht auch nicht. Eine Nachricht ohne Akte wäre ein // Gespräch ohne Vorgang, und genau das soll es nicht mehr geben. await expect( db.chatMessage.create({ data: { id: `${marke}-nachricht-null`, authorId: `${marke}-nutzer`, body: 'gehört nirgends hin', }, }), ).rejects.toThrow() // Einer: geht. const gut = await db.chatMessage.create({ data: { id: `${marke}-nachricht-eins`, personnelFileId: person.id, authorId: `${marke}-nutzer`, body: 'gehört zur Personalakte', }, }) expect(gut.personnelFileId).toBe(person.id) }) it('lässt ein Dokument nicht löschen', async () => { const doc = await db.document.create({ data: { documentNumber: `PRF-9999-${Date.now().toString(36)}`, title: 'Prüfdokument', templateId: (await db.documentTemplate.findFirstOrThrow({ where: { key: `${marke}-vorlage` }, })).id, templateKey: `${marke}-vorlage`, templateVersion: 1, caseId, createdById: `${marke}-nutzer`, }, }) // Die Regel schluckt das DELETE, statt zu werfen: so verhält sich // DO INSTEAD NOTHING. Entscheidend ist, dass die Zeile stehen bleibt. await db.document.deleteMany({ where: { id: doc.id } }) expect(await db.document.findUnique({ where: { id: doc.id } })).not.toBeNull() }) it('lässt ein Dokument nicht an zwei Besitzern zugleich hängen', async () => { const person = await db.personnelFile.create({ data: { displayName: `${marke} Person`, classification: 1 }, }) await expect( db.document.create({ data: { documentNumber: `PRF-9998-${Date.now().toString(36)}`, title: 'Zwei Besitzer', templateId: (await db.documentTemplate.findFirstOrThrow({ where: { key: `${marke}-vorlage` }, })).id, templateKey: `${marke}-vorlage`, templateVersion: 1, caseId, personnelFileId: person.id, createdById: `${marke}-nutzer`, }, }), ).rejects.toThrow() }) it('lässt den Besitzer eines Dokuments nicht nachträglich wechseln', async () => { const doc = await db.document.create({ data: { documentNumber: `PRF-9997-${Date.now().toString(36)}`, title: 'Unverrückbar', templateId: (await db.documentTemplate.findFirstOrThrow({ where: { key: `${marke}-vorlage` }, })).id, templateKey: `${marke}-vorlage`, templateVersion: 1, caseId, createdById: `${marke}-nutzer`, }, }) // Ein Vertrag, der nachträglich an einer anderen Person hängt, wäre eine // Fälschung. Der Trigger lässt das nicht zu. await expect( db.document.update({ where: { id: doc.id }, data: { caseId: null } }), ).rejects.toThrow() }) it('löst Schwärzungen im Suchindex stufenweise auf', async () => { // Der Vektor zur Stufe N muss enthalten, was diese Stufe lesen darf — und // nichts darüber. Sonst findet ein Leser der Stufe 4 einen Namen nicht, // den er auf der Seite sieht, oder ein Leser der Stufe 1 einen, den er // dort nur als Balken sieht. const [zeile] = await db.$queryRawUnsafe<{ tief: string; hoch: string }[]>( `SELECT strip_redactions_above($1, 1) AS tief, strip_redactions_above($1, 4) AS hoch`, 'Offen [[c4]]Perreira[[/c4]] Schluss', ) expect(zeile?.tief).not.toContain('Perreira') expect(zeile?.tief).toContain('Offen') expect(zeile?.hoch).toContain('Perreira') // Die Marker selbst gehören nie in den Index. expect(zeile?.hoch).not.toContain('[[c4]]') }) it('schreibt für jede Stufe eine Indexzeile und zieht sie nach', async () => { const stufen = await db.$queryRawUnsafe<{ minClearance: number }[]>( `SELECT "minClearance" FROM "SearchIndex" WHERE "targetType" = 'case' AND "targetId" = $1 ORDER BY "minClearance"`, caseId, ) expect(stufen.map((s) => s.minClearance)).toEqual([0, 1, 2, 3, 4, 5]) // Eine Änderung am Titel muss durchschlagen. await db.case.update({ where: { id: caseId }, data: { title: 'Umbenannter Prüffall' } }) const [treffer] = await db.$queryRawUnsafe<{ gefunden: boolean }[]>( `SELECT "vector" @@ websearch_to_tsquery('german', 'Umbenannter') AS gefunden FROM "SearchIndex" WHERE "targetType" = 'case' AND "targetId" = $1 AND "minClearance" = 0`, caseId, ) expect(treffer?.gefunden).toBe(true) }) it('nimmt geschwärzte Bereiche nicht in den Suchvektor auf', async () => { await db.caseEntry.create({ data: { id: `${marke}-geschwaerzt`, caseId, type: 'NOTE', body: 'Offener Teil [[c4]]Zeugenname Perreira[[/c4]] Schluss', authorId: `${marke}-nutzer`, minClearance: 0, }, }) const [treffer] = await db.$queryRawUnsafe<{ offen: boolean; geheim: boolean }[]>( `SELECT "searchVector" @@ websearch_to_tsquery('german', 'Offener') AS offen, "searchVector" @@ websearch_to_tsquery('german', 'Perreira') AS geheim FROM "Case" WHERE id = $1`, caseId, ) expect(treffer?.offen).toBe(true) expect(treffer?.geheim).toBe(false) }) })