- 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
175 lines
5.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|