diff --git a/packages/db/prisma/migrations/20260904180000_anhang_zurueckziehen/migration.sql b/packages/db/prisma/migrations/20260904180000_anhang_zurueckziehen/migration.sql new file mode 100644 index 0000000..a722a40 --- /dev/null +++ b/packages/db/prisma/migrations/20260904180000_anhang_zurueckziehen/migration.sql @@ -0,0 +1,30 @@ +-- Anhänge zurückziehen. +-- +-- Bisher gab es überhaupt keinen Weg, einen Anhang wieder loszuwerden. Wer die +-- falsche Datei hochlud, musste damit leben - und das ist keine Strenge, +-- sondern eine Lücke: die Regel des Hauses lautet nicht "nichts geht wieder +-- weg", sondern "nichts geht **unbemerkt** weg" (siehe README, Zusicherung 2). +-- +-- Also derselbe Weg wie beim Verlaufseintrag und beim Dokument: zurückziehen +-- mit Pflichtbegründung, der Anhang bleibt als Grabstein an seiner Stelle +-- stehen, die Datei wird nicht mehr ausgeliefert. Rückgängig machen geht, +-- protokolliert wird beides. + +ALTER TABLE "Attachment" ADD COLUMN "retractedAt" TIMESTAMP(3); +ALTER TABLE "Attachment" ADD COLUMN "retractedById" TEXT; +ALTER TABLE "Attachment" ADD COLUMN "retractionReason" TEXT; + +-- Zurückgezogen heisst: mit Zeitpunkt, mit Urheber und mit Begründung. Ein +-- Grabstein ohne Grund wäre nur ein Verschwinden mit Zusatzschritt. +ALTER TABLE "Attachment" + ADD CONSTRAINT "Attachment_ruecknahme_vollstaendig" + CHECK ( + ("retractedAt" IS NULL AND "retractedById" IS NULL AND "retractionReason" IS NULL) + OR ("retractedAt" IS NOT NULL AND "retractedById" IS NOT NULL + AND length(btrim("retractionReason")) >= 10) + ); + +-- Kein DELETE, wie bei Eintrag und Dokument. Ein Anhang ist Beweismittel: er +-- haengt an einem Eintrag, der nicht verschwinden kann, und darf deshalb auch +-- selbst nicht verschwinden. +CREATE RULE attachment_no_delete AS ON DELETE TO "Attachment" DO INSTEAD NOTHING; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 365451a..f8965ec 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -720,6 +720,13 @@ model Attachment { releasedById String? releasedAt DateTime? + /// Zurueckgezogen statt geloescht - wie beim Verlaufseintrag. Der Anhang + /// bleibt als Grabstein stehen, die Datei wird nicht mehr ausgeliefert. + /// Begruendung ist Pflicht. Siehe README, Zusicherung 2. + retractedAt DateTime? + retractedById String? + retractionReason String? + entry CaseEntry? @relation(fields: [entryId], references: [id], onDelete: Cascade) case Case? @relation(fields: [caseId], references: [id], onDelete: Cascade) document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) diff --git a/web/app/(app)/dateien/actions.ts b/web/app/(app)/dateien/actions.ts index 676924b..f9ca75a 100644 --- a/web/app/(app)/dateien/actions.ts +++ b/web/app/(app)/dateien/actions.ts @@ -4,7 +4,11 @@ import { revalidatePath } from 'next/cache' import { z } from 'zod' import { requireUser } from '@/lib/auth/current' import { AccessError } from '@/lib/errors' -import { releaseAttachment } from '@/lib/files/service' +import { + releaseAttachment, + restoreAttachment, + retractAttachment, +} from '@/lib/files/service' import { de } from '@/lib/i18n/de' export type ActionResult = { ok: true } | { ok: false; error: string } @@ -36,3 +40,59 @@ export async function releaseAttachmentAction(input: unknown): Promise { + const user = await requireUser() + const parsed = ruecknahmeSchema.safeParse(input) + if (!parsed.success) { + return { ok: false, error: de.actionErrors['reason-required'] ?? de.actionErrors.default! } + } + + try { + await retractAttachment(user.permissions, parsed.data.id, parsed.data.reason) + revalidatePath('/faelle') + revalidatePath('/personalakten') + revalidatePath('/dokumente') + return { ok: true } + } catch (error) { + return zuMeldung(error, 'retract') + } +} + +/** Rücknahme aufheben. Steht ebenfalls im Protokoll. */ +export async function restoreAttachmentAction(input: unknown): Promise { + const user = await requireUser() + const parsed = schema.safeParse(input) + if (!parsed.success) return { ok: false, error: de.actionErrors.default! } + + try { + await restoreAttachment(user.permissions, parsed.data.id) + revalidatePath('/faelle') + revalidatePath('/personalakten') + revalidatePath('/dokumente') + return { ok: true } + } catch (error) { + return zuMeldung(error, 'restore') + } +} diff --git a/web/components/files/Attachments.tsx b/web/components/files/Attachments.tsx index 208df01..ab3d731 100644 --- a/web/components/files/Attachments.tsx +++ b/web/components/files/Attachments.tsx @@ -3,7 +3,11 @@ import { useCallback, useRef, useState, useTransition } from 'react' import { attachmentText } from '@/lib/i18n/documents' import { MAX_FILE_BYTES, formatBytes } from '@/lib/files/limits' -import { releaseAttachmentAction } from '@/app/(app)/dateien/actions' +import { + releaseAttachmentAction, + restoreAttachmentAction, + retractAttachmentAction, +} from '@/app/(app)/dateien/actions' import { useStream, type ScanEvent } from '@/components/realtime/useStream' export type AttachmentView = { @@ -18,6 +22,8 @@ export type AttachmentView = { height: number | null hasThumb: boolean createdAt: string + retractedAt: string | null + retractionReason: string | null } /** Welchem Besitzer die Datei angehängt wird. Genau eines der drei Felder. */ @@ -49,8 +55,14 @@ export function Attachments({ topic: string initial: AttachmentView[] canUpload: boolean + /** + * Wer anhängen darf, darf auch zurücknehmen - und die eigene Datei ohnehin. + * Die endgültige Prüfung steht im Dienst; hier entscheidet sie nur, ob der + * Knopf sichtbar ist. + */ canRelease: boolean }) { + const [fehler, setFehler] = useState(null) const [dateien, setDateien] = useState(initial) const [laufend, setLaufend] = useState([]) const [pending, startTransition] = useTransition() @@ -125,6 +137,8 @@ export function Attachments({ height: null, hasThumb: false, createdAt: new Date().toISOString(), + retractedAt: null, + retractionReason: null, }, ]) fertig() @@ -158,7 +172,7 @@ export function Attachments({ {dateien.map((datei) => (
  • - {datei.hasThumb ? ( + {datei.hasThumb && !datei.retractedAt ? ( // eslint-disable-next-line @next/next/no-img-element ) : null} - {datei.originalName} + + {datei.originalName} + {formatBytes(datei.sizeBytes)} @@ -177,7 +197,7 @@ export function Attachments({ > {attachmentText.statusNames[datei.scanStatus] ?? datei.scanStatus} - {datei.scanStatus === 'CLEAN' ? ( + {datei.scanStatus === 'CLEAN' && !datei.retractedAt ? ( {attachmentText.download} @@ -197,7 +217,79 @@ export function Attachments({ {attachmentText.release} ) : null} + + {/* Zurückziehen statt Löschen: die Datei bleibt an ihrer + Stelle stehen, mit Begründung. Siehe README, Zusicherung 2. */} + {datei.retractedAt ? ( + + ) : ( + + )}
    + + {datei.retractedAt ? ( +

    + {attachmentText.retractedBy( + new Date(datei.retractedAt).toLocaleDateString('de-DE'), + datei.retractionReason ?? '', + )} +

    + ) : null} {datei.scanMessage ? (

    {datei.scanMessage}

    ) : null} @@ -225,6 +317,8 @@ export function Attachments({ )} + {fehler ?

    {fehler}

    : null} + {canUpload ? (
    {anhaenge.map((anhang) => ( - {anhang.originalName} + {/* Ein zurückgezogener Anhang bleibt im Verzeichnis stehen, + mit Grund. Ihn wegzulassen hiesse, den Ausdruck sauberer zu + machen als die Akte - siehe docs/08. */} + + {anhang.originalName} + {anhang.retractedAt ? ( + + {' '} + {de.attachment.retractedBy( + anhang.retractedAt.toLocaleDateString('de-DE'), + anhang.retractionReason ?? '', + )} + + ) : null} + {anhang.sizeBytes} {/* Der Hash macht nachprüfbar, dass die Datei zum Ausdruck gehört - deshalb steht er im Verzeichnis. */} diff --git a/web/lib/audit/write.ts b/web/lib/audit/write.ts index 0d2b14b..b4cd75c 100644 --- a/web/lib/audit/write.ts +++ b/web/lib/audit/write.ts @@ -43,6 +43,8 @@ export type AuditAction = | 'attachment.upload' | 'attachment.read' | 'attachment.release' + | 'attachment.retract' + | 'attachment.restore' | 'chat.adopt' | 'department.create' | 'department.update' diff --git a/web/lib/files/service.ts b/web/lib/files/service.ts index 9033c63..d27f39d 100644 --- a/web/lib/files/service.ts +++ b/web/lib/files/service.ts @@ -7,7 +7,7 @@ import { canEditCase } from '@/lib/access/caseWrite' import { canEditDocument } from '@/lib/access/documentWrite' import { loadVisibleCase } from '@/lib/cases/service' import { loadVisibleDocument } from '@/lib/documents/service' -import { visibleEntryWhere } from '@/lib/access/caseFilter' +import { visibleCaseWhere, visibleEntryWhere } from '@/lib/access/caseFilter' import { MAX_FILE_BYTES, MAX_UPLOADS_PER_MINUTE, @@ -48,6 +48,9 @@ const KOPFDATEN = { caseId: true, entryId: true, documentId: true, + retractedAt: true, + retractedById: true, + retractionReason: true, } satisfies Prisma.AttachmentSelect /** @@ -187,8 +190,21 @@ export async function listAttachments( * nicht mit Recht `admin`, denn die Datei liegt dann noch in der Quarantäne. */ export async function loadDeliverable(p: EffectivePermissions, id: string) { + // Ein zurückgezogener Anhang wird nicht mehr ausgeliefert - ausser an + // jemanden mit `audit.read`. Das ist dieselbe Regel wie beim Text eines + // zurückgezogenen Verlaufseintrags: die Sache bleibt nachprüfbar, ist aber + // aus dem Umlauf. Siehe docs/05-akten-und-vorlagen.md. + const darfNachpruefen = p.isAdmin || p.permissions.has('audit.read') + const anhang = await db.attachment.findFirst({ - where: { AND: [visibleAttachmentWhere(p), { id }, { scanStatus: 'CLEAN' }] }, + where: { + AND: [ + visibleAttachmentWhere(p), + { id }, + { scanStatus: 'CLEAN' }, + ...(darfNachpruefen ? [] : [{ retractedAt: null }]), + ], + }, select: { id: true, originalName: true, @@ -268,6 +284,9 @@ export async function resolvePhotoUrls( visibleAttachmentWhere(p), { id: { in: gesucht.map((g) => g.id) } }, { scanStatus: 'CLEAN' }, + // Ein zurückgezogenes Lichtbild verschwindet aus dem Feld. Bliebe es + // stehen, wäre die Rücknahme eine Beschriftung und keine. + { retractedAt: null }, ], }, select: { id: true, thumbKey: true }, @@ -282,3 +301,160 @@ export async function resolvePhotoUrls( } return ergebnis } + +/** + * Wer darf die Rücknahme eines Anhangs aussprechen? + * + * Zwei Wege, und der erste ist der häufige: **wer die Datei hochgeladen hat**, + * nimmt seinen eigenen Fehlgriff zurück. Sonst muss für jede versehentlich + * angehängte Datei die Leitung gerufen werden, und das führt dazu, dass sie + * eben liegen bleibt. + * + * Der zweite Weg ist, wer am Besitzer schreiben darf - dieselbe Prüfung wie + * beim Anhängen. Wer eine Datei hinzufügen darf, darf sie auch zurücknehmen. + */ +async function darfZuruecknehmen( + p: EffectivePermissions, + anhang: { + uploadedById: string + caseId: string | null + entryId: string | null + documentId: string | null + }, +): Promise { + if (p.isAdmin || anhang.uploadedById === p.userId) return + + if (anhang.documentId) { + const doc = await db.document.findUnique({ + where: { id: anhang.documentId }, + select: { status: true, classification: true, retractedAt: true }, + }) + if (!doc) throw new AccessError('not-found') + must(canEditDocument(p, doc)) + return + } + + const caseId = + anhang.caseId ?? + ( + await db.caseEntry.findUnique({ + where: { id: anhang.entryId ?? '' }, + select: { caseId: true }, + }) + )?.caseId + + if (!caseId) throw new AccessError('not-found') + + const akte = await db.case.findFirst({ + where: { AND: [visibleCaseWhere(p), { id: caseId }] }, + select: { status: true, classification: true }, + }) + if (!akte) throw new AccessError('not-found') + must(canEditCase(p, akte, 'entry.create')) +} + +/** Kürzer als das ist keine Begründung, sondern ein Achselzucken. */ +const MIN_BEGRUENDUNG = 10 + +/** + * Anhang zurückziehen. + * + * Kein Löschen: der Anhang bleibt als Grabstein an seiner Stelle stehen, mit + * Zeitpunkt, Urheber und Begründung. Die Datei wird nicht mehr ausgeliefert + * und verschwindet aus Lichtbildfeldern; für `audit.read` bleibt sie + * abrufbar. Genau wie beim Verlaufseintrag, siehe README, Zusicherung 2. + * + * Die Datei auf der Platte bleibt liegen. Sie zu entfernen wäre das Löschen, + * das hier gerade nicht stattfindet - und ein zurückgezogener Anhang, dessen + * Prüfsumme nicht mehr nachrechenbar ist, wäre als Beweismittel wertlos. + */ +export async function retractAttachment( + p: EffectivePermissions, + id: string, + reason: string, +): Promise { + const begruendung = reason.trim() + if (begruendung.length < MIN_BEGRUENDUNG) throw new AccessError('reason-required') + + const anhang = await db.attachment.findFirst({ + where: { AND: [visibleAttachmentWhere(p), { id }] }, + select: { + id: true, + originalName: true, + uploadedById: true, + caseId: true, + entryId: true, + documentId: true, + retractedAt: true, + }, + }) + if (!anhang) throw new AccessError('not-found') + if (anhang.retractedAt) throw new AccessError('already-retracted') + + await darfZuruecknehmen(p, anhang) + + await db.$transaction(async (tx) => { + await tx.attachment.update({ + where: { id }, + data: { + retractedAt: new Date(), + retractedById: p.userId, + retractionReason: begruendung.slice(0, 500), + }, + }) + + await writeAudit(tx, { + actorId: p.userId, + action: 'attachment.retract', + targetType: 'Attachment', + targetId: id, + clearanceAtTime: p.clearanceLevel, + meta: { originalName: anhang.originalName, reason: begruendung.slice(0, 500) }, + }) + }) +} + +/** + * Rücknahme aufheben. + * + * Möglich, weil ein Irrtum auch beim Zurückziehen vorkommt - und weil beides + * im Protokoll steht, ist daran nichts still. Der Grabstein verschwindet aus + * der Ansicht, die beiden Protokolleinträge bleiben. + */ +export async function restoreAttachment( + p: EffectivePermissions, + id: string, +): Promise { + const anhang = await db.attachment.findFirst({ + where: { AND: [visibleAttachmentWhere(p), { id }] }, + select: { + id: true, + originalName: true, + uploadedById: true, + caseId: true, + entryId: true, + documentId: true, + retractedAt: true, + }, + }) + if (!anhang) throw new AccessError('not-found') + if (!anhang.retractedAt) throw new AccessError('release-not-possible') + + await darfZuruecknehmen(p, anhang) + + await db.$transaction(async (tx) => { + await tx.attachment.update({ + where: { id }, + data: { retractedAt: null, retractedById: null, retractionReason: null }, + }) + + await writeAudit(tx, { + actorId: p.userId, + action: 'attachment.restore', + targetType: 'Attachment', + targetId: id, + clearanceAtTime: p.clearanceLevel, + meta: { originalName: anhang.originalName }, + }) + }) +} diff --git a/web/lib/files/view.ts b/web/lib/files/view.ts index d452d8e..bf3c154 100644 --- a/web/lib/files/view.ts +++ b/web/lib/files/view.ts @@ -21,6 +21,8 @@ export function toAttachmentViews( width: number | null height: number | null createdAt: Date + retractedAt: Date | null + retractionReason: string | null }[], ): AttachmentView[] { return rows.map((row) => ({ @@ -35,5 +37,7 @@ export function toAttachmentViews( height: row.height, hasThumb: row.thumbKey !== null, createdAt: row.createdAt.toISOString(), + retractedAt: row.retractedAt?.toISOString() ?? null, + retractionReason: row.retractionReason, })) } diff --git a/web/lib/i18n/admin.ts b/web/lib/i18n/admin.ts index cd2357d..e36c9a6 100644 --- a/web/lib/i18n/admin.ts +++ b/web/lib/i18n/admin.ts @@ -47,6 +47,8 @@ export const auditText = { 'document.retract': 'Dokument zurückgezogen', 'document.reclassify': 'Dokument umklassifiziert', 'attachment.upload': 'Datei hochgeladen', + 'attachment.retract': 'Anhang zurückgezogen', + 'attachment.restore': 'Rücknahme aufgehoben', 'attachment.read': 'Datei abgerufen', 'attachment.release': 'Datei freigegeben', 'chat.adopt': 'Nachricht als Vermerk übernommen', diff --git a/web/lib/i18n/documents.ts b/web/lib/i18n/documents.ts index ea8c206..ab86514 100644 --- a/web/lib/i18n/documents.ts +++ b/web/lib/i18n/documents.ts @@ -69,6 +69,15 @@ export const attachmentText = { checking: 'Wird geprüft', download: 'Herunterladen', release: 'Trotzdem freigeben', + retract: 'Zurückziehen', + retracting: 'Wird zurückgezogen', + restore: 'Rücknahme aufheben', + retractPrompt: + 'Warum wird dieser Anhang zurückgezogen? Die Begründung steht für alle an der Datei, mindestens 10 Zeichen.', + retractedBy: (datum: string, grund: string) => + `Zurückgezogen am ${datum} · Begründung: ${grund}`, + retractedHint: + 'Die Datei wird nicht mehr ausgeliefert und verschwindet aus Lichtbildfeldern. Gelöscht ist sie nicht.', releaseHint: 'Die Datei wurde nicht auf Schadsoftware geprüft. Sie wird beim Freigeben neu kodiert, soweit das geht.', quotaUsed: (used: string, total: string) => `${used} von ${total} belegt`, @@ -84,6 +93,8 @@ export const attachmentText = { 'quota-exceeded': 'Dein Kontingent von 500 MB ist ausgeschöpft.', 'rate-limited': 'Zu viele Uploads. In einer Minute erneut versuchen.', 'release-not-possible': 'Nur eine unvollständige Prüfung lässt sich freigeben.', + 'already-retracted': 'Dieser Anhang ist bereits zurückgezogen.', + 'reason-required': 'Begründung nötig, mindestens 10 Zeichen.', transfer: 'Übertragung fehlgeschlagen. Erneut versuchen.', } as Record, } as const diff --git a/web/tests/anhaenge.test.ts b/web/tests/anhaenge.test.ts index 41cd6dd..9edef34 100644 --- a/web/tests/anhaenge.test.ts +++ b/web/tests/anhaenge.test.ts @@ -2,7 +2,8 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { db } from '@/lib/db' import { visibleAttachmentWhere } from '@/lib/access/attachmentFilter' -import { loadDeliverable } from '@/lib/files/service' +import { loadDeliverable, restoreAttachment, retractAttachment } from '@/lib/files/service' +import { AccessError } from '@/lib/errors' import type { EffectivePermissions, Permission } from '@/lib/auth/permissions' /** @@ -150,3 +151,150 @@ suite('Sichtbarkeit von Anhängen', () => { 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() + }) +})