diff --git a/mail/docs/ARC-03-PRUEFPROTOKOLL.md b/mail/docs/ARC-03-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..1907b07 --- /dev/null +++ b/mail/docs/ARC-03-PRUEFPROTOKOLL.md @@ -0,0 +1,53 @@ +# ARC-03 – Prüfprotokoll: Dublettenerkennung E-Mail + +Voraussetzung ARC-01 (Mail, Fertig). + +## Umsetzung + +- `mail/internal/dedup/hash.go` — `HashAndBuffer(plaintext io.Reader)`: + SHA-256-Inhalts-Hash, gebildet auf dem KLARTEXT (Bekannter Fehler + vermeiden: muss VOR mail/internal/crypto passieren, siehe ARC-02 — + ein Hash auf dem Chiffretext wäre wegen des zufälligen DEK je Objekt + bei jedem Import anders). Liefert zusätzlich einen erneut lesbaren + Reader zurück, da der Original-Reader beim Hashen verbraucht wird. +- `mail/internal/dedup/store.go` — `Store.Register(ctx, contentHash, objectKey)`: + Postgres-Tabelle `mail_content_hashes`, Primärschlüssel + `(tenant_slug, content_hash)` — `tenant_slug` fest im Store gebunden + (`NewStore(pool, tenantSlug)`, gleiches Muster wie + `storage.Service`/`encstorage.Service`), nicht nur Konvention. + `ON CONFLICT DO NOTHING` + Rücklese entscheidet, ob der gefundene + Eintrag der gerade übergebene ist (kein Duplikat) oder ein älterer + (Duplikat, Original-`object_key` wird zurückgegeben statt erneut + gespeichert — Akzeptanzkriterium 2). +- Kein Umbau: `mail/internal/storage`/`mail/internal/crypto`/ + `mail/internal/encstorage` unverändert (`git diff --stat` bleibt für + alle drei leer). `dedup` kennt keines der drei Pakete — der Aufrufer + (spätere Ingest-Tickets) ruft `HashAndBuffer` VOR `encstorage.Put` + auf. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Test: dieselbe Nachricht aus zwei Quellen wird als Duplikat erkannt | **bestanden** – `TestRegister_SameMessageFromTwoSourcesIsDuplicate`: gleicher Hash, zwei verschiedene `object_key` ("quelle-1/objekt", "quelle-2/objekt") — zweite Registrierung liefert real `isDuplicate=true` und referenziert das Original `quelle-1/objekt` | +| 2 | Test: zwei Mandanten mit identischem Mailinhalt werden nicht fälschlich verknüpft | **bestanden** – `TestRegister_SameContentTwoTenantsNotLinked`: zwei `Store`-Instanzen mit unterschiedlichem `tenantSlug`, IDENTISCHER Hash — beide Registrierungen liefern real `isDuplicate=false`, keine Verknüpfung über die Mandantengrenze | +| 3 | Test mit knapp unterschiedlichen Nachrichten bestätigt korrekte Nicht-Erkennung | **bestanden** – `TestHashAndBuffer_SlightlyDifferentContentDifferentHash`: zwei Nachrichten, die sich nur im letzten Zeichen unterscheiden (`.` vs `,`) — real unterschiedlicher SHA-256-Hash | + +## Build/Test-Ergebnis (192.168.1.131) + +``` +go build ./... -> clean +go vet ./... -> clean +golangci-lint run ./... -> 0 issues +TEST_TENANT_DSN=postgresql://nexarch_test:***@localhost:5432/tenant_acme?sslmode=disable \ + go test ./... -v -p 1 -> alle Pakete bestanden, inkl. internal/dedup (5 Tests) +``` + +Testdaten (`mail_content_hashes`, Zeilen mit `tenant_slug` beginnend +`mandant-arc03-`) werden von den Tests selbst über `t.Cleanup` +entfernt. + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle drei +Pflichtprüfungen real erfüllt. Entsperrt SRC-01, SRC-02, SRC-07. diff --git a/mail/internal/dedup/hash.go b/mail/internal/dedup/hash.go new file mode 100644 index 0000000..61ddd0c --- /dev/null +++ b/mail/internal/dedup/hash.go @@ -0,0 +1,35 @@ +// Package dedup implementiert ARC-03: Dublettenerkennung für +// archivierte E-Mails über einen Inhalts-Hash. Kombiniert bewusst NICHT +// mit mail/internal/storage oder mail/internal/crypto — dieses Paket +// kennt beide nicht, der Aufrufer (spätere Ingest-Tickets) ruft es VOR +// mail/internal/crypto auf. +package dedup + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" +) + +// HashAndBuffer berechnet den SHA-256-Inhalts-Hash von plaintext. +// +// Bekannter Fehler vermeiden (siehe ARC-03-Ticket): der Hash MUSS auf +// dem Klartext berechnet werden, BEVOR mail/internal/crypto verschlüsselt +// — ein Hash auf dem Chiffretext wäre bei jedem Import anders (neuer +// DEK je Objekt, siehe ARC-02) und Dublettenerkennung würde vollständig +// versagen. Reihenfolge: Mail/Anhang empfangen -> HashAndBuffer (dieses +// Paket) -> verschlüsseln (ARC-02) -> ablegen. +// +// plaintext wird beim Hashen vollständig verbraucht — HashAndBuffer +// liefert deshalb einen erneut lesbaren Reader mit demselben Inhalt für +// den nachfolgenden Verschlüsselungsschritt zurück. +func HashAndBuffer(plaintext io.Reader) (contentHash string, buffered io.Reader, err error) { + var buf bytes.Buffer + hasher := sha256.New() + if _, err := io.Copy(hasher, io.TeeReader(plaintext, &buf)); err != nil { + return "", nil, fmt.Errorf("dedup: klartext hashen: %w", err) + } + return hex.EncodeToString(hasher.Sum(nil)), &buf, nil +} diff --git a/mail/internal/dedup/hash_test.go b/mail/internal/dedup/hash_test.go new file mode 100644 index 0000000..cb5740d --- /dev/null +++ b/mail/internal/dedup/hash_test.go @@ -0,0 +1,58 @@ +package dedup + +import ( + "io" + "strings" + "testing" +) + +func TestHashAndBuffer_SameContentSameHash(t *testing.T) { + h1, buf1, err := HashAndBuffer(strings.NewReader("identischer inhalt")) + if err != nil { + t.Fatal(err) + } + h2, buf2, err := HashAndBuffer(strings.NewReader("identischer inhalt")) + if err != nil { + t.Fatal(err) + } + if h1 != h2 { + t.Fatalf("erwartet identischen hash für identischen inhalt, habe %q vs %q", h1, h2) + } + + got1, _ := io.ReadAll(buf1) + got2, _ := io.ReadAll(buf2) + if string(got1) != "identischer inhalt" || string(got2) != "identischer inhalt" { + t.Fatal("buffered reader liefert nicht denselben inhalt zurück wie der ursprüngliche klartext") + } +} + +func TestHashAndBuffer_DifferentContentDifferentHash(t *testing.T) { + h1, _, err := HashAndBuffer(strings.NewReader("nachricht a")) + if err != nil { + t.Fatal(err) + } + h2, _, err := HashAndBuffer(strings.NewReader("nachricht b")) + if err != nil { + t.Fatal(err) + } + if h1 == h2 { + t.Fatal("unterschiedlicher inhalt hätte unterschiedlichen hash liefern müssen") + } +} + +// TestHashAndBuffer_SlightlyDifferentContentDifferentHash ist die +// geforderte Pflichtprüfung 3: knapp unterschiedliche Nachrichten +// werden korrekt NICHT als Duplikat erkannt. +func TestHashAndBuffer_SlightlyDifferentContentDifferentHash(t *testing.T) { + h1, _, err := HashAndBuffer(strings.NewReader("Betreff: Test\r\n\r\nInhalt der Nachricht.")) + if err != nil { + t.Fatal(err) + } + h2, _, err := HashAndBuffer(strings.NewReader("Betreff: Test\r\n\r\nInhalt der Nachricht,")) + if err != nil { + t.Fatal(err) + } + if h1 == h2 { + t.Fatal("ein einziges abweichendes zeichen hätte den hash ändern müssen") + } +} diff --git a/mail/internal/dedup/store.go b/mail/internal/dedup/store.go new file mode 100644 index 0000000..ce14586 --- /dev/null +++ b/mail/internal/dedup/store.go @@ -0,0 +1,75 @@ +package dedup + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Store verwaltet bekannte Inhalts-Hashes je Mandant. tenant_slug ist +// fester Bestandteil des Primärschlüssels (Akzeptanzkriterium 3: +// mandantenübergreifend korrekt getrennt) — auch wenn Store einen mit +// anderen Mandanten geteilten Pool erhält, kann ein Hash-Treffer nie +// über Mandantengrenzen hinweg entstehen. +type Store struct { + pool *pgxpool.Pool + tenantSlug string +} + +func NewStore(pool *pgxpool.Pool, tenantSlug string) *Store { + return &Store{pool: pool, tenantSlug: tenantSlug} +} + +// EnsureSchema legt die Tabelle an, falls sie noch nicht existiert — +// gleiches Muster wie mail/internal/example (kein zentraler +// Migrationsläufer für Mandanten-Datenbanken im Mail-Modul vorhanden). +func (s *Store) EnsureSchema(ctx context.Context) error { + if _, err := s.pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS mail_content_hashes ( + tenant_slug TEXT NOT NULL, + content_hash TEXT NOT NULL, + object_key TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_slug, content_hash) + ) + `); err != nil { + return fmt.Errorf("dedup: schema anlegen: %w", err) + } + return nil +} + +// Register trägt contentHash für den Mandanten als neu bekannt ein +// (Akzeptanzkriterium 1) und referenziert das Original, statt es +// redundant zu speichern (Akzeptanzkriterium 2): existiert derselbe +// Hash für DIESEN Mandanten bereits mit einem ANDEREN object_key, +// liefert Register isDuplicate=true und den object_key des Originals — +// der Aufrufer legt den neuen Inhalt dann NICHT ab. +func (s *Store) Register(ctx context.Context, contentHash, objectKey string) (isDuplicate bool, existingKey string, err error) { + if _, err := s.pool.Exec(ctx, ` + INSERT INTO mail_content_hashes (tenant_slug, content_hash, object_key) + VALUES ($1, $2, $3) + ON CONFLICT (tenant_slug, content_hash) DO NOTHING + `, s.tenantSlug, contentHash, objectKey); err != nil { + return false, "", fmt.Errorf("dedup: hash eintragen: %w", err) + } + + var storedKey string + err = s.pool.QueryRow(ctx, ` + SELECT object_key FROM mail_content_hashes + WHERE tenant_slug = $1 AND content_hash = $2 + `, s.tenantSlug, contentHash).Scan(&storedKey) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return false, "", fmt.Errorf("dedup: gerade eingetragenen hash nicht wiedergefunden") + } + return false, "", fmt.Errorf("dedup: eintrag lesen: %w", err) + } + + if storedKey != objectKey { + return true, storedKey, nil + } + return false, "", nil +} diff --git a/mail/internal/dedup/store_integration_test.go b/mail/internal/dedup/store_integration_test.go new file mode 100644 index 0000000..b3b4a56 --- /dev/null +++ b/mail/internal/dedup/store_integration_test.go @@ -0,0 +1,94 @@ +// Integrationstest (ARC-03): echte Postgres-Instanz, folgt derselben +// Testhost-Konvention wie mail/internal/example (QA-01) — TEST_TENANT_DSN. +package dedup + +import ( + "context" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func setupStore(t *testing.T, tenantSlug string) *Store { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(func() { pool.Close() }) + + store := NewStore(pool, tenantSlug) + if err := store.EnsureSchema(ctx); err != nil { + t.Fatalf("schema: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM mail_content_hashes WHERE tenant_slug = $1`, tenantSlug) + }) + return store +} + +// TestRegister_SameMessageFromTwoSourcesIsDuplicate ist die geforderte +// Pflichtprüfung 1: dieselbe Nachricht aus zwei Quellen wird als +// Duplikat erkannt. +func TestRegister_SameMessageFromTwoSourcesIsDuplicate(t *testing.T) { + store := setupStore(t, "mandant-arc03-a") + ctx := context.Background() + + hash := "fixierter-inhalts-hash-fuer-test-1" + + isDup, _, err := store.Register(ctx, hash, "quelle-1/objekt") + if err != nil { + t.Fatalf("erste registrierung: %v", err) + } + if isDup { + t.Fatal("erste registrierung eines hashes darf kein duplikat sein") + } + + isDup, existing, err := store.Register(ctx, hash, "quelle-2/objekt") + if err != nil { + t.Fatalf("zweite registrierung: %v", err) + } + if !isDup { + t.Fatal("erwartet: dieselbe nachricht aus zweiter quelle wird als duplikat erkannt") + } + if existing != "quelle-1/objekt" { + t.Fatalf("erwartet referenz auf das original quelle-1/objekt, habe %q", existing) + } +} + +// TestRegister_SameContentTwoTenantsNotLinked ist die geforderte +// Pflichtprüfung 2: zwei Mandanten mit identischem Mailinhalt werden +// nicht fälschlich verknüpft. +func TestRegister_SameContentTwoTenantsNotLinked(t *testing.T) { + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen") + } + storeA := setupStore(t, "mandant-arc03-x") + storeB := setupStore(t, "mandant-arc03-y") + ctx := context.Background() + + hash := "identischer-inhalt-ueber-zwei-mandanten-hinweg" + + isDupA, _, err := storeA.Register(ctx, hash, "mandant-x/objekt") + if err != nil { + t.Fatalf("mandant a: %v", err) + } + if isDupA { + t.Fatal("erste registrierung bei mandant a darf kein duplikat sein") + } + + isDupB, existingB, err := storeB.Register(ctx, hash, "mandant-y/objekt") + if err != nil { + t.Fatalf("mandant b: %v", err) + } + if isDupB { + t.Fatalf("mandant b wurde fälschlich mit mandant a verknüpft, existing=%q", existingB) + } +}