From aee687bb03f504e1cf83f1b6ea71fbc7e227c68c Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 30 Nov 2025 13:34:08 +0000 Subject: [PATCH] Add unit tests for DemoModeMiddleware (api) 26 test cases covering: - Demo mode disabled (all requests pass through) - Demo mode enabled with read-only methods (GET/HEAD/OPTIONS allowed) - WebSocket upgrade handling (case-insensitive) - Auth endpoint allowlist (login, OIDC, logout) - Modification request blocking (POST/PUT/DELETE/PATCH) - Partial path match rejection (security) - Response format verification (JSON error response) First test file for demo_middleware.go. Tests verify correct header setting (X-Demo-Mode) and error response structure. --- internal/api/demo_middleware_test.go | 143 +++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 internal/api/demo_middleware_test.go diff --git a/internal/api/demo_middleware_test.go b/internal/api/demo_middleware_test.go new file mode 100644 index 000000000..00687411d --- /dev/null +++ b/internal/api/demo_middleware_test.go @@ -0,0 +1,143 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +func TestDemoModeMiddleware(t *testing.T) { + // Create a simple handler that records if it was called + handlerCalled := false + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handlerCalled = true + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + + tests := []struct { + name string + demoMode bool + method string + path string + upgradeHeader string + wantCalled bool + wantStatus int + wantDemoHeader bool + }{ + // Demo mode disabled - all requests pass through + {"demo off GET", false, http.MethodGet, "/api/users", "", true, http.StatusOK, false}, + {"demo off POST", false, http.MethodPost, "/api/users", "", true, http.StatusOK, false}, + {"demo off PUT", false, http.MethodPut, "/api/users/1", "", true, http.StatusOK, false}, + {"demo off DELETE", false, http.MethodDelete, "/api/users/1", "", true, http.StatusOK, false}, + {"demo off PATCH", false, http.MethodPatch, "/api/users/1", "", true, http.StatusOK, false}, + + // Demo mode enabled - read-only methods allowed + {"demo on GET", true, http.MethodGet, "/api/users", "", true, http.StatusOK, true}, + {"demo on HEAD", true, http.MethodHead, "/api/users", "", true, http.StatusOK, true}, + {"demo on OPTIONS", true, http.MethodOptions, "/api/users", "", true, http.StatusOK, true}, + + // Demo mode enabled - WebSocket upgrades allowed + {"demo on websocket GET", true, http.MethodGet, "/api/ws", "websocket", true, http.StatusOK, true}, + {"demo on websocket case insensitive", true, http.MethodGet, "/api/ws", "WebSocket", true, http.StatusOK, true}, + {"demo on websocket uppercase", true, http.MethodGet, "/api/ws", "WEBSOCKET", true, http.StatusOK, true}, + + // Demo mode enabled - auth endpoints allowed (POST) + {"demo on login", true, http.MethodPost, "/api/login", "", true, http.StatusOK, true}, + {"demo on oidc login", true, http.MethodPost, "/api/oidc/login", "", true, http.StatusOK, true}, + {"demo on oidc callback", true, http.MethodPost, "/api/oidc/callback", "", true, http.StatusOK, true}, + {"demo on logout", true, http.MethodPost, "/api/logout", "", true, http.StatusOK, true}, + + // Demo mode enabled - modification requests blocked + {"demo on POST", true, http.MethodPost, "/api/users", "", false, http.StatusForbidden, true}, + {"demo on PUT", true, http.MethodPut, "/api/users/1", "", false, http.StatusForbidden, true}, + {"demo on DELETE", true, http.MethodDelete, "/api/users/1", "", false, http.StatusForbidden, true}, + {"demo on PATCH", true, http.MethodPatch, "/api/users/1", "", false, http.StatusForbidden, true}, + + // Demo mode enabled - partial path matches should not be allowed + {"demo on login prefix", true, http.MethodPost, "/api/login/extra", "", false, http.StatusForbidden, true}, + {"demo on oidc prefix", true, http.MethodPost, "/api/oidc/loginx", "", false, http.StatusForbidden, true}, + + // Demo mode enabled - POST to other paths blocked + {"demo on POST settings", true, http.MethodPost, "/api/settings", "", false, http.StatusForbidden, true}, + {"demo on POST config", true, http.MethodPost, "/api/config", "", false, http.StatusForbidden, true}, + {"demo on DELETE docker host", true, http.MethodDelete, "/api/docker-hosts/1", "", false, http.StatusForbidden, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handlerCalled = false + + cfg := &config.Config{ + DemoMode: tt.demoMode, + } + + middleware := DemoModeMiddleware(cfg, nextHandler) + + req := httptest.NewRequest(tt.method, tt.path, nil) + if tt.upgradeHeader != "" { + req.Header.Set("Upgrade", tt.upgradeHeader) + } + + rr := httptest.NewRecorder() + middleware.ServeHTTP(rr, req) + + // Check if handler was called + if handlerCalled != tt.wantCalled { + t.Errorf("handler called = %v, want %v", handlerCalled, tt.wantCalled) + } + + // Check status code + if rr.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rr.Code, tt.wantStatus) + } + + // Check X-Demo-Mode header + demoHeader := rr.Header().Get("X-Demo-Mode") + hasDemoHeader := demoHeader == "true" + if hasDemoHeader != tt.wantDemoHeader { + t.Errorf("X-Demo-Mode header present = %v, want %v", hasDemoHeader, tt.wantDemoHeader) + } + }) + } +} + +func TestDemoModeMiddleware_BlockedResponse(t *testing.T) { + // Verify the error response format when a request is blocked + cfg := &config.Config{ + DemoMode: true, + } + + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("handler should not be called for blocked request") + }) + + middleware := DemoModeMiddleware(cfg, nextHandler) + + req := httptest.NewRequest(http.MethodPost, "/api/users", nil) + rr := httptest.NewRecorder() + middleware.ServeHTTP(rr, req) + + // Check Content-Type header + contentType := rr.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("Content-Type = %q, want %q", contentType, "application/json") + } + + // Parse and verify response body + var response map[string]string + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to parse response body: %v", err) + } + + if response["error"] != "Demo mode enabled" { + t.Errorf("error = %q, want %q", response["error"], "Demo mode enabled") + } + + if response["message"] == "" { + t.Error("message should not be empty") + } +}