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
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user