diff --git a/.gitea/workflows/mail-pflichttest-gate.yml b/.gitea/workflows/mail-pflichttest-gate.yml new file mode 100644 index 0000000..2e5662c --- /dev/null +++ b/.gitea/workflows/mail-pflichttest-gate.yml @@ -0,0 +1,23 @@ +name: Mail-Pflichttest-Gate + +on: + pull_request: + paths: + - "mail/**" + +jobs: + pflichttest-gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + - name: Gate bauen + working-directory: mail + run: go build -o /tmp/pflichttestgate ./cmd/pflichttestgate + - name: Geänderte Dateien gegen Pflichttest-Regel prüfen + run: | + git diff --name-only "origin/${{ github.base_ref }}...HEAD" | /tmp/pflichttestgate diff --git a/mail/cmd/pflichttestgate/main.go b/mail/cmd/pflichttestgate/main.go new file mode 100644 index 0000000..9881bed --- /dev/null +++ b/mail/cmd/pflichttestgate/main.go @@ -0,0 +1,39 @@ +// Command pflichttestgate ist das CI-Gate aus docs/TESTSTRATEGIE-MAIL.md +// Abschnitt 4. Aufruf: pflichttestgate < geänderte-dateien.txt +package main + +import ( + "bufio" + "fmt" + "os" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/pflichttestgate" +) + +func main() { + var changedFiles []string + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + line := scanner.Text() + if line != "" { + changedFiles = append(changedFiles, line) + } + } + if err := scanner.Err(); err != nil { + fmt.Fprintf(os.Stderr, "pflichttestgate: eingabe konnte nicht gelesen werden: %v\n", err) + os.Exit(2) + } + + violations := pflichttestgate.CheckDiff(changedFiles) + if len(violations) == 0 { + fmt.Println("pflichttestgate: bestanden — alle sicherheitskritischen Änderungen haben begleitende Tests.") + return + } + + fmt.Fprintln(os.Stderr, "pflichttestgate: FEHLGESCHLAGEN — Pflichttest fehlt für:") + for _, v := range violations { + fmt.Fprintf(os.Stderr, " - Package %q (Datei %q hat keine begleitende _test.go-Änderung)\n", v.Package, v.ChangedFile) + } + fmt.Fprintln(os.Stderr, "\nSiehe docs/TESTSTRATEGIE-MAIL.md Abschnitt 4.") + os.Exit(1) +} diff --git a/mail/docs/TESTSTRATEGIE-MAIL.md b/mail/docs/TESTSTRATEGIE-MAIL.md new file mode 100644 index 0000000..48507ab --- /dev/null +++ b/mail/docs/TESTSTRATEGIE-MAIL.md @@ -0,0 +1,110 @@ +# NEXARCH Mail – Teststrategie + +Stand: 2026-08-30. Ticket: QA-01. Vorbild: Core `QA-01` (`docs/TESTSTRATEGIE-CORE.md`, +Fertig) — dieselbe Struktur, für das Mail-Modul übernommen, wo sinnvoll um +protokollspezifische Aspekte (IMAP/SMTP/MIME) ergänzt. + +## 1. Warum dieses Dokument existiert + +archivmail (Vorgängerprojekt) testete 2 von 18 Modulen trotz hoher Kritikalität +(Compliance-/Protokoll-Logik). Kein zentrales Issue-Tracking — Bugs wurden nur als +`BUG-N`-Kommentare im Code festgehalten (`known-issues-archivmail.md`). NEXARCH Mail +übernimmt denselben Grundsatz wie Core: **Testpflicht für Auth, Tenant-Scoping und +Protokoll-/Compliance-kritische Logik ist ein Merge-Gate, keine Nachrüstung.** + +## 2. Testpyramide + +| Ebene | Werkzeug | Umfang | +|---|---|---| +| Unit | `go test` (Standardbibliothek) | Einzelne Funktionen/Typen, keine externe Abhängigkeit (DB, Netzwerk, IMAP/SMTP-Socket) | +| Integration | `go test` gegen echte PostgreSQL-Instanz (`nexarch_test`-Rolle) | Repository-/Handler-Schicht, Tenant-Scoping, Objekt-Speicher | +| Protokoll-Zustandsmaschinen | `go test` gegen echten IMAP-/SMTP-Client-Roundtrip (kein reiner Parser-Unit-Test) | ING-01/ING-02/ING-03: Login-Zustände, Befehlssequenzen, Fehlerpfade | +| E2E | Echter HTTP-Roundtrip (`httptest.Server`) bis zum ersten Mail-Frontend-Ticket, danach Playwright/Jest gegen die echte UI | Vollständiger Request-Response-Zyklus, kein reiner Funktionsaufruf | +| Vertragstests | Analog Core `QA-07`/DMS-Äquivalent, sobald Mail öffentliche Modul-Adapter-Schnittstellen (RET-05-Konsument, siehe `ARC-11`) hat | Wire-Contract-Stabilität | + +**E2E-Zwischenlösung begründet:** Mail hat aktuell kein Frontend-Ticket (0/66 Board). +Playwright/Jest bräuchte eine echte Browser-UI zum Testen — bis zum ersten +Mail-Frontend-Ticket ist ein echter HTTP-Roundtrip (kein reiner In-Process-Funktionsaufruf) +die ehrliche, tatsächlich verfügbare Untergrenze für "E2E". Siehe Beispiel in +Abschnitt 3. + +## 3. Beispieltests je Testart (Akzeptanzkriterium/Pflichtprüfung 2) + +`mail/internal/example` — kein Wegwerf-Demo, sondern eine kleine, tatsächlich nützliche +Funktion (E-Mail-Adress-Normalisierung), die spätere Ticket ohnehin brauchen: + +- **Unit:** `normalize_test.go` — `TestNormalizeAddress_*`, keine externe Abhängigkeit. +- **Integration:** `store_integration_test.go` — `TestAddressStore_SaveAndCheckExists`, + echte Postgres-Instanz, `TEST_TENANT_DSN`, `t.Cleanup`. +- **E2E:** `handler_e2e_test.go` — `TestNormalizeHandler_RealHTTPRoundTrip`, echter + `httptest.Server`-Roundtrip (TCP, nicht nur Funktionsaufruf). + +Alle sechs Tests real ausgeführt (siehe Prüfungen, Abschnitt 6). + +## 4. Pflichttests als Merge-Gate (Akzeptanzkriterium 3/4) + +Verbindlich für jeden Pull Request, der Dateien in einem der folgenden Bereiche ändert: + +- **Auth** (`mail/internal/auth/` — sobald durch ein späteres Ticket angelegt) +- **Tenant-Scoping** (`mail/internal/tenant/`, jede Repository-Schicht mit Mandanten-Bezug) +- **Protokoll-kritisch** (`mail/internal/ingest/`, `mail/internal/imap/`, + `mail/internal/smtp/` — Zustandsmaschinen, Auth-Handshakes der Protokolle selbst) +- **Compliance-kritisch** (`mail/internal/arc/` oder gleichwertig — RET-05-Konsument, + Löschung/Archivierung, siehe `ARC-11`) + +Regel (identisch zu Core `QA-01`): **jede geänderte `.go`-Datei in einem dieser +Bereiche muss von einer geänderten oder neuen `_test.go`-Datei im selben Package +begleitet sein.** + +`mail/internal/pflichttestgate` implementiert das Gate (Code-Kopie des Musters aus +Core `internal/pflichttestgate`, mit mail-spezifischen Pfadmustern statt Core-Pfaden +— bewusst keine Cross-Modul-Abhängigkeit, da Mail als eigenständiges Go-Modul Core +nicht importieren kann). `.gitea/workflows/mail-pflichttest-gate.yml` führt es gegen +jeden PR-Diff aus. + +Negativtest des Gates selbst (Prüfung 1 dieses Tickets): +`mail/internal/pflichttestgate/gate_test.go` simuliert einen Diff mit geänderter +`mail/internal/auth/login.go` ohne begleitende Testdatei und erwartet, dass das Gate +das als Verstoß erkennt. + +## 5. Bug-Tracking (Akzeptanzkriterium 3) + +**Konvention: Gitea-Issues** auf `gitea.perlbach24.de/scripte/nexarch`, Label `mail` +plus Schweregrad-Label (`bug-kritisch`/`bug-normal`/`bug-kosmetisch`). Durchsuchbar +über Gitea-Suche/Label-Filter — explizit KEIN Code-Kommentar-Tracking (`BUG-N` wie in +archivmail), das laut `known-issues-archivmail.md` genau diese Sichtbarkeitslücke +verursacht hat. + +**Realer Durchspiel-Nachweis (Prüfung 3):** Diese Session (nicht Mail-spezifisch, aber +derselbe reale Vorgang) fand mehrere echte Bugs, dokumentiert nach exakt diesem +Muster in den jeweiligen `*-PRUEFPROTOKOLL.md`-Dateien statt als Code-Kommentar, z. B. +`archive/docs/RET-10-PRUEFPROTOKOLL.md`: fehlende CORS-Header bei RET-06-API, +gefunden bei einer Sichtprüfung, Symptom (Browser hätte Fetch blockiert), Ursache +(kein `Access-Control-Allow-Origin`), Fix (RET-10-Ticket), Nachweis (curl-Test vorher/ +nachher) — alles durchsuchbar in der Protokolldatei, nicht im Quelltext verstreut. + +**Ehrlich vermerkt:** Ein ECHTER Gitea-Issue konnte in dieser Session nicht angelegt +werden (kein Gitea-API-Token verfügbar, nur Git-SSH/HTTPS-Push-Zugriff). Das oben +verlinkte Beispiel demonstriert das Vorgehen strukturell (Symptom → Ursache → Fix → +Nachweis, durchsuchbar abgelegt), aber NICHT über die Gitea-Issue-Oberfläche selbst. +Sobald ein Gitea-Zugriffstoken verfügbar ist, sollte mindestens ein Test-Issue real +angelegt werden, um die Konvention vollständig nachzuweisen — offener Punkt, siehe +Abschnitt 7. + +## 6. Prüfungen (real durchgeführt) + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Dokument liegt vor und wurde von zweiter Person gegengelesen | **bestanden** — Dokument von der Nutzerin/dem Nutzer (zweite Person) gegengelesen und freigegeben (2026-08-30) | +| 2 | Stichprobe: mindestens ein Beispieltest je benannter Testart ist umgesetzt | **bestanden** — 6 Tests real ausgeführt auf 131: `go test ./mail/internal/example/... -v -p 1`, alle grün (3 Unit, 1 Integration, 2 E2E) | +| 3 | Bug-Tracking-Vorgehen wurde einmal exemplarisch für einen realen Befund durchgespielt | **teilweise bestanden** — Vorgehen strukturell durchgespielt anhand eines realen, bereits dokumentierten Befunds (RET-10), aber NICHT über die echte Gitea-Issue-Oberfläche (kein API-Token verfügbar). Siehe Abschnitt 5, offener Punkt in Abschnitt 7 | + +## 7. Offene Punkte + +- Echter Gitea-Issue als Nachweis der Bug-Tracking-Konvention noch nicht angelegt + (fehlendes API-Token in dieser Session). Sollte nachgeholt werden, sobald Zugriff + besteht. +- `mail/internal/auth/`, `mail/internal/tenant/`, `mail/internal/ingest/` etc. existieren + noch nicht — die Pflichttest-Gate-Pfadmuster sind auf Basis der geplanten + Modulstruktur vordefiniert, nicht an echtem Code verifiziert. Erste Nagelprobe: das + erste Ticket, das einen dieser Pfade tatsächlich anlegt (voraussichtlich `ING-01`). diff --git a/mail/go.mod b/mail/go.mod new file mode 100644 index 0000000..e7c6d1c --- /dev/null +++ b/mail/go.mod @@ -0,0 +1,14 @@ +module gitea.perlbach24.de/scripte/nexarch/mail + +go 1.22 + +require github.com/jackc/pgx/v5 v5.6.0 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/mail/go.sum b/mail/go.sum new file mode 100644 index 0000000..5c39671 --- /dev/null +++ b/mail/go.sum @@ -0,0 +1,28 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/mail/internal/example/handler.go b/mail/internal/example/handler.go new file mode 100644 index 0000000..4e4c5b1 --- /dev/null +++ b/mail/internal/example/handler.go @@ -0,0 +1,36 @@ +package example + +import ( + "encoding/json" + "net/http" +) + +// NormalizeHandler ist das E2E-Test-Beispiel (QA-01): ein echter +// HTTP-Endpunkt, gegen den ein Test einen vollständigen Request-Response- +// Zyklus fährt (httptest.Server, echter TCP-Roundtrip, kein reiner +// Funktionsaufruf). Sobald das erste Mail-Frontend-Ticket eine echte +// Browser-UI mitbringt, wird die E2E-Ebene um Playwright/Jest ergänzt +// (siehe QA-01-Teststrategiedokument, Abschnitt 2) — bis dahin ist ein +// echter HTTP-Roundtrip die ehrliche, verfügbare Untergrenze für "E2E". +type normalizeRequest struct { + Address string `json:"address"` +} + +type normalizeResponse struct { + Normalized string `json:"normalized"` +} + +func NormalizeHandler(w http.ResponseWriter, r *http.Request) { + var req normalizeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "ungültiger request-body", http.StatusBadRequest) + return + } + normalized, err := NormalizeAddress(req.Address) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(normalizeResponse{Normalized: normalized}) +} diff --git a/mail/internal/example/handler_e2e_test.go b/mail/internal/example/handler_e2e_test.go new file mode 100644 index 0000000..64df532 --- /dev/null +++ b/mail/internal/example/handler_e2e_test.go @@ -0,0 +1,51 @@ +// E2E-Test-Beispiel (QA-01 Akzeptanzkriterium 1/Prüfung 2): echter +// HTTP-Request über einen laufenden httptest.Server (TCP-Roundtrip), +// nicht nur ein Funktionsaufruf im selben Prozess. +package example + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestNormalizeHandler_RealHTTPRoundTrip(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(NormalizeHandler)) + defer server.Close() + + body, _ := json.Marshal(normalizeRequest{Address: "Kunde@Beispiel.DE"}) + resp, err := http.Post(server.URL, "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("post: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var out normalizeResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("antwort dekodieren: %v", err) + } + if out.Normalized != "Kunde@beispiel.de" { + t.Fatalf("got %q", out.Normalized) + } +} + +func TestNormalizeHandler_InvalidAddressReturns400(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(NormalizeHandler)) + defer server.Close() + + body, _ := json.Marshal(normalizeRequest{Address: "keine-gueltige-adresse"}) + resp, err := http.Post(server.URL, "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("post: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } +} diff --git a/mail/internal/example/normalize.go b/mail/internal/example/normalize.go new file mode 100644 index 0000000..c7e6153 --- /dev/null +++ b/mail/internal/example/normalize.go @@ -0,0 +1,26 @@ +// Package example dient QA-01 (Mail): liefert je Testart (Unit, +// Integration, E2E) genau EIN reales, lauffähiges Beispiel, an dem sich +// spätere Mail-Tickets orientieren können — keine Wegwerf-Demo, sondern +// eine tatsächlich nützliche, kleine Funktion (Adress-Normalisierung), +// die spätere Ticket (z. B. ING-01/ING-04) ohnehin brauchen werden. +package example + +import ( + "errors" + "strings" +) + +var ErrInvalidAddress = errors.New("example: keine gültige e-mail-adresse") + +// NormalizeAddress normalisiert eine E-Mail-Adresse für den +// tenant-scoped Vergleich: Kleinschreibung der Domain-Hälfte +// (lokaler Teil bleibt case-sensitive, RFC 5321), Leerraum entfernt. +func NormalizeAddress(addr string) (string, error) { + addr = strings.TrimSpace(addr) + at := strings.LastIndex(addr, "@") + if at <= 0 || at == len(addr)-1 { + return "", ErrInvalidAddress + } + local, domain := addr[:at], addr[at+1:] + return local + "@" + strings.ToLower(domain), nil +} diff --git a/mail/internal/example/normalize_test.go b/mail/internal/example/normalize_test.go new file mode 100644 index 0000000..a2d1fa0 --- /dev/null +++ b/mail/internal/example/normalize_test.go @@ -0,0 +1,38 @@ +// Unit-Test-Beispiel (QA-01 Akzeptanzkriterium 1/Prüfung 2): keine +// externe Abhängigkeit (DB, Netzwerk), reine Funktionsprüfung. +package example + +import ( + "errors" + "testing" +) + +func TestNormalizeAddress_LowercasesDomainOnly(t *testing.T) { + got, err := NormalizeAddress("User.Name@Example.COM") + if err != nil { + t.Fatalf("unerwarteter fehler: %v", err) + } + want := "User.Name@example.com" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestNormalizeAddress_TrimsWhitespace(t *testing.T) { + got, err := NormalizeAddress(" user@example.com ") + if err != nil { + t.Fatalf("unerwarteter fehler: %v", err) + } + if got != "user@example.com" { + t.Fatalf("got %q", got) + } +} + +func TestNormalizeAddress_RejectsInvalidInput(t *testing.T) { + cases := []string{"", "no-at-sign", "@nolocalpart.com", "trailing@"} + for _, c := range cases { + if _, err := NormalizeAddress(c); !errors.Is(err, ErrInvalidAddress) { + t.Fatalf("input %q: erwartet ErrInvalidAddress, habe: %v", c, err) + } + } +} diff --git a/mail/internal/example/store.go b/mail/internal/example/store.go new file mode 100644 index 0000000..315f4cc --- /dev/null +++ b/mail/internal/example/store.go @@ -0,0 +1,42 @@ +package example + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// AddressStore ist das Integrationstest-Beispiel (QA-01): eine +// minimale, aber echte DB-gestützte Komponente — nutzt dieselbe +// Tenant-DB-Isolationskonvention wie DMS/Archive (t.Cleanup, geteilte +// physische Postgres-Instanz auf dem Testhost). +type AddressStore struct { + pool *pgxpool.Pool +} + +func NewAddressStore(pool *pgxpool.Pool) *AddressStore { + return &AddressStore{pool: pool} +} + +func (s *AddressStore) SaveNormalized(ctx context.Context, addr string) (string, error) { + normalized, err := NormalizeAddress(addr) + if err != nil { + return "", err + } + if _, err := s.pool.Exec(ctx, ` + INSERT INTO example_addresses (address) VALUES ($1) + ON CONFLICT (address) DO NOTHING + `, normalized); err != nil { + return "", fmt.Errorf("example: adresse speichern: %w", err) + } + return normalized, nil +} + +func (s *AddressStore) Exists(ctx context.Context, addr string) (bool, error) { + var exists bool + if err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM example_addresses WHERE address = $1)`, addr).Scan(&exists); err != nil { + return false, fmt.Errorf("example: existenz prüfen: %w", err) + } + return exists, nil +} diff --git a/mail/internal/example/store_integration_test.go b/mail/internal/example/store_integration_test.go new file mode 100644 index 0000000..80742b8 --- /dev/null +++ b/mail/internal/example/store_integration_test.go @@ -0,0 +1,69 @@ +// Integrations-Test-Beispiel (QA-01 Akzeptanzkriterium 1/Prüfung 2): +// echte Postgres-Instanz, folgt derselben Testhost-Konvention wie +// DMS/Archive/Core (TEST_TENANT_DSN, t.Cleanup, geteilte physische +// Instanz auf 192.168.1.131 — siehe project-nexarch-test-infra). +package example + +import ( + "context" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func setupTest(t *testing.T) *pgxpool.Pool { + 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() }) + + if _, err := pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS example_addresses ( + address TEXT PRIMARY KEY + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `TRUNCATE example_addresses`) + }) + return pool +} + +func TestAddressStore_SaveAndCheckExists(t *testing.T) { + pool := setupTest(t) + store := NewAddressStore(pool) + ctx := context.Background() + + normalized, err := store.SaveNormalized(ctx, "Kunde@Beispiel.DE") + if err != nil { + t.Fatalf("savenormalized: %v", err) + } + if normalized != "Kunde@beispiel.de" { + t.Fatalf("erwartet normalisierte adresse, habe %q", normalized) + } + + exists, err := store.Exists(ctx, normalized) + if err != nil { + t.Fatal(err) + } + if !exists { + t.Fatal("erwartet real gespeicherte adresse") + } + + notExists, err := store.Exists(ctx, "unbekannt@beispiel.de") + if err != nil { + t.Fatal(err) + } + if notExists { + t.Fatal("nie gespeicherte adresse haette nicht existieren duerfen") + } +} diff --git a/mail/internal/pflichttestgate/gate.go b/mail/internal/pflichttestgate/gate.go new file mode 100644 index 0000000..bb557e9 --- /dev/null +++ b/mail/internal/pflichttestgate/gate.go @@ -0,0 +1,72 @@ +// Package pflichttestgate erzwingt die in docs/TESTSTRATEGIE-MAIL.md +// Abschnitt 4 festgelegte Regel: jede geänderte Go-Datei in einem +// sicherheitskritischen Bereich (Auth, Tenant-Scoping, Protokoll-/ +// Compliance-kritisch) muss von einer geänderten oder neuen _test.go- +// Datei im selben Package begleitet sein. Bewusste Code-Kopie des +// Musters aus Core internal/pflichttestgate — Mail ist ein eigenständiges +// Go-Modul und kann Core nicht importieren. +package pflichttestgate + +import ( + "path" + "regexp" + "strings" +) + +// sensitivePathPatterns beschreibt die Bereiche aus +// TESTSTRATEGIE-MAIL.md Abschnitt 4. +var sensitivePathPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(^|/)mail/internal/auth/`), + regexp.MustCompile(`(^|/)mail/internal/tenant/`), + regexp.MustCompile(`(^|/)mail/internal/ingest/`), + regexp.MustCompile(`(^|/)mail/internal/imap/`), + regexp.MustCompile(`(^|/)mail/internal/smtp/`), + regexp.MustCompile(`(^|/)mail/internal/arc/`), +} + +// Violation beschreibt ein Package mit sicherheitskritischer Änderung +// ohne begleitende Testdatei. +type Violation struct { + Package string + ChangedFile string +} + +func isSensitive(file string) bool { + if !strings.HasSuffix(file, ".go") || strings.HasSuffix(file, "_test.go") { + return false + } + for _, re := range sensitivePathPatterns { + if re.MatchString(file) { + return true + } + } + return false +} + +// CheckDiff prüft eine Liste geänderter Dateipfade gegen die +// Pflichttest-Regel — ein leeres Ergebnis bedeutet: Gate besteht. +func CheckDiff(changedFiles []string) []Violation { + sensitiveByPkg := map[string]string{} + testTouchedPkgs := map[string]bool{} + + for _, f := range changedFiles { + pkg := path.Dir(f) + if strings.HasSuffix(f, "_test.go") { + testTouchedPkgs[pkg] = true + continue + } + if isSensitive(f) { + if _, seen := sensitiveByPkg[pkg]; !seen { + sensitiveByPkg[pkg] = f + } + } + } + + var violations []Violation + for pkg, file := range sensitiveByPkg { + if !testTouchedPkgs[pkg] { + violations = append(violations, Violation{Package: pkg, ChangedFile: file}) + } + } + return violations +} diff --git a/mail/internal/pflichttestgate/gate_test.go b/mail/internal/pflichttestgate/gate_test.go new file mode 100644 index 0000000..ab7ca34 --- /dev/null +++ b/mail/internal/pflichttestgate/gate_test.go @@ -0,0 +1,33 @@ +// Negativtest des Gates selbst (QA-01 Prüfung 1): ein Diff mit +// geänderter mail/internal/auth/login.go ohne begleitende Testdatei +// muss als Verstoß erkannt werden. +package pflichttestgate + +import "testing" + +func TestCheckDiff_FlagsSensitiveChangeWithoutTest(t *testing.T) { + violations := CheckDiff([]string{"mail/internal/auth/login.go"}) + if len(violations) != 1 { + t.Fatalf("erwartet genau 1 verstoß, habe %d: %+v", len(violations), violations) + } + if violations[0].Package != "mail/internal/auth" { + t.Fatalf("falsches package gemeldet: %+v", violations[0]) + } +} + +func TestCheckDiff_PassesWhenTestFileAccompanies(t *testing.T) { + violations := CheckDiff([]string{ + "mail/internal/auth/login.go", + "mail/internal/auth/login_test.go", + }) + if len(violations) != 0 { + t.Fatalf("erwartet keine verstöße, habe: %+v", violations) + } +} + +func TestCheckDiff_IgnoresNonSensitivePaths(t *testing.T) { + violations := CheckDiff([]string{"mail/internal/example/normalize.go"}) + if len(violations) != 0 { + t.Fatalf("erwartet keine verstöße für nicht-sensiblen pfad, habe: %+v", violations) + } +}