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.
134 lines
4.6 KiB
Go
134 lines
4.6 KiB
Go
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)
|
|
}
|
|
}
|