Files
nexarch/mail/internal/qagate/gate.go
T
sysopsandClaude Sonnet 5 0065b959b3 QA-03: pruefgate-archivierung-suche
Prüfgate für Archivierung & Suche: verbindliche Kriterien für
Verschlüsselung, Speicherpfad-Konventionen und Suchqualität.

- qagate/gate.go: RunTestSuites führt go test real über storage/crypto/
  encstorage/search aus (inkl. ARC-08-Schlüsselrotation, SRC-10-OCR-
  Konfidenz). ScanSearchPathForDynamicSQL prüft jede Nicht-Test-Datei in
  mail/internal/search (außer reindex.go, dokumentierte DDL-Ausnahme)
  auf tatsächliche fmt.Sprintf(-Aufrufe — verallgemeinert die SRC-01-
  Prüfung auf den gesamten Suchpfad. GateResult.Report() liefert
  dokumentierten, zeitgestempelten Bericht.
- Echten Fehlalarm gefunden und behoben: Kommentartext in fields.go
  ("...fmt.Sprintf/strings.Join...") wurde fälschlich als Verstoß
  erkannt — Suchmuster auf "fmt.Sprintf(" präzisiert.

Prüfungen (alle real durchgeführt, siehe mail/docs/QA-03-PRUEFPROTOKOLL.md):
1. TestRun_RealGateAgainstCurrentARC08SRC10State: echter Gate-Lauf,
   Bericht real "BESTANDEN" mit Zeitstempel.
2. TestScanSearchPathForDynamicSQL_RealSearchPackagePasses +
   Negativ-/Ausnahmetests: automatisierte Codereview-Stichprobe bestätigt
   real statischen Query-Builder.
3. Unabhängiger Subagent (frischer Kontext) hat den Gate-Testlauf real
   erneut ausgeführt und den Suchpfad-Scan mit eigenem grep unabhängig
   verifiziert — "BESTANDEN, unabhängig bestätigt".

Kein Umbau: storage/crypto/encstorage/search unverändert, QA-03 fügt
ausschließlich das Gate selbst hinzu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-08-31 23:03:29 +02:00

163 lines
5.7 KiB
Go

// 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 // "<Datei>: 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()
}