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
Bisher gab es ueberhaupt keinen Weg, einen Anhang wieder loszuwerden. Wer die falsche Datei hochlud, musste damit leben. Das ist keine Strenge, sondern eine Luecke: die Regel des Hauses lautet nicht "nichts geht wieder weg", sondern "nichts geht unbemerkt weg". Also derselbe Weg wie beim Verlaufseintrag und beim Dokument. Zurueckziehen mit Pflichtbegruendung, der Anhang bleibt als Grabstein an seiner Stelle stehen - durchgestrichen, mit Zeitpunkt und Grund, fuer jeden sichtbar, der ihn sehen darf. Aufheben laesst es sich, beides steht im Protokoll. Aus dem Umlauf heisst wirklich aus dem Umlauf: die Auslieferung verweigert die Datei, sie verschwindet aus Lichtbildfeldern, und das Vorschaubild faellt weg. Ein Grabstein, unter dem der Download weiterlaeuft, waere eine Beschriftung und keine Ruecknahme. Fuer `audit.read` bleibt sie abrufbar - wie der Text eines zurueckgezogenen Eintrags, sonst waere es doch ein Loeschen. Im Ausdruck steht der Anhang weiter im Verzeichnis, mit dem Grund daneben. Ihn dort wegzulassen hiesse, den Ausdruck sauberer zu machen als die Akte. Die Datei auf der Platte bleibt liegen. Sie zu entfernen waere das Loeschen, das hier gerade nicht stattfindet - und ein Anhang, dessen Pruefsumme nicht mehr nachrechenbar ist, waere als Beweismittel wertlos. Dazu eine Regel in der Datenbank, die DELETE auf Attachment verbietet, und eine Bedingung, die einen Grabstein ohne Begruendung ausschliesst. Zuruecknehmen darf, wer die Datei hochgeladen hat - der haeufige Fall, und ohne das bliebe jeder Fehlgriff liegen, weil dafuer die Leitung zu rufen waere - sowie jeder, der am Besitzer schreiben darf.
301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
// @vitest-environment node
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
import { db } from '@/lib/db'
|
|
import { visibleAttachmentWhere } from '@/lib/access/attachmentFilter'
|
|
import { loadDeliverable, restoreAttachment, retractAttachment } from '@/lib/files/service'
|
|
import { AccessError } from '@/lib/errors'
|
|
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
|
|
|
|
/**
|
|
* Ein Anhang hat keine eigene Freigabestufe, er erbt die seines Besitzers.
|
|
* Diese Tests prüfen beide Seiten davon: dass die Stufe wirklich durchschlägt,
|
|
* und dass eine Datei, die die Prüfkette nicht bestanden hat, gar nicht erst
|
|
* ausgeliefert wird - auch nicht an einen Administrator.
|
|
*/
|
|
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
|
|
const suite = url ? describe : describe.skip
|
|
|
|
const marke = `anhang-${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('Sichtbarkeit von Anhängen', () => {
|
|
const nutzer = { stufe1: `${marke}-stufe1`, stufe4: `${marke}-stufe4` }
|
|
const anhang = { offen: '', geheim: '', imEintrag: '', inQuarantaene: '' }
|
|
|
|
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: `${marke}-o`.slice(0, 40),
|
|
title: 'Offen',
|
|
classification: 1,
|
|
status: 'OPEN',
|
|
},
|
|
})
|
|
const geheim = await db.case.create({
|
|
data: {
|
|
...gemeinsam,
|
|
caseNumber: `${marke}-g`.slice(0, 40),
|
|
title: 'Geheim',
|
|
classification: 4,
|
|
status: 'OPEN',
|
|
},
|
|
})
|
|
|
|
const eintrag = await db.caseEntry.create({
|
|
data: {
|
|
id: `${marke}-eintrag`,
|
|
caseId: offen.id,
|
|
type: 'NOTE',
|
|
body: 'Nur ab Stufe 4',
|
|
authorId: nutzer.stufe4,
|
|
minClearance: 4,
|
|
},
|
|
})
|
|
|
|
const anlegen = async (
|
|
name: string,
|
|
besitzer: { caseId?: string; entryId?: string },
|
|
scanStatus: 'CLEAN' | 'PENDING' = 'CLEAN',
|
|
) =>
|
|
(
|
|
await db.attachment.create({
|
|
data: {
|
|
originalName: `${name}.png`,
|
|
mimeType: 'image/png',
|
|
sizeBytes: 100,
|
|
sha256: `${marke}-${name}`,
|
|
storageKey: `store/aa/bb/${marke}-${name}`,
|
|
uploadedById: nutzer.stufe4,
|
|
scanStatus,
|
|
...besitzer,
|
|
},
|
|
select: { id: true },
|
|
})
|
|
).id
|
|
|
|
anhang.offen = await anlegen('offen', { caseId: offen.id })
|
|
anhang.geheim = await anlegen('geheim', { caseId: geheim.id })
|
|
anhang.imEintrag = await anlegen('eintrag', { entryId: eintrag.id })
|
|
anhang.inQuarantaene = await anlegen('quarantaene', { caseId: offen.id }, 'PENDING')
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await db.$disconnect()
|
|
})
|
|
|
|
async function sichtbar(p: EffectivePermissions): Promise<string[]> {
|
|
const rows = await db.attachment.findMany({
|
|
where: { AND: [visibleAttachmentWhere(p), { id: { in: Object.values(anhang) } }] },
|
|
select: { id: true },
|
|
})
|
|
return rows.map((r) => r.id).sort()
|
|
}
|
|
|
|
it('verbirgt den Anhang einer zu hoch eingestuften Akte, auch in der Zählung', async () => {
|
|
const p = perms(nutzer.stufe1, 1)
|
|
const treffer = await sichtbar(p)
|
|
|
|
expect(treffer).toContain(anhang.offen)
|
|
expect(treffer).not.toContain(anhang.geheim)
|
|
|
|
const anzahl = await db.attachment.count({
|
|
where: { AND: [visibleAttachmentWhere(p), { id: { in: Object.values(anhang) } }] },
|
|
})
|
|
expect(anzahl).toBe(treffer.length)
|
|
})
|
|
|
|
it('verbirgt den Anhang eines Eintrags über der Stufe des Lesers', async () => {
|
|
// Die Akte darf er sehen, den Eintrag nicht - und damit auch dessen Datei
|
|
// nicht. Sonst verriete die Anhangsliste den Inhalt des Eintrags.
|
|
expect(await sichtbar(perms(nutzer.stufe1, 1))).not.toContain(anhang.imEintrag)
|
|
expect(await sichtbar(perms(nutzer.stufe4, 4))).toContain(anhang.imEintrag)
|
|
})
|
|
|
|
it('liefert nichts aus, was die Prüfkette nicht bestanden hat', async () => {
|
|
// Auch nicht dem Administrator: die Datei liegt in der Quarantäne, dort
|
|
// führt kein Auslieferungspfad hin.
|
|
expect(await loadDeliverable(perms(nutzer.stufe4, 5, { isAdmin: true }), anhang.inQuarantaene))
|
|
.toBeNull()
|
|
expect(await loadDeliverable(perms(nutzer.stufe4, 4), anhang.offen)).not.toBeNull()
|
|
})
|
|
|
|
it('liefert einem zu niedrig eingestuften Leser nichts, sondern nichts Findbares', async () => {
|
|
// `null` und nicht "verboten": der Endpunkt macht daraus 404, die Existenz
|
|
// der Datei bleibt verborgen.
|
|
expect(await loadDeliverable(perms(nutzer.stufe1, 1), anhang.geheim)).toBeNull()
|
|
})
|
|
})
|
|
|
|
suite('Anhänge zurückziehen', () => {
|
|
/**
|
|
* Zurückziehen statt Löschen, wie beim Verlaufseintrag.
|
|
*
|
|
* Der Sinn steht und fällt damit, dass die Datei danach wirklich aus dem
|
|
* Umlauf ist - ein Grabstein, unter dem der Download weiterhin funktioniert,
|
|
* wäre eine Beschriftung und keine Rücknahme. Umgekehrt darf sie nicht
|
|
* verschwinden: für `audit.read` bleibt sie abrufbar, sonst wäre es eben
|
|
* doch ein Löschen.
|
|
*/
|
|
const nutzer = {
|
|
hochlader: `${marke}-r-hochlader`,
|
|
fremd: `${marke}-r-fremd`,
|
|
pruefer: `${marke}-r-pruefer`,
|
|
}
|
|
let anhangId = ''
|
|
let zweiterId = ''
|
|
|
|
beforeAll(async () => {
|
|
const vorlage = await db.documentTemplate.create({
|
|
data: { key: `${marke}-r-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 akte = await db.case.create({
|
|
data: {
|
|
caseNumber: `${marke}-R1`,
|
|
title: 'Rücknahme',
|
|
templateId: vorlage.id,
|
|
templateKey: vorlage.key,
|
|
classification: 1,
|
|
status: 'OPEN',
|
|
createdById: nutzer.hochlader,
|
|
},
|
|
})
|
|
|
|
const anlegen = async (name: string) =>
|
|
(
|
|
await db.attachment.create({
|
|
data: {
|
|
originalName: `${name}.png`,
|
|
mimeType: 'image/png',
|
|
sizeBytes: 100,
|
|
sha256: `${marke}-r-${name}`,
|
|
storageKey: `store/aa/bb/${marke}-r-${name}`,
|
|
uploadedById: nutzer.hochlader,
|
|
scanStatus: 'CLEAN',
|
|
caseId: akte.id,
|
|
},
|
|
select: { id: true },
|
|
})
|
|
).id
|
|
|
|
anhangId = await anlegen('erster')
|
|
zweiterId = await anlegen('zweiter')
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await db.$disconnect()
|
|
})
|
|
|
|
it('verlangt eine Begründung', async () => {
|
|
// Ohne Grund wäre die Rücknahme nur ein Verschwinden mit Zusatzschritt.
|
|
await expect(
|
|
retractAttachment(perms(nutzer.hochlader, 1), anhangId, 'zu kurz'),
|
|
).rejects.toMatchObject({ code: 'reason-required' })
|
|
await expect(
|
|
retractAttachment(perms(nutzer.hochlader, 1), anhangId, ' '),
|
|
).rejects.toMatchObject({ code: 'reason-required' })
|
|
|
|
const zeile = await db.attachment.findUniqueOrThrow({ where: { id: anhangId } })
|
|
expect(zeile.retractedAt).toBeNull()
|
|
})
|
|
|
|
it('lässt den Hochladenden seinen eigenen Fehlgriff zurücknehmen', async () => {
|
|
await retractAttachment(perms(nutzer.hochlader, 1), anhangId, 'Falsche Datei erwischt')
|
|
|
|
const zeile = await db.attachment.findUniqueOrThrow({ where: { id: anhangId } })
|
|
expect(zeile.retractedAt).not.toBeNull()
|
|
expect(zeile.retractedById).toBe(nutzer.hochlader)
|
|
expect(zeile.retractionReason).toBe('Falsche Datei erwischt')
|
|
})
|
|
|
|
it('nimmt die Datei aus dem Umlauf', async () => {
|
|
// Der eigentliche Punkt. Ein Download, der weiterläuft, wäre keine
|
|
// Rücknahme.
|
|
expect(await loadDeliverable(perms(nutzer.fremd, 4), anhangId)).toBeNull()
|
|
})
|
|
|
|
it('lässt sie für die Nachprüfung abrufbar', async () => {
|
|
// Wie beim Text eines zurückgezogenen Eintrags: nachprüfbar bleibt sie.
|
|
const mitEinsicht = perms(nutzer.pruefer, 4, { permissions: ['audit.read'] })
|
|
expect(await loadDeliverable(mitEinsicht, anhangId)).not.toBeNull()
|
|
})
|
|
|
|
it('steht mit Begründung im Protokoll', async () => {
|
|
const eintrag = await db.auditLog.findFirst({
|
|
where: { action: 'attachment.retract', targetId: anhangId },
|
|
orderBy: { at: 'desc' },
|
|
})
|
|
expect(eintrag?.meta).toMatchObject({ reason: 'Falsche Datei erwischt' })
|
|
})
|
|
|
|
it('nimmt sie kein zweites Mal zurück', async () => {
|
|
await expect(
|
|
retractAttachment(perms(nutzer.hochlader, 1), anhangId, 'Noch ein Grund dafür'),
|
|
).rejects.toMatchObject({ code: 'already-retracted' })
|
|
})
|
|
|
|
it('lässt die Rücknahme aufheben', async () => {
|
|
await restoreAttachment(perms(nutzer.hochlader, 1), anhangId)
|
|
|
|
const zeile = await db.attachment.findUniqueOrThrow({ where: { id: anhangId } })
|
|
expect(zeile.retractedAt).toBeNull()
|
|
expect(zeile.retractionReason).toBeNull()
|
|
// Und die Datei ist wieder abrufbar.
|
|
expect(await loadDeliverable(perms(nutzer.fremd, 4), anhangId)).not.toBeNull()
|
|
})
|
|
|
|
it('weist einen Fremden ohne Schreibrecht ab', async () => {
|
|
// Er sieht die Akte, darf aber nicht in ihr schreiben - also auch nichts
|
|
// aus ihr herausnehmen.
|
|
await expect(
|
|
retractAttachment(perms(nutzer.fremd, 4), zweiterId, 'Gefällt mir nicht so recht'),
|
|
).rejects.toBeInstanceOf(AccessError)
|
|
|
|
const zeile = await db.attachment.findUniqueOrThrow({ where: { id: zweiterId } })
|
|
expect(zeile.retractedAt).toBeNull()
|
|
})
|
|
|
|
it('gibt es nicht für jemanden, der die Akte gar nicht sieht', async () => {
|
|
// 404 statt 403, wie überall.
|
|
await expect(
|
|
retractAttachment(perms(nutzer.fremd, 0), zweiterId, 'Irgendein Grund hier'),
|
|
).rejects.toMatchObject({ code: 'not-found' })
|
|
})
|
|
|
|
it('lässt sich in der Datenbank nicht löschen', async () => {
|
|
// Die Regel im Schema ist die letzte Schranke: ein Anhang haengt an einem
|
|
// Eintrag, der nicht verschwinden kann, und darf es selbst auch nicht.
|
|
await db.attachment.deleteMany({ where: { id: zweiterId } })
|
|
expect(await db.attachment.findUnique({ where: { id: zweiterId } })).not.toBeNull()
|
|
})
|
|
})
|