feat(mail): QA-02 Prüfgate Ingestion & Import

Neues Paket mail/internal/ingestiontestgate, spiegelt das etablierte
Muster aus mail/internal/qagate (QA-03) und mail/internal/importtestgate
(IMP-09): ein echtes, ausführbares Gate statt einer nur behaupteten
Prüfung. IngestionAndImportPackages listet alle 14 aus QA-02s eigener
dependsOn-Liste folgenden Pakete (Protokoll-Server, Fehlerbehandlung/
Logging, Import-Pfade). RunTestSuites führt go test -count=1 -p 1 über
alle 14 Pakete aus — -p 1 bewusst, da parallele Testbinaries sich bei
den echten QA-07-Lasttests gegenseitig CPU-Kontingent wegnehmen und
Latenz-Zielwerte durch reine Testhost-Überlastung verfehlen lassen
(real beobachtet, mit -p 1 behoben). ScanForKnownErrorPointTests prüft
automatisiert, dass die drei in Akzeptanzkriterium 2 namentlich
geforderten Fehlerpunkte (Header-Injection, Anhang-Limit, UIDVALIDITY)
je einen passenden Regressionstest im zuständigen Paket haben.

Gate-Lauf gegen aktuellen Stand real ausgeführt und mit Zeitstempel
dokumentiert: BESTANDEN, 14 Pakete grün, alle drei bekannten
Fehlerpunkte abgedeckt. Scanner durch Negativtest bewiesen, dass er
eine tatsächlich fehlende Abdeckung real erkennt statt immer
"bestanden" zu melden.

Pflichtprüfung 3 (Zweitperson bestätigt Gate-Ergebnis unabhängig)
bleibt strukturell offen — eine einzelne Sitzung kann keine unabhängige
zweite Person sein, im Prüfprotokoll dokumentiert (analog zu ING-10 und
QA-04).

