package httpapi
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/syncova/syncova/packages/platform/config"
"github.com/syncova/syncova/packages/platform/health"
)
// newTestLogger liefert einen Logger, der ins Nichts schreibt.
// Tests sollen keine Logausgabe erzeugen, das Verhalten aber unverändert lassen.
func newTestLogger() *slog.Logger {
return slog.New(slog.NewJSONHandler(io.Discard, nil))
}
// newTestRouter baut einen Router mit den übergebenen Health-Checks.
func newTestRouter(testInstance *testing.T, healthRegistry *health.Registry) http.Handler {
testInstance.Helper()
return NewRouter(RouterDependencies{
Config: config.Config{
Environment: config.EnvironmentTest,
HTTP: config.HTTPConfig{
MaxRequestBodyBytes: 1 << 20,
AllowedOrigins: []string{"https://ui.example.local"},
},
},
Logger: newTestLogger(),
HealthRegistry: healthRegistry,
BuildVersion: "0.1.0-test",
})
}
// healthyTestCheck meldet eine uneingeschränkt funktionsfähige Komponente.
func healthyTestCheck(context.Context) health.CheckResult {
return health.CheckResult{Status: health.StatusHealthy}
}
// offlineTestCheck meldet eine nicht erreichbare Komponente.
func offlineTestCheck(context.Context) health.CheckResult {
return health.CheckResult{Status: health.StatusOffline, Message: "Datenbank nicht erreichbar."}
}
// decodeSuccessResponse liest eine Erfolgsantwort in der Standard-Hülle.
func decodeSuccessResponse(testInstance *testing.T, responseRecorder *httptest.ResponseRecorder) SuccessResponse {
testInstance.Helper()
var successResponse SuccessResponse
if decodeError := json.Unmarshal(responseRecorder.Body.Bytes(), &successResponse); decodeError != nil {
testInstance.Fatalf("Antwort ist kein gültiges JSON (%v): %s", decodeError, responseRecorder.Body.String())
}
return successResponse
}
func TestLivenessIgnoresBrokenDependencies(testInstance *testing.T) {
healthRegistry := health.NewRegistry(time.Second)
// Die Datenbank ist ausgefallen — die Liveness darf davon unberührt bleiben,
// sonst würde der Prozess grundlos neu gestartet.
healthRegistry.Register("database", true, offlineTestCheck)
responseRecorder := httptest.NewRecorder()
newTestRouter(testInstance, healthRegistry).ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/health/live", nil))
if responseRecorder.Code != http.StatusOK {
testInstance.Fatalf("Liveness soll 200 liefern, war %d", responseRecorder.Code)
}
}
func TestReadinessFailsWhenCriticalComponentIsDown(testInstance *testing.T) {
healthRegistry := health.NewRegistry(time.Second)
healthRegistry.Register("database", true, offlineTestCheck)
responseRecorder := httptest.NewRecorder()
newTestRouter(testInstance, healthRegistry).ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/health/ready", nil))
// Ein nicht betriebsbereiter Dienst muss 503 melden, damit ihm kein Verkehr zugestellt wird.
if responseRecorder.Code != http.StatusServiceUnavailable {
testInstance.Fatalf("Readiness soll 503 liefern, war %d", responseRecorder.Code)
}
}
func TestSystemHealthReturnsComponentReport(testInstance *testing.T) {
healthRegistry := health.NewRegistry(time.Second)
healthRegistry.Register("database", true, healthyTestCheck)
responseRecorder := httptest.NewRecorder()
newTestRouter(testInstance, healthRegistry).ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/health", nil))
if responseRecorder.Code != http.StatusOK {
testInstance.Fatalf("Systemzustand soll 200 liefern, war %d", responseRecorder.Code)
}
successResponse := decodeSuccessResponse(testInstance, responseRecorder)
// Jede Antwort trägt eine Request-ID, damit ein Vorfall im Log auffindbar ist.
if successResponse.Meta.RequestID == "" {
testInstance.Error("die Antwort enthält keine request_id")
}
if _, parseError := uuid.Parse(successResponse.Meta.RequestID); parseError != nil {
testInstance.Errorf("request_id soll eine UUID sein, war %q", successResponse.Meta.RequestID)
}
}
func TestUnknownRouteUsesStandardErrorEnvelope(testInstance *testing.T) {
responseRecorder := httptest.NewRecorder()
newTestRouter(testInstance, health.NewRegistry(time.Second)).ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/gibt-es-nicht", nil))
if responseRecorder.Code != http.StatusNotFound {
testInstance.Fatalf("unbekannte Route soll 404 liefern, war %d", responseRecorder.Code)
}
var errorResponse ErrorResponse
if decodeError := json.Unmarshal(responseRecorder.Body.Bytes(), &errorResponse); decodeError != nil {
testInstance.Fatalf("Fehlerantwort ist kein gültiges JSON: %s", responseRecorder.Body.String())
}
if errorResponse.Error.Code != ErrorCodeNotFound {
testInstance.Errorf("Fehlercode soll %q sein, war %q", ErrorCodeNotFound, errorResponse.Error.Code)
}
if errorResponse.Error.RequestID == "" {
testInstance.Error("die Fehlerantwort enthält keine request_id")
}
}
func TestClientCorrelationIDIsAdopted(testInstance *testing.T) {
const clientCorrelationID = "3f1c2b4a-5d6e-4f70-8192-a3b4c5d6e7f8"
testRequest := httptest.NewRequest(http.MethodGet, "/health/live", nil)
testRequest.Header.Set(headerCorrelationID, clientCorrelationID)
responseRecorder := httptest.NewRecorder()
newTestRouter(testInstance, health.NewRegistry(time.Second)).ServeHTTP(responseRecorder, testRequest)
if responseRecorder.Header().Get(headerCorrelationID) != clientCorrelationID {
testInstance.Fatalf("die vorgegebene Correlation ID soll übernommen werden, war %q", responseRecorder.Header().Get(headerCorrelationID))
}
}
func TestInvalidCorrelationIDIsReplaced(testInstance *testing.T) {
testRequest := httptest.NewRequest(http.MethodGet, "/health/live", nil)
// Ein frei gewählter Wert darf nicht ungeprüft in die Logs gelangen.
testRequest.Header.Set(headerCorrelationID, "")
responseRecorder := httptest.NewRecorder()
newTestRouter(testInstance, health.NewRegistry(time.Second)).ServeHTTP(responseRecorder, testRequest)
returnedCorrelationID := responseRecorder.Header().Get(headerCorrelationID)
if returnedCorrelationID == "" {
testInstance.Fatal("eine ungültige Correlation ID darf nicht übernommen werden")
}
if _, parseError := uuid.Parse(returnedCorrelationID); parseError != nil {
testInstance.Fatalf("die ersetzte Correlation ID soll eine UUID sein, war %q", returnedCorrelationID)
}
}
func TestSecurityHeadersArePresent(testInstance *testing.T) {
responseRecorder := httptest.NewRecorder()
newTestRouter(testInstance, health.NewRegistry(time.Second)).ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/health/live", nil))
expectedHeaders := map[string]string{
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "no-referrer",
"Cache-Control": "no-store",
}
for headerName, expectedValue := range expectedHeaders {
if responseRecorder.Header().Get(headerName) != expectedValue {
testInstance.Errorf("Header %s soll %q sein, war %q", headerName, expectedValue, responseRecorder.Header().Get(headerName))
}
}
}
func TestCORSAllowsOnlyConfiguredOrigin(testInstance *testing.T) {
testRouter := newTestRouter(testInstance, health.NewRegistry(time.Second))
// Die konfigurierte Herkunft wird zugelassen.
allowedRequest := httptest.NewRequest(http.MethodGet, "/health/live", nil)
allowedRequest.Header.Set("Origin", "https://ui.example.local")
allowedRecorder := httptest.NewRecorder()
testRouter.ServeHTTP(allowedRecorder, allowedRequest)
if allowedRecorder.Header().Get("Access-Control-Allow-Origin") != "https://ui.example.local" {
testInstance.Error("die konfigurierte Herkunft soll zugelassen werden")
}
// Eine fremde Herkunft erhält keinen CORS-Freibrief.
foreignRequest := httptest.NewRequest(http.MethodGet, "/health/live", nil)
foreignRequest.Header.Set("Origin", "https://angreifer.example.com")
foreignRecorder := httptest.NewRecorder()
testRouter.ServeHTTP(foreignRecorder, foreignRequest)
if foreignRecorder.Header().Get("Access-Control-Allow-Origin") != "" {
testInstance.Errorf("eine fremde Herkunft darf keinen CORS-Header erhalten, war %q", foreignRecorder.Header().Get("Access-Control-Allow-Origin"))
}
}
func TestPanicIsTurnedIntoInternalError(testInstance *testing.T) {
panickingHandler := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
panic("unerwarteter programmierfehler")
})
handlerChain := Chain(panickingHandler,
RecoveryMiddleware(newTestLogger()),
CorrelationMiddleware(),
)
responseRecorder := httptest.NewRecorder()
handlerChain.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/health", nil))
if responseRecorder.Code != http.StatusInternalServerError {
testInstance.Fatalf("ein Panic soll 500 liefern, war %d", responseRecorder.Code)
}
// Der Panic-Text ist ein internes Detail und darf den Aufrufer nicht erreichen.
if strings.Contains(responseRecorder.Body.String(), "unerwarteter programmierfehler") {
testInstance.Fatalf("interne Details wurden ausgeliefert: %s", responseRecorder.Body.String())
}
}
func TestWriteErrorHidesInternalCause(testInstance *testing.T) {
const internalDetail = "pq: relation \"backup_jobs\" does not exist"
apiError := NewInternalError(errors.New(internalDetail))
testRequest := httptest.NewRequest(http.MethodGet, "/api/v1/jobs", nil)
responseRecorder := httptest.NewRecorder()
WriteError(responseRecorder, testRequest, newTestLogger(), apiError)
if strings.Contains(responseRecorder.Body.String(), internalDetail) {
testInstance.Fatalf("die interne Ursache darf nicht ausgeliefert werden: %s", responseRecorder.Body.String())
}
// Im Fehlerobjekt selbst bleibt die Ursache für das Log erhalten.
if !strings.Contains(apiError.Error(), internalDetail) {
testInstance.Error("die Ursache soll für das Log erhalten bleiben")
}
}
func TestNotImplementedErrorIsExplicit(testInstance *testing.T) {
// PROMPT.md §138: unfertige Funktionen melden das ehrlich.
notImplementedError := NewNotImplementedError("Der Proxmox-Restore")
if notImplementedError.StatusCode != http.StatusNotImplemented {
testInstance.Errorf("Status soll 501 sein, war %d", notImplementedError.StatusCode)
}
if notImplementedError.Code != ErrorCodeNotImplemented {
testInstance.Errorf("Code soll %q sein, war %q", ErrorCodeNotImplemented, notImplementedError.Code)
}
}
func TestPaginatedResponseCarriesMeta(testInstance *testing.T) {
testRequest := httptest.NewRequest(http.MethodGet, "/api/v1/jobs", nil)
responseRecorder := httptest.NewRecorder()
WritePaginatedSuccess(responseRecorder, testRequest, []string{}, PaginationMeta{Page: 2, PageSize: 50, Total: 1000})
successResponse := decodeSuccessResponse(testInstance, responseRecorder)
if successResponse.Meta.Page == nil || *successResponse.Meta.Page != 2 {
testInstance.Error("meta.page fehlt oder ist falsch")
}
if successResponse.Meta.Total == nil || *successResponse.Meta.Total != 1000 {
testInstance.Error("meta.total fehlt oder ist falsch")
}
}
func TestNonPaginatedResponseOmitsPaginationMeta(testInstance *testing.T) {
testRequest := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
responseRecorder := httptest.NewRecorder()
WriteSuccess(responseRecorder, testRequest, http.StatusOK, map[string]string{"status": "healthy"})
// Pagination-Felder dürfen bei Einzelressourcen gar nicht erst erscheinen.
if strings.Contains(responseRecorder.Body.String(), "page_size") {
testInstance.Fatalf("die Antwort enthält unerwartete Pagination-Felder: %s", responseRecorder.Body.String())
}
}