Anhaenge zurueckziehen statt sie liegen zu lassen
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.
This commit is contained in:
Harlekin
2026-09-04 16:18:33 +02:00
parent ad3bf48d2c
commit 42dbf0f6b1
11 changed files with 566 additions and 10 deletions
@@ -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;
+7
View File
@@ -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)
+61 -1
View File
@@ -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<ActionRes
return { ok: false, error: de.actionErrors.default! }
}
}
function zuMeldung(error: unknown, stelle: string): ActionResult {
if (error instanceof AccessError) {
return {
ok: false,
error:
de.attachment.errors[error.code] ?? de.actionErrors[error.code] ?? de.actionErrors.default!,
}
}
console.error(`[dateien] ${stelle} fehlgeschlagen`, error)
return { ok: false, error: de.actionErrors.default! }
}
const ruecknahmeSchema = z.object({
id: z.string().min(1),
// Die Mindestlänge steht auch im Dienst und als Bedingung in der Datenbank.
// Dreifach, weil eine Rücknahme ohne Grund nur ein Verschwinden mit
// Zusatzschritt wäre.
reason: z.string().trim().min(10).max(500),
})
/** Anhang zurückziehen. Kein Löschen - der Grabstein bleibt stehen. */
export async function retractAttachmentAction(input: unknown): Promise<ActionResult> {
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<ActionResult> {
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')
}
}
+98 -4
View File
@@ -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<string | null>(null)
const [dateien, setDateien] = useState<AttachmentView[]>(initial)
const [laufend, setLaufend] = useState<Laufend[]>([])
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) => (
<li key={datei.id} className="border-b border-line py-2">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
{datei.hasThumb ? (
{datei.hasThumb && !datei.retractedAt ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/files/${datei.id}?variant=thumb`}
@@ -166,7 +180,13 @@ export function Attachments({
className="h-10 w-10 border border-line object-cover"
/>
) : null}
<span className="grow text-sm">{datei.originalName}</span>
<span
className={`grow text-sm ${
datei.retractedAt ? 'text-text-dim line-through' : ''
}`}
>
{datei.originalName}
</span>
<span className="font-mono text-2xs text-text-dim">
{formatBytes(datei.sizeBytes)}
</span>
@@ -177,7 +197,7 @@ export function Attachments({
>
{attachmentText.statusNames[datei.scanStatus] ?? datei.scanStatus}
</span>
{datei.scanStatus === 'CLEAN' ? (
{datei.scanStatus === 'CLEAN' && !datei.retractedAt ? (
<a href={`/api/files/${datei.id}`} className="font-condensed text-2xs">
{attachmentText.download}
</a>
@@ -197,7 +217,79 @@ export function Attachments({
{attachmentText.release}
</button>
) : null}
{/* Zurückziehen statt Löschen: die Datei bleibt an ihrer
Stelle stehen, mit Begründung. Siehe README, Zusicherung 2. */}
{datei.retractedAt ? (
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
setFehler(null)
const ergebnis = await restoreAttachmentAction({ id: datei.id })
if (ergebnis.ok) {
setDateien((bisher) =>
bisher.map((d) =>
d.id === datei.id
? { ...d, retractedAt: null, retractionReason: null }
: d,
),
)
} else {
setFehler(ergebnis.error)
}
})
}
className="btn btn-sm"
>
{attachmentText.restore}
</button>
) : (
<button
type="button"
disabled={pending}
onClick={() => {
const grund = prompt(attachmentText.retractPrompt)?.trim()
if (!grund) return
startTransition(async () => {
setFehler(null)
const ergebnis = await retractAttachmentAction({
id: datei.id,
reason: grund,
})
if (ergebnis.ok) {
setDateien((bisher) =>
bisher.map((d) =>
d.id === datei.id
? {
...d,
retractedAt: new Date().toISOString(),
retractionReason: grund,
}
: d,
),
)
} else {
setFehler(ergebnis.error)
}
})
}}
className="btn btn-sm"
>
{pending ? attachmentText.retracting : attachmentText.retract}
</button>
)}
</div>
{datei.retractedAt ? (
<p className="mt-1 text-2xs text-text-dim">
{attachmentText.retractedBy(
new Date(datei.retractedAt).toLocaleDateString('de-DE'),
datei.retractionReason ?? '',
)}
</p>
) : null}
{datei.scanMessage ? (
<p className="mt-1 text-2xs text-text-dim">{datei.scanMessage}</p>
) : null}
@@ -225,6 +317,8 @@ export function Attachments({
</ul>
)}
{fehler ? <p className="mt-2 text-2xs text-stamp-4">{fehler}</p> : null}
{canUpload ? (
<div className="mt-4">
<input
+24 -2
View File
@@ -68,7 +68,15 @@ export async function CaseSheet({
db.attachment.findMany({
where: { AND: [visibleAttachmentWhere(permissions), { caseId: akte.id }] },
orderBy: { createdAt: 'asc' },
select: { id: true, originalName: true, sha256: true, sizeBytes: true, scanStatus: true },
select: {
id: true,
originalName: true,
sha256: true,
sizeBytes: true,
scanStatus: true,
retractedAt: true,
retractionReason: true,
},
}),
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
authors ? [] : db.user.findMany({ select: { id: true, displayName: true } }),
@@ -171,7 +179,21 @@ export async function CaseSheet({
<tbody>
{anhaenge.map((anhang) => (
<tr key={anhang.id}>
<td>{anhang.originalName}</td>
{/* Ein zurückgezogener Anhang bleibt im Verzeichnis stehen,
mit Grund. Ihn wegzulassen hiesse, den Ausdruck sauberer zu
machen als die Akte - siehe docs/08. */}
<td>
{anhang.originalName}
{anhang.retractedAt ? (
<span className="print-note">
{' '}
{de.attachment.retractedBy(
anhang.retractedAt.toLocaleDateString('de-DE'),
anhang.retractionReason ?? '',
)}
</span>
) : null}
</td>
<td>{anhang.sizeBytes}</td>
{/* Der Hash macht nachprüfbar, dass die Datei zum Ausdruck
gehört - deshalb steht er im Verzeichnis. */}
+2
View File
@@ -43,6 +43,8 @@ export type AuditAction =
| 'attachment.upload'
| 'attachment.read'
| 'attachment.release'
| 'attachment.retract'
| 'attachment.restore'
| 'chat.adopt'
| 'department.create'
| 'department.update'
+178 -2
View File
@@ -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<void> {
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<void> {
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<void> {
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 },
})
})
}
+4
View File
@@ -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,
}))
}
+2
View File
@@ -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',
+11
View File
@@ -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<string, string>,
} as const
+149 -1
View File
@@ -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()
})
})