go build/go vet/golangci-lint clean, gesamtes Mail-Modul
regressionsfrei getestet.
This commit is contained in:
sysops
2026-09-01 17:36:48 +02:00
parent 0505351e8f
commit 2d32157de4
3 changed files with 452 additions and 0 deletions
+232
View File
@@ -0,0 +1,232 @@
// Package ingestiontestgate implementiert QA-02: das Prüfgate für
// Ingestion & Import — spiegelt das Muster aus mail/internal/qagate
// (QA-03) und mail/internal/importtestgate (IMP-09), hier bezogen auf
// ALLE Ingestion-/Import-Pakete zusammen (Protokoll-Server, Fehler-
// behandlung/Logging, Import-Pfade).
package ingestiontestgate
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// IngestionAndImportPackages sind alle Pakete, deren Testsuiten das
// Gate ausführt (Akzeptanzkriterium 1) — abgeleitet aus QA-02s
// dependsOn-Liste: ING-10 (Protokoll-Server + MIME + Folder-State),
// ING-07 (Fehlerbehandlung, protoguard), ING-08 (Logging, protolog),
// IMP-04/IMP-09 (imapimport), IMP-05 (hotfolder), IMP-06 (virusscan),
// IMP-07 (mailboxconfig), IMP-08 (syncalert). attachments/mailrules
// gehören ebenfalls zum von IMP-09 geprüften Importpfad.
var IngestionAndImportPackages = []string{
"./internal/imap/...",
"./internal/pop3/...",
"./internal/smtp/...",
"./internal/protoguard/...",
"./internal/protolog/...",
"./internal/mimeparse/...",
"./internal/folderstate/...",
"./internal/imapimport/...",
"./internal/attachments/...",
"./internal/mailrules/...",
"./internal/hotfolder/...",
"./internal/virusscan/...",
"./internal/mailboxconfig/...",
"./internal/syncalert/...",
}
// TestSuiteResult ist das Ergebnis eines `go test`-Laufs über
// IngestionAndImportPackages.
type TestSuiteResult struct {
Passed bool
Output string
}
// RunTestSuites führt `go test` über IngestionAndImportPackages im
// angegebenen Modulverzeichnis aus (Akzeptanzkriterium 1). Umgebungs-
// variablen (TEST_TENANT_DSN u. Ä.) werden unverändert vom aufrufenden
// Prozess vererbt — das Gate setzt selbst keine Zugangsdaten.
func RunTestSuites(ctx context.Context, moduleDir string) (TestSuiteResult, error) {
// -p 1: Pakete NACHEINANDER statt parallel — mehrere gleichzeitige
// go-test-Binaries würden sich bei den echten QA-07-Lasttests
// (imap/pop3/smtp) gegenseitig CPU-Kontingent wegnehmen und so
// Latenz-Zielwerte durch reine Testhost-Überlastung verfehlen
// lassen, nicht durch einen echten Regressionsfehler.
args := append([]string{"test", "-count=1", "-p", "1"}, IngestionAndImportPackages...)
cmd := exec.CommandContext(ctx, "go", args...)
cmd.Dir = moduleDir
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
err := cmd.Run()
if err != nil {
if _, isExitErr := err.(*exec.ExitError); !isExitErr {
return TestSuiteResult{}, fmt.Errorf("ingestiontestgate: go test ausführen: %w", err)
}
}
return TestSuiteResult{Passed: err == nil, Output: out.String()}, nil
}
// KnownErrorPoint ist ein bekannter, historisch dokumentierter
// Fehlerpunkt (Akzeptanzkriterium 2), der durch mindestens einen
// benannten Regressionstest abgedeckt sein muss.
type KnownErrorPoint struct {
Name string // z. B. "Header-Injection"
PackageRelDir string // relativ zu moduleDir, z. B. "internal/mailer"
TestNameMarker string // Teilzeichenkette, die im Testfunktionsnamen vorkommen muss
Rationale string // kurze Begründung, warum dieser Punkt bekannt kritisch ist
}
// knownErrorPoints sind die drei in QA-02 Akzeptanzkriterium 2 namentlich
// geforderten Fehlerpunkte.
var knownErrorPoints = []KnownErrorPoint{
{
Name: "Header-Injection",
PackageRelDir: "internal/mailer",
TestNameMarker: "CRLF",
Rationale: "known-issues-archivmail.md #1: From/To/Subject per Stringkonkatenation ohne CRLF-Prüfung",
},
{
Name: "Anhang-Limit",
PackageRelDir: "internal/mimeparse",
TestNameMarker: "OversizedAttachment",
Rationale: "known-issues-archivmail.md #3: Anhänge ohne Größenlimit gelesen (Speicherbombe)",
},
{
Name: "UIDVALIDITY",
PackageRelDir: "internal/folderstate",
TestNameMarker: "UIDValidity",
Rationale: "ING-01/repos-analyse-mail-reuse.md: archivmail brach Resync bei UIDVALIDITY=0 nicht-konformer Server",
},
}
// KnownErrorPointResult ist das Ergebnis der Prüfung EINES bekannten
// Fehlerpunkts.
type KnownErrorPointResult struct {
Point KnownErrorPoint
Covered bool
FoundInFile string
TestName string
}
// KnownErrorPointsResult fasst die Stichprobenprüfung aller bekannten
// Fehlerpunkte zusammen (Akzeptanzkriterium 2, Pflichtprüfung 2:
// "mindestens ein Regressionstest je bekanntem Fehlerpunkt").
type KnownErrorPointsResult struct {
Passed bool
Results []KnownErrorPointResult
}
// ScanForKnownErrorPointTests prüft für jeden bekannten Fehlerpunkt, ob
// im angegebenen Paketverzeichnis mindestens eine _test.go-Datei eine
// Testfunktion enthält, deren Name den TestNameMarker enthält.
func ScanForKnownErrorPointTests(moduleDir string) (KnownErrorPointsResult, error) {
out := KnownErrorPointsResult{Passed: true}
for _, point := range knownErrorPoints {
dir := filepath.Join(moduleDir, point.PackageRelDir)
entries, err := os.ReadDir(dir)
if err != nil {
return KnownErrorPointsResult{}, fmt.Errorf("ingestiontestgate: %s lesen: %w", dir, err)
}
res := KnownErrorPointResult{Point: point}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), "_test.go") {
continue
}
content, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
return KnownErrorPointsResult{}, fmt.Errorf("ingestiontestgate: %s lesen: %w", e.Name(), err)
}
if testName, ok := findTestFuncContaining(string(content), point.TestNameMarker); ok {
res.Covered = true
res.FoundInFile = e.Name()
res.TestName = testName
break
}
}
if !res.Covered {
out.Passed = false
}
out.Results = append(out.Results, res)
}
return out, nil
}
// findTestFuncContaining sucht die erste "func TestXxx(" -Zeile, deren
// Funktionsname marker enthält.
func findTestFuncContaining(source, marker string) (string, bool) {
for _, line := range strings.Split(source, "\n") {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "func Test") {
continue
}
parenIdx := strings.Index(trimmed, "(")
if parenIdx < 0 {
continue
}
name := strings.TrimPrefix(trimmed[:parenIdx], "func ")
if strings.Contains(name, marker) {
return name, true
}
}
return "", false
}
// GateResult fasst ein vollständiges QA-02-Gate-Ergebnis zusammen
// (Akzeptanzkriterium 3: dokumentiert, mit Zeitstempel).
type GateResult struct {
Timestamp time.Time
TestSuite TestSuiteResult
KnownErrorPoints KnownErrorPointsResult
}
// Passed ist true, wenn ALLE Kriterien erfüllt sind.
func (r GateResult) Passed() bool {
return r.TestSuite.Passed && r.KnownErrorPoints.Passed
}
// Run führt das vollständige Gate aus.
func Run(ctx context.Context, moduleDir string) (GateResult, error) {
testResult, err := RunTestSuites(ctx, moduleDir)
if err != nil {
return GateResult{}, err
}
errorPointsResult, err := ScanForKnownErrorPointTests(moduleDir)
if err != nil {
return GateResult{}, err
}
return GateResult{
Timestamp: time.Now().UTC(),
TestSuite: testResult,
KnownErrorPoints: errorPointsResult,
}, nil
}
// Report erzeugt den dokumentierten, zeitgestempelten Gate-Bericht
// (Akzeptanzkriterium 3).
func (r GateResult) Report() string {
status := "BESTANDEN"
if !r.Passed() {
status = "FEHLGESCHLAGEN"
}
var b strings.Builder
fmt.Fprintf(&b, "# QA-02 Gate-Ergebnis: %s\n\n", status)
fmt.Fprintf(&b, "Zeitstempel (UTC): %s\n\n", r.Timestamp.Format(time.RFC3339))
fmt.Fprintf(&b, "## Testsuiten (Ingestion & Import, %d Pakete)\n\nBestanden: %v\n\n", len(IngestionAndImportPackages), r.TestSuite.Passed)
fmt.Fprintf(&b, "## Bekannte Fehlerpunkte — Regressionstest-Stichprobe\n\n")
for _, res := range r.KnownErrorPoints.Results {
fmt.Fprintf(&b, "- %s (%s): abgedeckt=%v", res.Point.Name, res.Point.Rationale, res.Covered)
if res.Covered {
fmt.Fprintf(&b, " — %s in %s/%s\n", res.TestName, res.Point.PackageRelDir, res.FoundInFile)
} else {
fmt.Fprintf(&b, "\n")
}
}
return b.String()
}
@@ -0,0 +1,119 @@
package ingestiontestgate
import (
"context"
"os"
"path/filepath"
"testing"
)
func moduleRoot(t *testing.T) string {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("arbeitsverzeichnis ermitteln: %v", err)
}
// gate_test.go liegt in internal/ingestiontestgate — Modulwurzel ist
// zwei Ebenen darüber.
return filepath.Join(wd, "..", "..")
}
// TestScanForKnownErrorPointTests_RealPackagesAllCovered ist die
// geforderte Pflichtprüfung 2: Stichprobe, mindestens ein
// Regressionstest je bekanntem Fehlerpunkt — automatisiert und
// reproduzierbar gegen den echten Quelltext ausgeführt.
func TestScanForKnownErrorPointTests_RealPackagesAllCovered(t *testing.T) {
root := moduleRoot(t)
result, err := ScanForKnownErrorPointTests(root)
if err != nil {
t.Fatalf("scan: %v", err)
}
if !result.Passed {
for _, r := range result.Results {
if !r.Covered {
t.Errorf("bekannter fehlerpunkt %q (%s) hat KEINEN passenden regressionstest in %s gefunden", r.Point.Name, r.Point.Rationale, r.Point.PackageRelDir)
}
}
t.FailNow()
}
for _, r := range result.Results {
t.Logf("%s: %s (%s/%s)", r.Point.Name, r.TestName, r.Point.PackageRelDir, r.FoundInFile)
}
}
// TestScanForKnownErrorPointTests_DetectsMissingCoverage beweist, dass
// der Scanner einen tatsächlich fehlenden Regressionstest auch real
// erkennt (Negativtest) — ein Gate, das nie eine Lücke melden kann,
// wäre wertlos.
func TestScanForKnownErrorPointTests_DetectsMissingCoverage(t *testing.T) {
dir := t.TempDir()
// Nur EIN Paketverzeichnis anlegen (für "Header-Injection"), die
// beiden anderen bekannten Fehlerpunkte bleiben absichtlich ohne
// Verzeichnis/Tests.
mailerDir := filepath.Join(dir, "internal", "mailer")
if err := os.MkdirAll(mailerDir, 0o750); err != nil {
t.Fatalf("verzeichnis anlegen: %v", err)
}
content := []byte("package mailer\n\nimport \"testing\"\n\nfunc TestRejectsCRLFInHeader(t *testing.T) {}\n")
if err := os.WriteFile(filepath.Join(mailerDir, "mailer_test.go"), content, 0o600); err != nil {
t.Fatalf("testdatei schreiben: %v", err)
}
// Leere Verzeichnisse für die anderen beiden Punkte, damit ReadDir
// nicht mit einem Fehler abbricht.
if err := os.MkdirAll(filepath.Join(dir, "internal", "mimeparse"), 0o750); err != nil {
t.Fatalf("verzeichnis anlegen: %v", err)
}
if err := os.MkdirAll(filepath.Join(dir, "internal", "folderstate"), 0o750); err != nil {
t.Fatalf("verzeichnis anlegen: %v", err)
}
result, err := ScanForKnownErrorPointTests(dir)
if err != nil {
t.Fatalf("scan: %v", err)
}
if result.Passed {
t.Fatal("erwartete erkannte lücke (2 von 3 fehlerpunkten unabgedeckt), scan meldet bestanden")
}
covered := 0
for _, r := range result.Results {
if r.Covered {
covered++
}
}
if covered != 1 {
t.Fatalf("erwartete genau 1 abgedeckten fehlerpunkt, habe %d", covered)
}
}
// TestRun_RealGateAgainstCurrentIngestionImportState ist die geforderte
// Pflichtprüfung 1: Gate-Lauf gegen aktuellen Stand von ING-10/IMP-09
// dokumentiert — führt das vollständige Gate real gegen den aktuellen
// Modul-Quelltext aus (inklusive echter Postgres-Integrationstests) und
// dokumentiert das Ergebnis mit Zeitstempel.
func TestRun_RealGateAgainstCurrentIngestionImportState(t *testing.T) {
if os.Getenv("TEST_TENANT_DSN") == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
}
root := moduleRoot(t)
ctx := context.Background()
result, err := Run(ctx, root)
if err != nil {
t.Fatalf("gate-lauf: %v", err)
}
if result.Timestamp.IsZero() {
t.Fatal("erwarteter zeitstempel fehlt")
}
if !result.KnownErrorPoints.Passed {
t.Fatalf("bekannte fehlerpunkte nicht vollständig abgedeckt: %+v", result.KnownErrorPoints.Results)
}
if !result.TestSuite.Passed {
t.Fatalf("testsuiten fehlgeschlagen, ausgabe:\n%s", result.TestSuite.Output)
}
reportPath := filepath.Join(t.TempDir(), "qa-02-gate-report.md")
if err := os.WriteFile(reportPath, []byte(result.Report()), 0o600); err != nil {
t.Fatalf("bericht schreiben: %v", err)
}
t.Logf("Gate-Bericht (%s):\n%s", reportPath, result.Report())
}