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