Enterprise-Backup-, Recovery-, Verification-, Security- und Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme. Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes geht in keine Bewertung als "gut" ein. Umfang (Phasen 0-23): - Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll, Katalogaufbau allein aus den Manifesten — ohne Datenbank - Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck - Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell) - Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive - Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit - Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center, Ransomware-Heuristik (meldet, handelt nie) - Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing - Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository - Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64 Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs. Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md und docs/release-candidate.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
298 lines
12 KiB
Go
298 lines
12 KiB
Go
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, "<script>alert(1)</script>")
|
|
|
|
responseRecorder := httptest.NewRecorder()
|
|
newTestRouter(testInstance, health.NewRegistry(time.Second)).ServeHTTP(responseRecorder, testRequest)
|
|
|
|
returnedCorrelationID := responseRecorder.Header().Get(headerCorrelationID)
|
|
if returnedCorrelationID == "<script>alert(1)</script>" {
|
|
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())
|
|
}
|
|
}
|