// Package qagate implementiert QA-03: das Prüfgate für Archivierung & // Suche. Verbindliche Kriterien: (1) die Archivierungs- und // Suchindex-Testsuiten bestehen, einschließlich Schlüsselrotation // (ARC-08), (2) der Suchpfad baut keine SQL-Klauseln dynamisch zusammen, // (3) das Gate-Ergebnis ist dokumentiert und mit Zeitstempel // nachvollziehbar. package qagate import ( "bytes" "context" "fmt" "os" "os/exec" "path/filepath" "strings" "time" ) // ArchiveAndSearchPackages sind die Pakete, deren Testsuiten das Gate // ausführt (Akzeptanzkriterium 1: Archivierung — storage/crypto/ // encstorage, inklusive Schlüsselrotation aus ARC-08 — UND Suchindex — // search, inklusive Facetten/Reindex/OCR-Konfidenz). var ArchiveAndSearchPackages = []string{ "./internal/storage/...", "./internal/crypto/...", "./internal/encstorage/...", "./internal/search/...", } // dynamicSQLBuildMarker ist das Muster, dessen Anwesenheit im Suchpfad // (Akzeptanzkriterium 2) einen Verstoß bedeutet — dieselbe Prüfung wie // mail/internal/search/no_dynamic_sql_test.go, hier auf alle // Suchpfad-Dateien verallgemeinert statt nur auf client.go beschränkt. // Der abschließende "(" grenzt einen tatsächlichen Funktionsaufruf von // bloßer Erwähnung in Kommentaren ab (z. B. "kein fmt.Sprintf/strings.Join" // in Erklärtexten, real als Fehlalarm aufgetreten und hiermit behoben). const dynamicSQLBuildMarker = "fmt.Sprintf(" // sqlBuildExceptionFiles sind Dateien, die fmt.Sprintf bewusst und // dokumentiert für Schema-/DDL-Verwaltung nutzen (Tabellennamen aus // paketintern erzeugten, regex-geprüften Werten — SRC-09 reindex.go), // NICHT für den eigentlichen Abfrage-/Suchpfad. Diese Unterscheidung // steht bereits im Quelltext von reindex.go dokumentiert. var sqlBuildExceptionFiles = map[string]bool{ "reindex.go": true, } // TestSuiteResult ist das Ergebnis eines `go test`-Laufs über die // Archivierungs-/Suchindex-Pakete. type TestSuiteResult struct { Passed bool Output string } // SQLScanResult ist das Ergebnis des statischen Suchpfad-Scans. type SQLScanResult struct { Passed bool Violations []string // ": enthält fmt.Sprintf" } // GateResult fasst ein vollständiges Gate-Ergebnis zusammen // (Akzeptanzkriterium 3: dokumentiert, mit Zeitstempel). type GateResult struct { Timestamp time.Time TestSuite TestSuiteResult SQLScan SQLScanResult } // Passed ist true, wenn ALLE Kriterien erfüllt sind. func (r GateResult) Passed() bool { return r.TestSuite.Passed && r.SQLScan.Passed } // RunTestSuites führt `go test` über ArchiveAndSearchPackages im // angegebenen Modulverzeichnis aus (Akzeptanzkriterium 1). Umgebungs- // variablen (z. B. TEST_TENANT_DSN/TEST_MANTICORE_URL für die echten // Integrationstests, darunter ARC-08s Schlüsselrotationstests) werden // unverändert vom aufrufenden Prozess vererbt — das Gate setzt selbst // keine Zugangsdaten. func RunTestSuites(ctx context.Context, moduleDir string) (TestSuiteResult, error) { args := append([]string{"test", "-count=1"}, ArchiveAndSearchPackages...) 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("qagate: go test ausführen: %w", err) } } return TestSuiteResult{Passed: err == nil, Output: out.String()}, nil } // ScanSearchPathForDynamicSQL prüft alle Nicht-Test-.go-Dateien in // searchPkgDir auf dynamische SQL-Klauselbildung (Akzeptanzkriterium 2), // mit Ausnahme der dokumentierten DDL-Verwaltung (reindex.go). func ScanSearchPathForDynamicSQL(searchPkgDir string) (SQLScanResult, error) { entries, err := os.ReadDir(searchPkgDir) if err != nil { return SQLScanResult{}, fmt.Errorf("qagate: suchpfad-verzeichnis lesen: %w", err) } var violations []string for _, e := range entries { name := e.Name() if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { continue } if sqlBuildExceptionFiles[name] { continue } content, err := os.ReadFile(filepath.Join(searchPkgDir, name)) if err != nil { return SQLScanResult{}, fmt.Errorf("qagate: %s lesen: %w", name, err) } if strings.Contains(string(content), dynamicSQLBuildMarker) { violations = append(violations, fmt.Sprintf("%s: enthält %s", name, dynamicSQLBuildMarker)) } } return SQLScanResult{Passed: len(violations) == 0, Violations: violations}, nil } // 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 } sqlResult, err := ScanSearchPathForDynamicSQL(filepath.Join(moduleDir, "internal", "search")) if err != nil { return GateResult{}, err } return GateResult{ Timestamp: time.Now().UTC(), TestSuite: testResult, SQLScan: sqlResult, }, 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-03 Gate-Ergebnis: %s\n\n", status) fmt.Fprintf(&b, "Zeitstempel (UTC): %s\n\n", r.Timestamp.Format(time.RFC3339)) fmt.Fprintf(&b, "## Testsuiten (Archivierung & Suche, inkl. Schlüsselrotation)\n\nBestanden: %v\n\n", r.TestSuite.Passed) fmt.Fprintf(&b, "## Statischer Suchpfad-Scan (keine dynamische SQL-Klauselbildung)\n\nBestanden: %v\n", r.SQLScan.Passed) if len(r.SQLScan.Violations) > 0 { fmt.Fprintf(&b, "Verstöße:\n") for _, v := range r.SQLScan.Violations { fmt.Fprintf(&b, "- %s\n", v) } } return b.String() }