Pulse/internal/api/demo_middleware_test.go
2026-04-11 17:20:58 +01:00

169 lines
8.5 KiB
Go

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},
// WebSocket upgrade with POST method (tests websocket branch after GET/HEAD/OPTIONS check)
{"demo on websocket POST", true, http.MethodPost, "/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 provider login", true, http.MethodPost, "/api/oidc/acme/login", "", true, http.StatusOK, true},
{"demo on oidc provider callback", true, http.MethodPost, "/api/oidc/acme/callback", "", true, http.StatusOK, true},
{"demo on logout", true, http.MethodPost, "/api/logout", "", true, http.StatusOK, true},
// Demo mode enabled - commercial surfaces are hidden centrally
{"demo on hidden license status", true, http.MethodGet, "/api/license/status", "", false, http.StatusNotFound, true},
{"demo on hidden commercial posture", true, http.MethodGet, "/api/license/commercial-posture", "", false, http.StatusNotFound, true},
{"demo on hidden license entitlements", true, http.MethodGet, "/api/license/entitlements", "", false, http.StatusNotFound, true},
{"demo on hidden monitored system ledger", true, http.MethodGet, "/api/license/monitored-system-ledger", "", false, http.StatusNotFound, true},
{"demo on hidden monitored system explanation", true, http.MethodPost, "/api/license/monitored-system-ledger/explain", "", false, http.StatusNotFound, true},
{"demo on hidden monitored system preview", true, http.MethodPost, "/api/license/monitored-system-ledger/preview", "", false, http.StatusNotFound, true},
{"demo on hidden truenas draft preview", true, http.MethodPost, "/api/truenas/connections/preview", "", false, http.StatusNotFound, true},
{"demo on hidden truenas saved preview", true, http.MethodPost, "/api/truenas/connections/conn-1/preview", "", false, http.StatusNotFound, true},
{"demo on hidden vmware draft preview", true, http.MethodPost, "/api/vmware/connections/preview", "", false, http.StatusNotFound, true},
{"demo on hidden vmware saved preview", true, http.MethodPost, "/api/vmware/connections/conn-1/preview", "", false, http.StatusNotFound, true},
{"demo on hidden diagnostics", true, http.MethodGet, "/api/diagnostics", "", false, http.StatusNotFound, true},
{"demo on hidden diagnostics token prepare", true, http.MethodPost, "/api/diagnostics/docker/prepare-token", "", false, http.StatusNotFound, true},
{"demo on hidden logs stream", true, http.MethodGet, "/api/logs/stream", "", false, http.StatusNotFound, true},
{"demo on hidden logs download", true, http.MethodGet, "/api/logs/download", "", false, http.StatusNotFound, true},
{"demo on hidden logs level read", true, http.MethodGet, "/api/logs/level", "", false, http.StatusNotFound, true},
{"demo on hidden logs level write", true, http.MethodPost, "/api/logs/level", "", false, http.StatusNotFound, true},
{"demo on hidden checkout start", true, http.MethodGet, "/auth/license-purchase-start", "", false, http.StatusNotFound, true},
{"demo on hidden license activate", true, http.MethodPost, "/api/license/activate", "", false, http.StatusNotFound, true},
{"demo on hidden purchase start", true, http.MethodGet, licensePurchaseStartPath, "", false, http.StatusNotFound, true},
{"demo on hidden trial activation", true, http.MethodGet, "/auth/trial-activate", "", false, http.StatusNotFound, true},
{"demo on runtime capabilities", true, http.MethodGet, "/api/license/runtime-capabilities", "", 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 legacy oidc alias", true, http.MethodPost, "/api/oidc/login", "", false, http.StatusForbidden, true},
{"demo on oidc prefix", true, http.MethodPost, "/api/oidc/acme/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")
}
}