Files
archivdms/internal/api/observability_test.go
patrick 89de794356
CI / Backend (go vet, go test -cover) (push) Has been cancelled
CI / Frontend (ESLint, tsc, next build) (push) Has been cancelled
FDN-02/FDN-03/FDN-07/FDN-08: Migrations-Rollback, Objekt-Storage-Interface, go.sum-Fix, Observability
- FDN-02: Rollback-fähige Down-Migrationen (024-026), archivdms seed dev CLI
- FDN-03: internal/objectstore Interface + lokaler WORM-Treiber, signierte Download-URLs
- FDN-07: go.mod/go.sum vervollständigt (fehlender go-ldap/v3-Eintrag), CI-Pipeline (.gitea/workflows/ci.yml, bereits in FDN-01 committet) damit lauffähig
- FDN-08: Request-ID-Middleware, /metrics-Endpoint, Panic-Recovery, Login/Logout/Me technisches Logging inkl. Access-Log je Anfrage
2026-08-11 22:27:52 +02:00

175 lines
5.9 KiB
Go

// FDN-08 — Tests zu den drei Akzeptanzkriterien:
// 1. Korrelations-ID über alle Schichten
// 2. Metriken (Latenz, Fehlerrate, Queue-Länge)
// 3. Unbehandelte Fehler werden zentral gemeldet
//
// Zusätzlich Prüfung 2 der Abnahme: es dürfen keine Tokens/Passwörter in Logs
// oder Metrik-Labels landen (TestNormalizeRouteRedactsSecrets).
package api
import (
"bytes"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"archivdms/config"
)
func newTestServer(buf *bytes.Buffer) *Server {
logger := slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
return New(config.APIConfig{}, nil, nil, nil, nil, logger)
}
// AK 1: jede Anfrage bekommt eine Korrelations-ID, eine vom Client gelieferte
// wird übernommen und im Response-Header zurückgegeben.
func TestRequestIDGeneratedAndEchoed(t *testing.T) {
srv := newTestServer(&bytes.Buffer{})
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/health", nil))
got := rec.Header().Get(requestIDHeader)
if got == "" {
t.Fatalf("erwartete generierte Request-ID im Header %s", requestIDHeader)
}
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
req.Header.Set(requestIDHeader, "abc-123")
rec2 := httptest.NewRecorder()
srv.ServeHTTP(rec2, req)
if rec2.Header().Get(requestIDHeader) != "abc-123" {
t.Fatalf("Client-Request-ID nicht übernommen: %q", rec2.Header().Get(requestIDHeader))
}
// Log-Injection: unsaubere IDs werden verworfen, nicht durchgereicht.
bad := httptest.NewRequest(http.MethodGet, "/api/health", nil)
bad.Header.Set(requestIDHeader, "evil\nid")
rec3 := httptest.NewRecorder()
srv.ServeHTTP(rec3, bad)
if strings.Contains(rec3.Header().Get(requestIDHeader), "evil") {
t.Fatalf("ungültige Request-ID wurde übernommen: %q", rec3.Header().Get(requestIDHeader))
}
}
// AK 1: die ID landet im Logger, den Handler über den Context ziehen.
func TestLoggerFromCtxCarriesRequestID(t *testing.T) {
var buf bytes.Buffer
srv := newTestServer(&buf)
h := srv.requestIDMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if requestIDFromCtx(r.Context()) == "" {
t.Errorf("keine Request-ID im Context")
}
srv.reqLog(r.Context()).Info("testereignis")
}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/health", nil))
rid := rec.Header().Get(requestIDHeader)
if !strings.Contains(buf.String(), "request_id="+rid) {
t.Fatalf("Log-Zeile ohne request_id=%s: %s", rid, buf.String())
}
}
// AK 2: Zähler und Latenz-Histogramm werden im Prometheus-Textformat geliefert.
func TestMetricsEndpoint(t *testing.T) {
srv := newTestServer(&bytes.Buffer{})
for i := 0; i < 2; i++ {
srv.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/health", nil))
}
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
req.RemoteAddr = "127.0.0.1:54321"
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("erwartet 200, bekam %d", rec.Code)
}
body := rec.Body.String()
want := `archivdms_http_requests_total{method="GET",route="/api/health",status="200"} 2`
if !strings.Contains(body, want) {
t.Fatalf("Request-Zähler fehlt:\n%s", body)
}
for _, frag := range []string{
"archivdms_http_request_duration_seconds_bucket",
"archivdms_http_request_duration_seconds_sum",
"archivdms_goroutines",
"archivdms_panics_total",
} {
if !strings.Contains(body, frag) {
t.Errorf("Metrik %q fehlt in der Ausgabe", frag)
}
}
}
// AK 2 (Zugriffsschutz): fremde IPs dürfen nicht scrapen.
func TestMetricsEndpointIPRestricted(t *testing.T) {
srv := newTestServer(&bytes.Buffer{})
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
req.RemoteAddr = "203.0.113.7:5000"
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("erwartet 403 für fremde IP, bekam %d", rec.Code)
}
srv.cfg.MetricsAllowedIPs = []string{"203.0.113.0/24"}
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/metrics", nil)
req2.RemoteAddr = "203.0.113.7:5000"
srv.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusOK {
t.Fatalf("erwartet 200 für freigeschaltete CIDR, bekam %d", rec2.Code)
}
}
// AK 3: ein Panic wird zentral abgefangen, mit Korrelations-ID geloggt und als
// sauberer 500 beantwortet — der Zähler archivdms_panics_total steigt.
func TestRecoverMiddlewareCatchesPanic(t *testing.T) {
var buf bytes.Buffer
srv := newTestServer(&buf)
boom := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
panic("kaputt")
})
h := srv.requestIDMiddleware(srv.metricsMiddleware(srv.recoverMiddleware(boom)))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/documents/42", nil))
if rec.Code != http.StatusInternalServerError {
t.Fatalf("erwartet 500, bekam %d", rec.Code)
}
rid := rec.Header().Get(requestIDHeader)
logged := buf.String()
if !strings.Contains(logged, "panic in http handler") || !strings.Contains(logged, "request_id="+rid) {
t.Fatalf("Panic nicht mit Korrelations-ID geloggt: %s", logged)
}
if _, _, panics := srv.metrics.snapshot(); panics != 1 {
t.Fatalf("erwartet 1 gezähltes Panic, bekam %d", panics)
}
}
// Abnahme-Prüfung 2: keine Tokens/IDs in Logs oder Metrik-Labels.
func TestNormalizeRouteRedactsSecrets(t *testing.T) {
cases := [][2]string{
{"/api/health", "/api/health"},
{"/api/documents/42", "/api/documents/{id}"},
{"/api/documents/42/notes/7", "/api/documents/{id}/notes/{id}"},
{"/api/classification-templates", "/api/classification-templates"},
{"/api/trash/9/delete-requests", "/api/trash/{id}/delete-requests"},
{"/public/share/s3cr3tTokenXyz", "/public/share/{token}"},
{"/public/share/abc/download", "/public/share/{token}/download"},
}
for _, c := range cases {
in, want := c[0], c[1]
if got := normalizeRoute(in); got != want {
t.Errorf("normalizeRoute(%q) = %q, erwartet %q", in, got, want)
}
}
}