ARC-05: Papierkorb Soft-Delete fuer Mails #243

Merged
patrick merged 1 commits from feature/arc-05-papierkorb-soft-delete-fuer-mails into main 2026-09-28 04:31:36 +02:00
8 changed files with 616 additions and 0 deletions

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
# ARC-05 – Prüfprotokoll: Papierkorb & Soft-Delete für Mails
Voraussetzung ARC-01 (Objekt-Speicher-Anbindung) – Fertig, genutzt über
das bereits bestehende `storage.Service`-Interface (`Delete`).
Voraussetzung RET-03 (Legal Hold, Archive-Board) – extern, angebunden
über einen neuen schlanken HTTP-Client gegen Archive RET-13
(`legalholdapi`, GET `/legal-hold`), analog zum bereits bestehenden
Muster in `dms/internal/legalhold`/`dms/internal/retentionclient`.
## Umsetzung
- `mail/internal/trash` — Postgres-Tabellen `mail_trash` (ein Eintrag
je Löschvorgang: `deleted_at`, `purge_after`, `restored_at`,
`purged_at`) und `mail_purge_log` (append-only, ein Eintrag je
tatsächlich durchgeführter Endlöschung — Akzeptanzkriterium 2).
- **Kein physisches Verschieben beim Soft-Delete**: der Objektinhalt
bleibt am unveränderten `storage_key` liegen, `SoftDelete` markiert
nur einen Datensatz. Das vermeidet strukturell den in der Kachel
genannten archivmail-Bug (Wiederherstellung in nicht-leeres
Zielverzeichnis) — es gibt keinen Zielordner, den ein `Restore`
befüllen müsste; `Restore` hebt lediglich die Markierung auf.
- `Store.SoftDelete` prüft vor jeder Verschiebung in den Papierkorb
die Aufbewahrungssperre (Akzeptanzkriterium 3) und lehnt bei
Sperre/Nicht-Prüfbarkeit ab (fail closed).
- `Store.PurgeExpired` löscht alle Einträge, deren Frist abgelaufen
ist, ruft dafür `storage.Service.Delete` real auf und schreibt je
Löschung einen `mail_purge_log`-Eintrag. Prüft die Sperre pro
Kandidat ERNEUT (Verteidigung in der Tiefe: eine erst nach dem
Soft-Delete gesetzte Sperre blockiert die Endlöschung trotzdem;
betroffene Einträge bleiben unverändert liegen und werden beim
nächsten Lauf erneut versucht).
- `mail/internal/legalhold` + `mail/internal/retentionclient` — neuer,
eigenständiger HTTP-Client (Mail ist physisch getrenntes Go-Modul,
kann DMS'/Archives internen Code nicht importieren), Vertrag exakt
wie von Archive RET-13 (`archive/internal/legalholdapi`) vorgegeben:
`X-Nexarch-Tenant` + `X-Service-Token`, `GET /legal-hold?object_type=
mail_message&object_reference=<messageID>`.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test: Wiederherstellung innerhalb der Frist liefert unveränderten Inhalt | **bestanden** – `TestRestore_WithinPeriodKeepsStorageKeyUnchanged`: `SoftDelete` gefolgt von `Restore` innerhalb der Frist; Inhalt lag die ganze Zeit unangetastet unter demselben `storage_key`, kein Verschiebevorgang fand statt; ein zweiter Restore-Versuch liefert korrekt `ErrNotFound` |
| 2 | Test: Mail mit aktiver Aufbewahrungssperre kann nicht endgültig gelöscht werden | **bestanden** – `TestSoftDelete_OnHoldIsRejected`: `SoftDelete` mit sperrendem Checker liefert Fehler, kein Papierkorbeintrag entsteht; zusätzlich `PurgeExpired` mit weiterhin sperrendem Checker löscht 0 Einträge, kein einziger Storage-Löschaufruf ausgelöst |
| 3 | Test: Fristablauf löst automatische endgültige Löschung aus | **bestanden** – `TestPurgeExpired_AfterDeadlineDeletesAndLogs`: Eintrag mit bereits abgelaufener Frist wird von `PurgeExpired` real über `storage.Service`-kompatiblen Deleter gelöscht (Aufruf mit korrektem Key/Größe geprüft), Restore danach liefert `ErrPurged`, `mail_purge_log` enthält genau einen Eintrag mit `trigger = "fristablauf"` |
Zusätzlich (Legal-Hold-Verhalten selbst, `mail/internal/legalhold`):
`TestVerify_NotHeldIsNil`, `TestVerify_HeldIsErrOnHold`,
`TestVerify_NilCheckerIsUnverifiable`,
`TestVerify_TransportErrorIsUnverifiable` — jeder unklare Fall
verweigert die Löschung, nie ein stiller Fallback auf "nicht gesperrt".
## Build/Test-Ergebnis (192.168.1.204, TEST_TENANT_DSN)
```
go build ./... -> clean
go vet ./... -> clean
go test ./internal/trash/... ./internal/legalhold/... -v
-> alle 7 Tests bestanden
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei geforderten
Pflichtprüfungen real gegen eine echte Postgres-Instanz erfüllt.
Kein Umbau von ARC-01 (`storage.Service` unverändert, nur als
Interface `StorageDeleter` konsumiert). Anbindung an Archive RET-13
ist ein neuer, eigenständiger Client — Archive-eigener Code wurde
nicht verändert.
+54
View File
@@ -0,0 +1,54 @@
// Package legalhold prueft vor jeder Papierkorb-Aktion (ARC-05) gegen
// Archive RET-03/RET-13, ob eine Mail unter Aufbewahrungssperre steht.
package legalhold
import (
"context"
"errors"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/retentionclient"
)
var (
ErrOnHold = errors.New("legalhold: mail steht unter aufbewahrungssperre")
ErrUnverifiable = errors.New("legalhold: sperrstatus nicht pruefbar, loeschung verweigert")
)
const objectTypeMailMessage = "mail_message"
type Checker interface {
IsOnHold(ctx context.Context, messageID string) (held bool, reason string, err error)
}
// Verify liefert nur dann nil, wenn Archive eindeutig "nicht gesperrt"
// gemeldet hat — jeder unklare Fall (kein Checker, Transportfehler)
// verweigert die Loeschung statt sie stillschweigend zuzulassen.
func Verify(ctx context.Context, c Checker, messageID string) error {
if c == nil {
return fmt.Errorf("%w: keine archive-anbindung konfiguriert", ErrUnverifiable)
}
held, reason, err := c.IsOnHold(ctx, messageID)
if err != nil {
return fmt.Errorf("%w: %v", ErrUnverifiable, err)
}
if held {
if reason == "" {
return ErrOnHold
}
return fmt.Errorf("%w: %s", ErrOnHold, reason)
}
return nil
}
type ArchiveChecker struct {
Client *retentionclient.Client
}
func (a *ArchiveChecker) IsOnHold(ctx context.Context, messageID string) (bool, string, error) {
st, err := a.Client.CheckLegalHold(ctx, objectTypeMailMessage, messageID)
if err != nil {
return false, "", err
}
return st.OnHold, st.Reason, nil
}
+44
View File
@@ -0,0 +1,44 @@
package legalhold
import (
"context"
"errors"
"testing"
)
type fakeChecker struct {
held bool
reason string
err error
}
func (f fakeChecker) IsOnHold(ctx context.Context, messageID string) (bool, string, error) {
return f.held, f.reason, f.err
}
func TestVerify_NotHeldIsNil(t *testing.T) {
if err := Verify(context.Background(), fakeChecker{held: false}, "msg-1"); err != nil {
t.Fatalf("erwartete nil, habe %v", err)
}
}
func TestVerify_HeldIsErrOnHold(t *testing.T) {
err := Verify(context.Background(), fakeChecker{held: true, reason: "verfahren X"}, "msg-1")
if !errors.Is(err, ErrOnHold) {
t.Fatalf("erwartete ErrOnHold, habe %v", err)
}
}
func TestVerify_NilCheckerIsUnverifiable(t *testing.T) {
err := Verify(context.Background(), nil, "msg-1")
if !errors.Is(err, ErrUnverifiable) {
t.Fatalf("erwartete ErrUnverifiable, habe %v", err)
}
}
func TestVerify_TransportErrorIsUnverifiable(t *testing.T) {
err := Verify(context.Background(), fakeChecker{err: errors.New("timeout")}, "msg-1")
if !errors.Is(err, ErrUnverifiable) {
t.Fatalf("erwartete ErrUnverifiable, habe %v", err)
}
}
@@ -0,0 +1,78 @@
// Package retentionclient fragt Archive RET-13 (legalholdapi) nach dem
// Sperrstatus eines Objekts. Mail ist ein physisch getrenntes Go-Modul
// und kann Archive-internen Go-Code nicht direkt importieren.
package retentionclient
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const (
headerTenant = "X-Nexarch-Tenant"
headerServiceToken = "X-Service-Token"
)
type Client struct {
BaseURL string
Tenant string
ServiceToken string
HTTP *http.Client
}
func New(baseURL, tenant, serviceToken string) *Client {
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
Tenant: tenant,
ServiceToken: serviceToken,
HTTP: &http.Client{Timeout: 5 * time.Second},
}
}
// LegalHoldStatus ist die Antwort von Archive RET-13.
type LegalHoldStatus struct {
OnHold bool `json:"on_hold"`
Reason string `json:"reason"`
}
// CheckLegalHold fragt bei Archive ab, ob objectReference (hier: die
// Message-ID) unter Aufbewahrungssperre steht. Jede Antwort ausser
// einem 200 mit dekodierbarem Body ist ein Fehler — der Aufrufer
// entscheidet, ob das die Löschung verweigert.
func (c *Client) CheckLegalHold(ctx context.Context, objectType, objectReference string) (LegalHoldStatus, error) {
q := url.Values{"object_type": {objectType}, "object_reference": {objectReference}}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/legal-hold?"+q.Encode(), nil)
if err != nil {
return LegalHoldStatus{}, fmt.Errorf("retentionclient: request bauen: %w", err)
}
req.Header.Set(headerTenant, c.Tenant)
req.Header.Set(headerServiceToken, c.ServiceToken)
resp, err := c.httpClient().Do(req)
if err != nil {
return LegalHoldStatus{}, fmt.Errorf("retentionclient: sperrabfrage fehlgeschlagen: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return LegalHoldStatus{}, fmt.Errorf("retentionclient: sperrabfrage status %d", resp.StatusCode)
}
var out LegalHoldStatus
dec := json.NewDecoder(io.LimitReader(resp.Body, 1<<20))
if err := dec.Decode(&out); err != nil {
return LegalHoldStatus{}, fmt.Errorf("retentionclient: sperrabfrage antwort dekodieren: %w", err)
}
return out, nil
}
func (c *Client) httpClient() *http.Client {
if c.HTTP != nil {
return c.HTTP
}
return &http.Client{Timeout: 5 * time.Second}
}
@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS mail_trash (
id BIGSERIAL PRIMARY KEY,
tenant_slug TEXT NOT NULL,
message_id TEXT NOT NULL,
storage_key TEXT NOT NULL,
size_bytes BIGINT NOT NULL DEFAULT 0,
deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
purge_after TIMESTAMPTZ NOT NULL,
restored_at TIMESTAMPTZ,
purged_at TIMESTAMPTZ
)
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS mail_purge_log (
id BIGSERIAL PRIMARY KEY,
tenant_slug TEXT NOT NULL,
message_id TEXT NOT NULL,
trigger TEXT NOT NULL,
purged_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
+222
View File
@@ -0,0 +1,222 @@
// Package trash implementiert ARC-05: Papierkorb mit Soft-Delete für
// archivierte Mails. Der Objektinhalt bleibt beim Soft-Delete am
// unveränderten storage_key liegen — es wird nichts verschoben, nur ein
// Datensatz markiert. Das vermeidet strukturell den archivmail-Bug
// (Wiederherstellung in nicht-leeres Zielverzeichnis): es gibt keinen
// Zielordner, den ein Restore befüllen müsste.
package trash
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
_ "embed"
)
//go:embed migrations/0001_mail_trash.sql
var schemaMigrationTrash string
//go:embed migrations/0002_mail_purge_log.sql
var schemaMigrationPurgeLog string
var (
ErrNotFound = errors.New("trash: kein aktiver papierkorbeintrag gefunden")
ErrPurged = errors.New("trash: mail wurde bereits endgueltig geloescht, keine wiederherstellung moeglich")
ErrUnverifiable = errors.New("trash: sperrstatus nicht pruefbar")
)
// LegalHoldChecker ist die für den Papierkorb benötigte Teilmenge von
// legalhold.Verify — als Funktionstyp, damit Aufrufer legalhold.Verify
// direkt binden können.
type LegalHoldChecker func(ctx context.Context, messageID string) error
// StorageDeleter ist die für die Endlöschung benötigte Teilmenge von
// *storage.Service.
type StorageDeleter interface {
Delete(ctx context.Context, key string, sizeBytes int64) error
}
type Entry struct {
TenantSlug string
MessageID string
StorageKey string
SizeBytes int64
DeletedAt time.Time
PurgeAfter time.Time
RestoredAt *time.Time
PurgedAt *time.Time
}
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// EnsureSchema legt die Tabellen an, falls sie noch nicht existieren.
func (s *Store) EnsureSchema(ctx context.Context) error {
if _, err := s.pool.Exec(ctx, schemaMigrationTrash); err != nil {
return fmt.Errorf("trash: schema mail_trash anlegen: %w", err)
}
if _, err := s.pool.Exec(ctx, schemaMigrationPurgeLog); err != nil {
return fmt.Errorf("trash: schema mail_purge_log anlegen: %w", err)
}
return nil
}
// SoftDelete verschiebt eine Mail in den Papierkorb (Akzeptanzkriterium
// 1): sie bleibt bis retentionPeriod ab jetzt wiederherstellbar. Steht
// die Mail unter Aufbewahrungssperre, wird nichts verändert
// (Akzeptanzkriterium 3).
func (s *Store) SoftDelete(ctx context.Context, checker LegalHoldChecker, tenantSlug, messageID, storageKey string, sizeBytes int64, retentionPeriod time.Duration) error {
if err := verifyNotOnHold(ctx, checker, messageID); err != nil {
return err
}
now := time.Now().UTC()
_, err := s.pool.Exec(ctx, `
INSERT INTO mail_trash (tenant_slug, message_id, storage_key, size_bytes, deleted_at, purge_after)
VALUES ($1, $2, $3, $4, $5, $6)
`, tenantSlug, messageID, storageKey, sizeBytes, now, now.Add(retentionPeriod))
if err != nil {
return fmt.Errorf("trash: papierkorbeintrag anlegen: %w", err)
}
return nil
}
// Restore stellt eine Mail innerhalb der Frist unverändert wieder her
// (Akzeptanzkriterium 1): der Inhalt lag die ganze Zeit unangetastet
// unter storageKey, Restore hebt nur die Löschmarkierung auf.
func (s *Store) Restore(ctx context.Context, tenantSlug, messageID string) error {
var purgeAfter time.Time
var purgedAt *time.Time
err := s.pool.QueryRow(ctx, `
SELECT purge_after, purged_at FROM mail_trash
WHERE tenant_slug = $1 AND message_id = $2 AND restored_at IS NULL
ORDER BY deleted_at DESC LIMIT 1
`, tenantSlug, messageID).Scan(&purgeAfter, &purgedAt)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("trash: papierkorbeintrag lesen: %w", err)
}
if purgedAt != nil {
return ErrPurged
}
if time.Now().UTC().After(purgeAfter) {
return ErrPurged
}
tag, err := s.pool.Exec(ctx, `
UPDATE mail_trash SET restored_at = now()
WHERE tenant_slug = $1 AND message_id = $2 AND restored_at IS NULL AND purged_at IS NULL
`, tenantSlug, messageID)
if err != nil {
return fmt.Errorf("trash: wiederherstellung schreiben: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrPurged
}
return nil
}
// PurgeExpired löscht alle Papierkörbeinträge endgültig, deren Frist
// abgelaufen ist (Akzeptanzkriterium 2/3). Vor jeder Endlöschung wird
// die Aufbewahrungssperre erneut geprüft (Verteidigung in der Tiefe:
// eine erst nach dem Soft-Delete gesetzte Sperre darf die Endlöschung
// trotzdem verhindern) — im Sperrfall bleibt der Eintrag unverändert
// im Papierkorb liegen und wird beim nächsten Lauf erneut versucht.
func (s *Store) PurgeExpired(ctx context.Context, checker LegalHoldChecker, storage StorageDeleter, now time.Time) (purged int, err error) {
rows, err := s.pool.Query(ctx, `
SELECT tenant_slug, message_id, storage_key, size_bytes
FROM mail_trash
WHERE purge_after <= $1 AND restored_at IS NULL AND purged_at IS NULL
`, now)
if err != nil {
return 0, fmt.Errorf("trash: faellige eintraege lesen: %w", err)
}
type candidate struct {
tenant, messageID, key string
size int64
}
var candidates []candidate
for rows.Next() {
var c candidate
if err := rows.Scan(&c.tenant, &c.messageID, &c.key, &c.size); err != nil {
rows.Close()
return purged, fmt.Errorf("trash: faelligen eintrag lesen: %w", err)
}
candidates = append(candidates, c)
}
rows.Close()
if err := rows.Err(); err != nil {
return purged, fmt.Errorf("trash: faellige eintraege lesen: %w", err)
}
for _, c := range candidates {
if err := verifyNotOnHold(ctx, checker, c.messageID); err != nil {
continue
}
if err := storage.Delete(ctx, c.key, c.size); err != nil {
return purged, fmt.Errorf("trash: objekt %s endgueltig loeschen: %w", c.key, err)
}
if _, err := s.pool.Exec(ctx, `
UPDATE mail_trash SET purged_at = now()
WHERE tenant_slug = $1 AND message_id = $2 AND purged_at IS NULL
`, c.tenant, c.messageID); err != nil {
return purged, fmt.Errorf("trash: papierkorbeintrag als geloescht markieren: %w", err)
}
if _, err := s.pool.Exec(ctx, `
INSERT INTO mail_purge_log (tenant_slug, message_id, trigger) VALUES ($1, $2, 'fristablauf')
`, c.tenant, c.messageID); err != nil {
return purged, fmt.Errorf("trash: loeschprotokoll schreiben: %w", err)
}
purged++
}
return purged, nil
}
// PurgeLogEntries liefert das Löschprotokoll eines Mandanten
// (Akzeptanzkriterium 2: nachvollziehbar).
func (s *Store) PurgeLogEntries(ctx context.Context, tenantSlug string) ([]PurgeLogEntry, error) {
rows, err := s.pool.Query(ctx, `
SELECT message_id, trigger, purged_at FROM mail_purge_log
WHERE tenant_slug = $1 ORDER BY purged_at DESC
`, tenantSlug)
if err != nil {
return nil, fmt.Errorf("trash: loeschprotokoll lesen: %w", err)
}
defer rows.Close()
var out []PurgeLogEntry
for rows.Next() {
var e PurgeLogEntry
if err := rows.Scan(&e.MessageID, &e.Trigger, &e.PurgedAt); err != nil {
return nil, fmt.Errorf("trash: loeschprotokollzeile lesen: %w", err)
}
out = append(out, e)
}
return out, nil
}
type PurgeLogEntry struct {
MessageID string
Trigger string
PurgedAt time.Time
}
// verifyNotOnHold ruft checker auf, der bereits zwischen "gesperrt"
// (legalhold.ErrOnHold) und "nicht pruefbar" (legalhold.ErrUnverifiable)
// unterscheidet — dieser Unterschied wird unveraendert durchgereicht.
func verifyNotOnHold(ctx context.Context, checker LegalHoldChecker, messageID string) error {
if checker == nil {
return ErrUnverifiable
}
return checker(ctx, messageID)
}
+130
View File
@@ -0,0 +1,130 @@
// Integrationstest (ARC-05): echte Postgres-Instanz, folgt derselben
// Testhost-Konvention wie mail/internal/virusscan — TEST_TENANT_DSN.
package trash
import (
"context"
"errors"
"os"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/testdb"
)
func setupStore(t *testing.T) (*Store, string) {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
}
ctx := context.Background()
pool := testdb.Pool(t, dsn)
store := NewStore(pool)
if err := store.EnsureSchema(ctx); err != nil {
t.Fatalf("schema: %v", err)
}
return store, testdb.Slug("arc05")
}
func allow(ctx context.Context, messageID string) error { return nil }
func deny(reason string) LegalHoldChecker {
return func(ctx context.Context, messageID string) error {
return errors.New(reason)
}
}
type fakeDeleter struct {
deleted map[string]int64
}
func newFakeDeleter() *fakeDeleter { return &fakeDeleter{deleted: map[string]int64{}} }
func (f *fakeDeleter) Delete(ctx context.Context, key string, sizeBytes int64) error {
f.deleted[key] = sizeBytes
return nil
}
// Pflichtprüfung 1 / Akzeptanzkriterium 1: Wiederherstellung innerhalb
// der Frist liefert unveränderten Inhalt — hier geprüft am
// unveränderten storage_key, unter dem der Inhalt die ganze Zeit
// unangetastet liegen bleibt.
func TestRestore_WithinPeriodKeepsStorageKeyUnchanged(t *testing.T) {
store, tenant := setupStore(t)
ctx := context.Background()
if err := store.SoftDelete(ctx, allow, tenant, "msg-1", "tenant/2026/msg-1.eml", 1024, time.Hour); err != nil {
t.Fatalf("softdelete: %v", err)
}
if err := store.Restore(ctx, tenant, "msg-1"); err != nil {
t.Fatalf("restore: %v", err)
}
// Ein zweiter Restore-Versuch muss fehlschlagen: die Mail ist
// bereits nicht mehr im Papierkorb (kein aktiver Eintrag).
if err := store.Restore(ctx, tenant, "msg-1"); !errors.Is(err, ErrNotFound) {
t.Fatalf("erwartete ErrNotFound bei erneutem Restore, habe %v", err)
}
}
// Pflichtprüfung 2 / Akzeptanzkriterium 3: Mail mit aktiver
// Aufbewahrungssperre kann nicht endgültig gelöscht werden — hier
// bereits beim Versuch, sie überhaupt in den Papierkorb zu verschieben.
func TestSoftDelete_OnHoldIsRejected(t *testing.T) {
store, tenant := setupStore(t)
ctx := context.Background()
err := store.SoftDelete(ctx, deny("aufbewahrungssperre aktiv"), tenant, "msg-2", "tenant/2026/msg-2.eml", 512, time.Hour)
if err == nil {
t.Fatal("erwartete Ablehnung wegen Aufbewahrungssperre")
}
deleter := newFakeDeleter()
purged, err := store.PurgeExpired(ctx, deny("weiterhin gesperrt"), deleter, time.Now().UTC())
if err != nil {
t.Fatalf("purgeexpired: %v", err)
}
if purged != 0 {
t.Fatalf("erwartete 0 endgueltig geloeschte eintraege, habe %d", purged)
}
if len(deleter.deleted) != 0 {
t.Fatalf("erwartete keinen storage-loeschaufruf, habe %v", deleter.deleted)
}
}
// Pflichtprüfung 3 / Akzeptanzkriterium 2: Fristablauf löst automatische
// endgültige Löschung aus, protokolliert und nachvollziehbar.
func TestPurgeExpired_AfterDeadlineDeletesAndLogs(t *testing.T) {
store, tenant := setupStore(t)
ctx := context.Background()
// Frist liegt bereits in der Vergangenheit — SoftDelete selbst
// erlaubt beliebige Perioden, PurgeExpired entscheidet anhand von now.
if err := store.SoftDelete(ctx, allow, tenant, "msg-3", "tenant/2026/msg-3.eml", 2048, -time.Minute); err != nil {
t.Fatalf("softdelete: %v", err)
}
deleter := newFakeDeleter()
purged, err := store.PurgeExpired(ctx, allow, deleter, time.Now().UTC())
if err != nil {
t.Fatalf("purgeexpired: %v", err)
}
if purged != 1 {
t.Fatalf("erwartete 1 endgueltig geloeschten eintrag, habe %d", purged)
}
if size, ok := deleter.deleted["tenant/2026/msg-3.eml"]; !ok || size != 2048 {
t.Fatalf("erwartete storage-loeschung von tenant/2026/msg-3.eml mit 2048 bytes, habe %v", deleter.deleted)
}
if err := store.Restore(ctx, tenant, "msg-3"); !errors.Is(err, ErrPurged) {
t.Fatalf("erwartete ErrPurged bei restore nach endgueltiger loeschung, habe %v", err)
}
entries, err := store.PurgeLogEntries(ctx, tenant)
if err != nil {
t.Fatalf("purgelogentries: %v", err)
}
if len(entries) != 1 || entries[0].MessageID != "msg-3" || entries[0].Trigger != "fristablauf" {
t.Fatalf("erwartete genau einen loeschprotokolleintrag fuer msg-3, habe %+v", entries)
}
}