// @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(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 { 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() }) })