Files
nexarch/mail/internal/healthcheck/healthcheck_test.go
T
sysops 8ee0e6c771 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.
2026-09-01 19:49:47 +02:00

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)
}
}