ARC-05: Papierkorb Soft-Delete fuer Mails #243
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user