Compare commits

..
19 changed files with 434 additions and 502 deletions
@@ -1,9 +1,7 @@
name: Mail-Pflichttest-Gate
name: Core-Pflichttest-Gate
on:
pull_request:
paths:
- "mail/**"
pull_request: {}
jobs:
pflichttest-gate:
@@ -16,8 +14,7 @@ jobs:
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
- name: Geänderte Dateien gegen Pflichttest-Regel pruefen
run: |
git diff --name-only "origin/${{ github.base_ref }}...HEAD" | /tmp/pflichttestgate
+17
View File
@@ -44,3 +44,20 @@ Keine Commits in dieser Session.
Keine Änderungen ermittelbar.
---
## 2026-08-28 22:37 22:41 (3m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
- 5a0d074 QA-01: teststrategie-core (pflichttest-gate, testdb-isolationsnachweis, ci-workflow)
### Geänderte Dateien
- .gitea/workflows/pflichttest-gate.yml | 20 ++++++++++++++++++++
- cmd/pflichttestgate/main.go | 41 +++++++++++++++++++++++++++++++++++++++++
- docs/TESTSTRATEGIE-CORE.md | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/pflichttestgate/gate.go | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/pflichttestgate/gate_test.go | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/testdbisolation/isolation.go | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/testdbisolation/isolation_test.go | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
@@ -1,5 +1,7 @@
// Command pflichttestgate ist das CI-Gate aus docs/TESTSTRATEGIE-MAIL.md
// Abschnitt 4. Aufruf: pflichttestgate < geänderte-dateien.txt
// Command pflichttestgate ist das CI-Gate aus docs/TESTSTRATEGIE-CORE.md Abschnitt 5.
// Aufruf: pflichttestgate <geänderte-Dateien-getrennt-durch-Zeilenumbruch-via-stdin>
// Beendet sich mit Exit-Code 1 und einer Liste der Verstöße, wenn ein sicherheitskritisches
// Package ohne begleitende Testdatei geändert wurde.
package main
import (
@@ -7,7 +9,7 @@ import (
"fmt"
"os"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/pflichttestgate"
"gitea.perlbach24.de/scripte/nexarch/internal/pflichttestgate"
)
func main() {
@@ -20,7 +22,7 @@ func main() {
}
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "pflichttestgate: eingabe konnte nicht gelesen werden: %v\n", err)
fmt.Fprintf(os.Stderr, "pflichttestgate: Eingabe konnte nicht gelesen werden: %v\n", err)
os.Exit(2)
}
@@ -34,6 +36,6 @@ func main() {
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.")
fmt.Fprintln(os.Stderr, "\nSiehe docs/TESTSTRATEGIE-CORE.md Abschnitt 3.")
os.Exit(1)
}
+81
View File
@@ -0,0 +1,81 @@
# NEXARCH Core Teststrategie
Stand: 2026-08-28. Ticket: QA-01.
## 1. Warum dieses Dokument existiert
archivdms hatte zur Zeit der Bestandsaufnahme nur 2 `_test.go`-Dateien in ganz `internal/`, davon 0 für
`auth`/`storage`/`permissions.go`. archivmail testete 2 von 18 Modulen. Beide Lücken wurden erst im
Betrieb entdeckt, nicht vor dem Merge. NEXARCH Core übernimmt daraus einen Grundsatz: **Testpflicht für
Auth, Tenant-Scoping und Policy-Enforcement 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) |
| Integration | `go test` gegen echte PostgreSQL-Instanz (`nexarch_test`-Rolle) | Repository-/Handler-Schicht, Tenant-Scoping, Policy-Enforcement |
| Vertragstests | `internal/contracttest` (siehe QA-07) | Öffentliche API-Verträge zwischen Core und Modulen |
| Last-/Leistungstests | `internal/loadtest` (siehe QA-08) | Mehrmodul-Last, JWT-Verifikation, Connection-Pooling |
| Extern | Core `QA-06` Penetrationstest | Vor Produktivbetrieb, außerhalb dieses CI-Gates |
Diese Kachel (QA-01) legt die Pflichtebenen fest und erzwingt sie technisch für die drei sicherheitskritischsten Bereiche; sie ersetzt nicht QA-07/QA-08, die eigene, bereits umgesetzte CI-Gates haben.
## 3. Pflichttests als Merge-Gate
Verbindlich für jeden Pull Request, der Dateien in einem der folgenden Bereiche ändert:
- **Auth** (`internal/auth/`, `internal/iam/`, Login/Session/2FA/SSO-Pakete)
- **Tenant-Scoping** (`internal/tenant/`, jede Repository-Schicht mit `tenant_id`-Filterung)
- **Policy-Enforcement** (`internal/rbac/`, `internal/policy/`, jede Autorisierungsprüfung)
Regel: **jede geänderte `.go`-Datei in einem dieser Bereiche muss von einer geänderten oder neuen
`_test.go`-Datei im selben Package begleitet sein.** Das CI-Gate (Abschnitt 5) prüft das automatisiert
und blockiert den Merge, wenn die Regel verletzt ist — analog zum bereits etablierten Sprintf-Verbot für
SQL (siehe `SICHERHEITSKONZEPT.md`), nur als technisch erzwungene statt nur dokumentierte Regel.
Diese Regel gilt projektweit für alle sieben Boards, nicht nur Core — siehe die entsprechenden
Akzeptanzkriterien in den `QA-01`-Tickets von DMS, Mail, Archive, Workflow, AI, Connect
(`SICHERHEITSKONZEPT.md` Abschnitt 11, Punkt 8, 2026-08-28 geklärt).
## 4. Testdatenbank-Strategie
Isolation zwischen parallelen Testläufen ist die zentrale Lehre aus dem bisherigen Testbetrieb
(siehe Projekt-Testinfrastruktur): mehrere Go-Testpakete teilen sich dieselbe physische PostgreSQL-Instanz
auf dem Testhost, aber jedes Paket braucht einen isolierten Datenbestand.
- **Rolle `nexarch_test`**: `CREATEDB`, kein Superuser, einmalig eingerichtet über `scripts/setup-test-env.sh`.
- **Reset vor jedem Testlauf**: `scripts/reset-test-env.sh` droppt die geteilte `tenants`-Registry-Tabelle
und alle `tenant_*`-Datenbanken in der `postgres`-Wartungs-DB. Nötig, weil verschiedene Branches
unterschiedliche Registry-Schemata erwarten, aber dieselbe physische Instanz teilen.
- **`-p 1` ist Pflicht** für `go test ./...`, sobald mehrere Pakete gegen die geteilte Registry-Tabelle
testen (z. B. `internal/tenant` + `internal/migrate`). Ohne `-p 1` laufen Paket-Testbinaries parallel
gegen dieselbe physische PostgreSQL-Instanz, ihre Registry-Einträge/DBs kollidieren
(falsche Tenant-Zählungen, „database already exists"-Fehler).
- **Isolation innerhalb eines Testlaufs**: jeder Test, der eine Tenant-Datenbank braucht, provisioniert
seine eigene, eindeutig benannte `tenant_*`-DB über dieselbe Provisionierungs-Logik wie die
Anwendung selbst (`TEN-01`) und räumt sie in einem `t.Cleanup()` wieder ab — keine geteilten
Fixture-Datenbanken zwischen Testfällen.
Siehe `internal/testdbisolation/isolation_test.go` (dieses Ticket) für den automatisierten Nachweis,
dass zwei parallel laufende Tenant-Provisionierungen sich nicht gegenseitig sehen.
## 5. CI-Gate
`.gitea/workflows/pflichttest-gate.yml` führt `cmd/pflichttestgate` gegen den PR-Diff aus
(`git diff --name-only origin/<base>...HEAD`). Das Programm:
1. Filtert die geänderten Dateien auf die in Abschnitt 3 genannten Pfad-Muster.
2. Prüft je betroffenem Go-Package, ob mindestens eine `_test.go`-Datei desselben Packages ebenfalls
im Diff enthalten ist.
3. Beendet sich mit Exit-Code 1 und einer Liste der betroffenen Packages ohne Teständerung, wenn die
Regel verletzt ist — der CI-Job schlägt dann fehl, der Merge ist blockiert.
Negativtest des Gates selbst: `internal/pflichttestgate/gate_test.go` enthält einen Testfall, der einen
Diff mit geänderter `internal/auth/login.go` ohne begleitende Testdatei simuliert und erwartet, dass das
Gate das als Verstoß erkennt (Prüfung 1 dieses Tickets).
## 6. Dokumentation & Gegenlesen
Dieses Dokument ist von einer zweiten Person gegenzulesen, bevor die Kachel als abgeschlossen gilt
(Akzeptanzkriterium 3 / Prüfung 3). Fund/Freigabe wird im Pull Request vermerkt.
View File
+67
View File
@@ -0,0 +1,67 @@
// Package pflichttestgate erzwingt die in docs/TESTSTRATEGIE-CORE.md Abschnitt 3 festgelegte Regel:
// jede geänderte Go-Datei in einem sicherheitskritischen Bereich (Auth, Tenant-Scoping, Policy)
// muss von einer geänderten oder neuen _test.go-Datei im selben Package begleitet sein.
package pflichttestgate
import (
"path"
"regexp"
"strings"
)
// sensitivePathPatterns beschreibt die Bereiche aus TESTSTRATEGIE-CORE.md Abschnitt 3.
var sensitivePathPatterns = []*regexp.Regexp{
regexp.MustCompile(`(^|/)internal/auth/`),
regexp.MustCompile(`(^|/)internal/iam/`),
regexp.MustCompile(`(^|/)internal/tenant/`),
regexp.MustCompile(`(^|/)internal/rbac/`),
regexp.MustCompile(`(^|/)internal/policy/`),
}
// Violation beschreibt ein Package mit sicherheitskritischer Änderung ohne begleitende Testdatei.
type Violation struct {
Package string
ChangedFile string
}
// isSensitive meldet, ob eine geänderte Datei in einen der Pflichttest-Bereiche fällt.
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 (z. B. aus `git diff --name-only`) gegen die
// Pflichttest-Regel und gibt für jedes betroffene Package ohne begleitende Testdatei eine Violation
// zurück. Ein leeres Ergebnis bedeutet: Gate besteht.
func CheckDiff(changedFiles []string) []Violation {
sensitiveByPkg := map[string]string{} // package -> erste sensible Datei (fürs Reporting)
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
}
+84
View File
@@ -0,0 +1,84 @@
package pflichttestgate
import "testing"
// Prüfung 1 (QA-01): CI-Lauf mit absichtlich fehlendem Pflichttest schlägt kontrolliert fehl —
// Negativtest des Gates selbst.
func TestCheckDiff_BlocksAuthChangeWithoutTest(t *testing.T) {
changed := []string{
"internal/auth/login.go",
"internal/apiserver/handler.go", // unkritisch, keine Testpflicht
}
violations := CheckDiff(changed)
if len(violations) != 1 {
t.Fatalf("erwartet 1 Verstoß, bekommen %d: %+v", len(violations), violations)
}
if violations[0].Package != "internal/auth" {
t.Errorf("erwartetes Package internal/auth, bekommen %q", violations[0].Package)
}
if violations[0].ChangedFile != "internal/auth/login.go" {
t.Errorf("erwartete Datei internal/auth/login.go, bekommen %q", violations[0].ChangedFile)
}
}
func TestCheckDiff_PassesWhenTestFileAccompaniesChange(t *testing.T) {
changed := []string{
"internal/auth/login.go",
"internal/auth/login_test.go",
}
violations := CheckDiff(changed)
if len(violations) != 0 {
t.Fatalf("erwartet keinen Verstoß, bekommen %+v", violations)
}
}
func TestCheckDiff_PassesForNewTestFileEvenWithoutSourceChange(t *testing.T) {
// Nachtraeglich ergaenzter Test fuer bestehenden Code ist erlaubt/erwuenscht.
changed := []string{"internal/tenant/registry_test.go"}
violations := CheckDiff(changed)
if len(violations) != 0 {
t.Fatalf("erwartet keinen Verstoß, bekommen %+v", violations)
}
}
func TestCheckDiff_IgnoresUnrelatedPackages(t *testing.T) {
changed := []string{"internal/config/config.go", "internal/db/db.go"}
violations := CheckDiff(changed)
if len(violations) != 0 {
t.Fatalf("erwartet keinen Verstoß fuer unkritische Pakete, bekommen %+v", violations)
}
}
func TestCheckDiff_CoversAllSensitiveAreas(t *testing.T) {
cases := []string{
"internal/auth/session.go",
"internal/iam/user.go",
"internal/tenant/provisioner.go",
"internal/rbac/enforcer.go",
"internal/policy/decision.go",
}
for _, f := range cases {
violations := CheckDiff([]string{f})
if len(violations) != 1 {
t.Errorf("Datei %q sollte als sensibel erkannt werden, Verstöße: %+v", f, violations)
}
}
}
func TestCheckDiff_IgnoresNonGoFiles(t *testing.T) {
changed := []string{"internal/auth/README.md"}
violations := CheckDiff(changed)
if len(violations) != 0 {
t.Fatalf("Nicht-Go-Dateien duerfen keine Testpflicht ausloesen, bekommen %+v", violations)
}
}
+76
View File
@@ -0,0 +1,76 @@
// Package testdbisolation demonstriert und prüft die in docs/TESTSTRATEGIE-CORE.md Abschnitt 4
// beschriebene Testdatenbank-Isolation: jeder Test, der eine Tenant-Datenbank braucht,
// provisioniert seine eigene, eindeutig benannte DB und räumt sie über Cleanup wieder ab —
// keine geteilten Fixture-Datenbanken zwischen parallelen Testläufen.
package testdbisolation
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// ProvisionEphemeralDB legt unter dem gegebenen Admin-DSN eine neue, eindeutig benannte Datenbank an
// und liefert eine Cleanup-Funktion, die sie wieder droppt. adminDSN muss auf eine Wartungs-DB
// (z. B. "postgres") zeigen, name muss ein gültiger, bereits eindeutiger Bezeichner sein.
func ProvisionEphemeralDB(ctx context.Context, adminDSN, name string) (cleanup func(context.Context) error, err error) {
conn, err := pgx.Connect(ctx, adminDSN)
if err != nil {
return nil, fmt.Errorf("verbindung zur wartungs-db fehlgeschlagen: %w", err)
}
defer conn.Close(ctx)
if _, err := conn.Exec(ctx, fmt.Sprintf("CREATE DATABASE %s", pgx.Identifier{name}.Sanitize())); err != nil {
return nil, fmt.Errorf("datenbank %q konnte nicht angelegt werden: %w", name, err)
}
cleanup = func(cleanupCtx context.Context) error {
adminConn, err := pgx.Connect(cleanupCtx, adminDSN)
if err != nil {
return fmt.Errorf("cleanup: verbindung fehlgeschlagen: %w", err)
}
defer adminConn.Close(cleanupCtx)
_, err = adminConn.Exec(cleanupCtx, fmt.Sprintf("DROP DATABASE IF EXISTS %s", pgx.Identifier{name}.Sanitize()))
return err
}
return cleanup, nil
}
// WriteMarker schreibt einen eindeutigen Beleg-Datensatz in die angegebene Datenbank (verbindet
// per dsnForDB), um im Test nachzuweisen, dass zwei parallele Provisionierungen sich nicht sehen.
func WriteMarker(ctx context.Context, dsn, table, value string) error {
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return err
}
defer pool.Close()
if _, err := pool.Exec(ctx, fmt.Sprintf(
"CREATE TABLE IF NOT EXISTS %s (value text primary key)", pgx.Identifier{table}.Sanitize(),
)); err != nil {
return err
}
_, err = pool.Exec(ctx, fmt.Sprintf("INSERT INTO %s (value) VALUES ($1)", pgx.Identifier{table}.Sanitize()), value)
return err
}
// CountMarkers zählt die Belegzeilen in der angegebenen Datenbank/Tabelle.
func CountMarkers(ctx context.Context, dsn, table string) (int, error) {
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return 0, err
}
defer pool.Close()
if _, err := pool.Exec(ctx, fmt.Sprintf(
"CREATE TABLE IF NOT EXISTS %s (value text primary key)", pgx.Identifier{table}.Sanitize(),
)); err != nil {
return 0, err
}
var n int
err = pool.QueryRow(ctx, fmt.Sprintf("SELECT count(*) FROM %s", pgx.Identifier{table}.Sanitize())).Scan(&n)
return n, err
}
@@ -0,0 +1,99 @@
package testdbisolation
import (
"context"
"fmt"
"os"
"strings"
"sync"
"testing"
"time"
)
// TestParallelProvisioning_AreIsolated ist der automatisierte Nachweis für QA-01 Prüfung 2:
// Testdatenbank-Isolation zwischen parallelen Testläufen. Zwei Provisionierungen laufen
// gleichzeitig, jede schreibt einen Beleg-Datensatz in ihre eigene DB — keine darf den
// Beleg der jeweils anderen sehen.
//
// Braucht TEST_ADMIN_DSN (siehe Projekt-Testinfrastruktur), läuft auf dem Testhost, nicht lokal
// auf der Entwicklungsmaschine (siehe "no installs"-Regel) — wird ohne gesetzte Variable übersprungen.
func TestParallelProvisioning_AreIsolated(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt — Test läuft nur auf dem Testhost, siehe docs/TESTSTRATEGIE-CORE.md")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
const numParallel = 2
dbNames := make([]string, numParallel)
for i := range dbNames {
dbNames[i] = fmt.Sprintf("qa01_isolation_%d_%d", time.Now().UnixNano(), i)
}
var wg sync.WaitGroup
errs := make([]error, numParallel)
cleanups := make([]func(context.Context) error, numParallel)
for i, name := range dbNames {
wg.Add(1)
go func(idx int, dbName string) {
defer wg.Done()
cleanup, err := ProvisionEphemeralDB(ctx, adminDSN, dbName)
if err != nil {
errs[idx] = fmt.Errorf("provisionierung %d fehlgeschlagen: %w", idx, err)
return
}
cleanups[idx] = cleanup
dbDSN := dsnForDB(adminDSN, dbName)
// Jede Goroutine schreibt einen für sie eindeutigen Beleg.
if err := WriteMarker(ctx, dbDSN, "isolation_marker", fmt.Sprintf("marker-von-goroutine-%d", idx)); err != nil {
errs[idx] = fmt.Errorf("markierung %d fehlgeschlagen: %w", idx, err)
}
}(i, name)
}
wg.Wait()
t.Cleanup(func() {
for _, cleanup := range cleanups {
if cleanup != nil {
_ = cleanup(context.Background())
}
}
})
for i, err := range errs {
if err != nil {
t.Fatalf("goroutine %d: %v", i, err)
}
}
// Isolationsnachweis: jede DB enthält genau ihren eigenen Beleg (1), keinen fremden.
for i, name := range dbNames {
dbDSN := dsnForDB(adminDSN, name)
count, err := CountMarkers(ctx, dbDSN, "isolation_marker")
if err != nil {
t.Fatalf("zaehlung in db %d fehlgeschlagen: %v", i, err)
}
if count != 1 {
t.Errorf("db %d (%s): erwartet genau 1 eigenen Beleg, gefunden %d — Isolation verletzt", i, name, count)
}
}
}
// dsnForDB ersetzt den Datenbanknamen im Wartungs-DSN durch den gegebenen Namen.
func dsnForDB(adminDSN, dbName string) string {
idx := strings.LastIndex(adminDSN, "/")
if idx == -1 {
return adminDSN
}
base := adminDSN[:idx+1]
rest := adminDSN[idx+1:]
if q := strings.Index(rest, "?"); q != -1 {
return base + dbName + rest[q:]
}
return base + dbName
}
-110
View File
@@ -1,110 +0,0 @@
# 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`).
-14
View File
@@ -1,14 +0,0 @@
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
)
-36
View File
@@ -1,36 +0,0 @@
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})
}
-51
View File
@@ -1,51 +0,0 @@
// 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)
}
}
-26
View File
@@ -1,26 +0,0 @@
// 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
}
-38
View File
@@ -1,38 +0,0 @@
// 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)
}
}
}
-42
View File
@@ -1,42 +0,0 @@
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
}
@@ -1,69 +0,0 @@
// 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")
}
}
-72
View File
@@ -1,72 +0,0 @@
// 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
}
@@ -1,33 +0,0 @@
// 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)
}
}