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.
302 lines
10 KiB
TypeScript
302 lines
10 KiB
TypeScript
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)
|
|
})
|
|
})
|