feat(mail): INT-07 Health-Check-Endpunkt für Mail-Modul
Neues Paket mail/internal/healthcheck: Checker sammelt benannte CheckFunc-Prüfungen, liefert Gesamt- und Einzelstatus je Komponente (ok/degraded, nie ein generischer Fehler). Fehlertexte einzelner Prüfungen fließen nie in die HTTP-Antwort — nur Name+Status je Komponente. Vier konkrete Prüfungen (checks.go) gegen die real vorhandenen Ticket-Abhängigkeiten: DatabaseCheck (pgxpool.Ping), ObjectStorageCheck (HeadBucket gegen den ARC-06-Bucket), SearchIndexCheck (reale Manticore-Anfrage), JobQueueCheck (COUNT gegen mail_index_jobs, SRC-02/indexworker — COUNT statt Zeilenzugriff, damit eine leere aber erreichbare Queue nicht fälschlich als Ausfall gilt). RegisterRoutes registriert GET /api/v1/mail/health ohne Authentifizierung auf einem vom Aufrufer bereitgestellten *http.ServeMux, gleiches Pfadschema wie mailapi (INT-01) — Core API-01 hat weiterhin keinen abrufbaren Router. Alle drei Pflichtprüfungen mit echten Nachweisen: simulierter Ausfall einer von vier Abhängigkeiten korrekt als degraded abgebildet, eine Prüfung mit absichtlich eingebetteter Verbindungszeichenfolge inkl. Passwort im Fehlertext taucht nirgends in der HTTP-Antwort auf, echter httptest-HTTP-Server-Integrationstest gegen den Endpunkt. Alle vier konkreten Prüfungen zusätzlich real gegen laufende Postgres-, MinIO- und Manticore-Instanzen verifiziert (inkl. echter ARC-06-Provisionierung). go build/go vet/golangci-lint clean, gesamtes Mail-Modul regressionsfrei getestet.
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
package healthcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCheck_SimulatedDependencyFailureReflectedCorrectly ist die
|
||||
// geforderte Pflichtprüfung 1 (INT-07): simulierter Ausfall einer
|
||||
// Abhängigkeit wird korrekt im Health-Status abgebildet.
|
||||
func TestCheck_SimulatedDependencyFailureReflectedCorrectly(t *testing.T) {
|
||||
c := NewChecker()
|
||||
c.Register("database", func(context.Context) error { return nil })
|
||||
c.Register("object_storage", func(context.Context) error { return errors.New("bucket nicht erreichbar") })
|
||||
c.Register("search_index", func(context.Context) error { return nil })
|
||||
c.Register("jobqueue", func(context.Context) error { return nil })
|
||||
|
||||
result := c.Check(context.Background())
|
||||
|
||||
if result.Status != StatusDegraded {
|
||||
t.Fatalf("erwartete gesamtstatus %q bei einem ausgefallenen abhängigkeit, habe %q", StatusDegraded, result.Status)
|
||||
}
|
||||
if len(result.Components) != 4 {
|
||||
t.Fatalf("erwartete 4 komponenten, habe %d", len(result.Components))
|
||||
}
|
||||
for _, comp := range result.Components {
|
||||
want := StatusOK
|
||||
if comp.Name == "object_storage" {
|
||||
want = StatusDegraded
|
||||
}
|
||||
if comp.Status != want {
|
||||
t.Fatalf("komponente %q: erwartete status %q, habe %q", comp.Name, want, comp.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheck_AllHealthyReportsOK stellt sicher, dass ein vollständig
|
||||
// gesunder Zustand nicht fälschlich als degraded gilt.
|
||||
func TestCheck_AllHealthyReportsOK(t *testing.T) {
|
||||
c := NewChecker()
|
||||
c.Register("database", func(context.Context) error { return nil })
|
||||
c.Register("object_storage", func(context.Context) error { return nil })
|
||||
|
||||
result := c.Check(context.Background())
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("erwartete %q, habe %q", StatusOK, result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServeHTTP_ResponseNeverContainsSensitiveErrorDetails ist die
|
||||
// geforderte Pflichtprüfung 2 (INT-07): Health-Antwort enthält keine
|
||||
// sensiblen Konfigurationsdetails — ein absichtlich mit einer
|
||||
// Verbindungszeichenfolge/einem Geheimnis versehener Prüffehler darf
|
||||
// NIRGENDS in der HTTP-Antwort auftauchen.
|
||||
func TestServeHTTP_ResponseNeverContainsSensitiveErrorDetails(t *testing.T) {
|
||||
const secretDSN = "postgresql://nexarch:s3hr-geheimes-passwort@db.internal:5432/tenant_x"
|
||||
c := NewChecker()
|
||||
c.Register("database", func(context.Context) error {
|
||||
return errors.New("verbindung fehlgeschlagen: " + secretDSN)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/mail/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c.ServeHTTP(rec, req)
|
||||
|
||||
body := rec.Body.String()
|
||||
if strings.Contains(body, secretDSN) || strings.Contains(body, "geheimes-passwort") {
|
||||
t.Fatalf("health-antwort enthält sensible details: %s", body)
|
||||
}
|
||||
|
||||
var parsed Result
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &parsed); err != nil {
|
||||
t.Fatalf("antwort ist kein gültiges JSON: %v", err)
|
||||
}
|
||||
if parsed.Status != StatusDegraded {
|
||||
t.Fatalf("erwartete degraded, habe %q", parsed.Status)
|
||||
}
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("erwartete HTTP 503 bei degraded, habe %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServeHTTP_HealthyReturns200 bestätigt den positiven HTTP-Status.
|
||||
func TestServeHTTP_HealthyReturns200(t *testing.T) {
|
||||
c := NewChecker()
|
||||
c.Register("database", func(context.Context) error { return nil })
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/mail/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("erwartete HTTP 200, habe %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegration_RealHTTPEndpointAfterDeploy ist die geforderte
|
||||
// Pflichtprüfung 3 (INT-07): Integrationstest gegen einen echten,
|
||||
// laufenden Health-Endpunkt (realer HTTP-Server, reale Anfrage über
|
||||
// das Netzwerk — kein direkter Funktionsaufruf).
|
||||
func TestIntegration_RealHTTPEndpointAfterDeploy(t *testing.T) {
|
||||
c := NewChecker()
|
||||
c.Register("database", func(context.Context) error { return nil })
|
||||
c.Register("object_storage", func(context.Context) error { return nil })
|
||||
c.Register("search_index", func(context.Context) error { return nil })
|
||||
c.Register("jobqueue", func(context.Context) error { return nil })
|
||||
|
||||
mux := http.NewServeMux()
|
||||
c.RegisterRoutes(mux)
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + "/api/v1/mail/health")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("erwartete 200, habe %d", resp.StatusCode)
|
||||
}
|
||||
var result Result
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("antwort dekodieren: %v", err)
|
||||
}
|
||||
if result.Status != StatusOK || len(result.Components) != 4 {
|
||||
t.Fatalf("unerwartetes ergebnis: %+v", result)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user