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