ARC-03: dublettenerkennung-e-mail
- mail/internal/dedup/hash.go: HashAndBuffer, SHA-256 auf Klartext VOR Verschluesselung (ARC-02), liefert erneut lesbaren Reader zurueck. - mail/internal/dedup/store.go: Store (Postgres, tenant_slug fest im Primaerschluessel gebunden), Register: Duplikat referenziert Original statt redundant zu speichern. - Alle 3 Pflichtpruefungen real bestanden (siehe mail/docs/ARC-03-PRUEFPROTOKOLL.md): Duplikat aus zwei Quellen erkannt, zwei Mandanten mit identischem Inhalt nicht verknuepft, knapp unterschiedliche Nachricht korrekt nicht erkannt. - Kein Umbau: internal/storage, internal/crypto, internal/encstorage unveraendert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
cb5a9da701
commit
704b64fe27
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user