package api import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strconv" "strings" "testing" "time" "github.com/gorilla/websocket" "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" "github.com/rcourtman/pulse-go-rewrite/internal/ai/approval" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/servicediscovery" pulsews "github.com/rcourtman/pulse-go-rewrite/internal/websocket" "github.com/rcourtman/pulse-go-rewrite/pkg/auth" ) type wsRawMessage struct { Type agentexec.MessageType `json:"type"` Payload json.RawMessage `json:"payload,omitempty"` } func TestBrowserCookiePolicyWritesSecureCookiesForTLS(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "https://pulse.example/api/config", nil) rec := httptest.NewRecorder() getBrowserCookiePolicy(req).setClientReadable(rec, &http.Cookie{Name: CookieNameCSRF, Value: "token", Path: "/"}) cookies := rec.Result().Cookies() if len(cookies) != 1 || !cookies[0].Secure { t.Fatalf("TLS cookie policy = %+v, want one Secure cookie", cookies) } if cookies[0].SameSite != http.SameSiteLaxMode { t.Fatalf("SameSite = %v, want Lax", cookies[0].SameSite) } } type denyAuthorizer struct{} func (d *denyAuthorizer) Authorize(_ context.Context, _ string, _ string) (bool, error) { return false, nil } type adminOnlyAuthorizer struct{} func (a *adminOnlyAuthorizer) Authorize(ctx context.Context, _ string, _ string) (bool, error) { return auth.GetUser(ctx) == "admin", nil } func newTestConfigWithTokens(t *testing.T, records ...config.APITokenRecord) *config.Config { t.Helper() tempDir := t.TempDir() return &config.Config{ DataPath: tempDir, ConfigPath: tempDir, APITokens: records, } } func newTokenRecord(t *testing.T, raw string, scopes []string, metadata map[string]string) config.APITokenRecord { t.Helper() record, err := config.NewAPITokenRecord(raw, "test-token", scopes) if err != nil { t.Fatalf("NewAPITokenRecord: %v", err) } if metadata != nil { record.Metadata = metadata } return *record } func TestSecurityRejectsWildcardTrustedProxyCIDR(t *testing.T) { t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "0.0.0.0/0") resetTrustedProxyConfig() if err := ValidateTrustedProxyCIDRsFromEnv(); err == nil || !strings.Contains(err.Error(), "wildcard trust range") { t.Fatalf("expected wildcard trusted proxy configuration to be rejected, got %v", err) } req := httptest.NewRequest(http.MethodGet, "http://pulse.local/api/state", nil) req.RemoteAddr = "198.51.100.42:8443" req.Header.Set("X-Forwarded-For", "203.0.113.10") if got := GetClientIP(req); got != "198.51.100.42" { t.Fatalf("expected forwarded headers to fail closed, got %q", got) } } func readRegisteredPayload(t *testing.T, conn *websocket.Conn) agentexec.RegisteredPayload { t.Helper() _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) _, data, err := conn.ReadMessage() if err != nil { t.Fatalf("ReadMessage: %v", err) } var msg wsRawMessage if err := json.Unmarshal(data, &msg); err != nil { t.Fatalf("unmarshal message: %v", err) } if msg.Type != agentexec.MsgTypeRegistered { t.Fatalf("message type = %q, want %q", msg.Type, agentexec.MsgTypeRegistered) } if msg.Payload == nil { t.Fatalf("registered payload missing") } var payload agentexec.RegisteredPayload if err := json.Unmarshal(msg.Payload, &payload); err != nil { t.Fatalf("unmarshal registered payload: %v", err) } return payload } func TestSimpleStatsRequiresAuthInAPIMode(t *testing.T) { rawToken := "stats-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/simple-stats", nil) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 without token, got %d", rec.Code) } req = httptest.NewRequest(http.MethodGet, "/simple-stats", nil) req.Header.Set("X-API-Token", rawToken) rec = httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 with token, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "Simple Pulse Stats") { t.Fatalf("expected stats page HTML, got %q", rec.Body.String()) } } func TestSimpleStatsAllowsBearerToken(t *testing.T) { rawToken := "stats-bearer-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/simple-stats", nil) req.Header.Set("Authorization", "Bearer "+rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 with bearer token, got %d", rec.Code) } if rec.Header().Get("X-Auth-Method") != "api_token" { t.Fatalf("expected X-Auth-Method api_token, got %q", rec.Header().Get("X-Auth-Method")) } } func TestSimpleStatsRejectsInvalidBearerToken(t *testing.T) { rawToken := "stats-bearer-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/simple-stats", nil) req.Header.Set("Authorization", "Bearer invalid-token") rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 for invalid bearer token, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "Invalid API token") { t.Fatalf("expected invalid token response, got %q", rec.Body.String()) } } func TestSchedulerHealthRequiresAuthInAPIMode(t *testing.T) { record := newTokenRecord(t, "sched-token-123.12345678", []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/monitoring/scheduler/health", nil) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 without token, got %d", rec.Code) } } func TestChangePasswordRequiresAuthInAPIMode(t *testing.T) { record := newTokenRecord(t, "change-pass-token-123.12345678", []string{config.ScopeSettingsWrite}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/security/change-password", strings.NewReader(`{}`)) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 without auth, got %d", rec.Code) } } func TestChangePasswordRejectsProxyNonAdmin(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.ProxyAuthSecret = "proxy-secret" cfg.ProxyAuthUserHeader = "X-Remote-User" cfg.ProxyAuthRoleHeader = "X-Remote-Roles" cfg.ProxyAuthAdminRole = "admin" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/security/change-password", strings.NewReader(`{}`)) req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret) req.Header.Set("X-Remote-User", "viewer-user") req.Header.Set("X-Remote-Roles", "viewer") rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for non-admin proxy password change, got %d", rec.Code) } } func TestResetLockoutRequiresAuthInAPIMode(t *testing.T) { record := newTokenRecord(t, "reset-lockout-token-123.12345678", []string{config.ScopeSettingsWrite}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/security/reset-lockout", strings.NewReader(`{}`)) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 without auth, got %d", rec.Code) } } func TestRequirePermissionDeniesProxyNonAdminUsers(t *testing.T) { prevAuthorizer := auth.GetAuthorizer() auth.SetAuthorizer(&adminOnlyAuthorizer{}) defer auth.SetAuthorizer(prevAuthorizer) cfg := newTestConfigWithTokens(t) cfg.ProxyAuthSecret = "proxy-secret" cfg.ProxyAuthUserHeader = "X-Remote-User" cfg.ProxyAuthRoleHeader = "X-Remote-Roles" cfg.ProxyAuthAdminRole = "admin" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/security/tokens", nil) req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret) req.Header.Set("X-Remote-User", "viewer-user") req.Header.Set("X-Remote-Roles", "viewer") rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for non-admin proxy user, got %d", rec.Code) } req = httptest.NewRequest(http.MethodGet, "/api/security/tokens", nil) req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret) req.Header.Set("X-Remote-User", "admin") req.Header.Set("X-Remote-Roles", "admin") rec = httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 for admin proxy user, got %d", rec.Code) } } func TestLicenseFeaturesRequiresAuthInAPIMode(t *testing.T) { record := newTokenRecord(t, "license-token-123.12345678", []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/license/features", nil) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 without token, got %d", rec.Code) } } func TestLicenseStatusRequiresAuthInAPIMode(t *testing.T) { record := newTokenRecord(t, "license-status-token-123.12345678", []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/license/status", nil) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 without token, got %d", rec.Code) } } func TestAIStatusRequiresAuthInAPIMode(t *testing.T) { record := newTokenRecord(t, "ai-status-token-123.12345678", []string{config.ScopeAIChat}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/ai/status", nil) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 without token, got %d", rec.Code) } } func TestAIStatusRequiresAIChatScope(t *testing.T) { rawToken := "ai-status-scope-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/ai/status", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing ai:chat scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeAIChat) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAIChat, rec.Body.String()) } } func TestWebSocketRequiresMonitoringReadScope(t *testing.T) { rawToken := "ws-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeAgentReport}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/ws", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing monitoring:read scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeMonitoringRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringRead, rec.Body.String()) } } func TestWebSocketRequiresMonitoringReadScopeForUpgrade(t *testing.T) { rawToken := "ws-scope-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeAgentReport}, nil) cfg := newTestConfigWithTokens(t, record) hub := pulsews.NewHub(nil) go hub.Run() defer hub.Stop() router := NewRouter(cfg, nil, nil, hub, nil, "1.0.0") ts := newIPv4HTTPServer(t, router.Handler()) defer ts.Close() wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws" headers := http.Header{} headers.Set("X-API-Token", rawToken) conn, resp, err := websocket.DefaultDialer.Dial(wsURL, headers) if err == nil { conn.Close() t.Fatalf("expected websocket upgrade to be rejected without monitoring:read scope") } if resp == nil { t.Fatalf("expected HTTP response for failed websocket upgrade") } if resp.StatusCode != http.StatusForbidden { t.Fatalf("expected 403 for missing scope, got %d", resp.StatusCode) } } func TestUnifiedAgentManagementRequiresSettingsWriteScope(t *testing.T) { rawToken := "host-manage-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeAgentManage}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") cases := []struct { name string method string path string }{ {name: "link", method: http.MethodPost, path: "/api/agents/agent/link"}, {name: "unlink", method: http.MethodPost, path: "/api/agents/agent/unlink"}, {name: "delete", method: http.MethodDelete, path: "/api/agents/agent/agent-1"}, {name: "host link alias", method: http.MethodPost, path: "/api/agents/host/link"}, {name: "host unlink alias", method: http.MethodPost, path: "/api/agents/host/unlink"}, {name: "host delete alias", method: http.MethodDelete, path: "/api/agents/host/agent-1"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest(tc.method, tc.path, nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } }) } } func TestTestNotificationRequiresSettingsWriteScope(t *testing.T) { rawToken := "notify-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/test-notification", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } func TestAIFindingsRequiresAIExecuteScope(t *testing.T) { rawToken := "ai-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeAIChat}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/ai/findings/f-1/investigation", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing ai:execute scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeAIExecute) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAIExecute, rec.Body.String()) } } func TestNotificationsDLQRequiresSettingsReadScope(t *testing.T) { rawToken := "dlq-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/notifications/dlq", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String()) } } func TestNotificationsDLQMutationsRequireSettingsWriteScope(t *testing.T) { rawToken := "dlq-write-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") cases := []string{ "/api/notifications/dlq/retry", "/api/notifications/dlq/delete", } for _, path := range cases { req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader([]byte(`{"id":"test"}`))) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope on %s, got %d", path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } } func TestAgentExecTokenBindingEnforced(t *testing.T) { rawToken := "agent-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeAgentExec}, map[string]string{"bound_agent_id": "agent-1"}) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ts := newIPv4HTTPServer(t, router.Handler()) defer ts.Close() wsURL := wsURLForHTTP(ts.URL) + "/api/agent/ws" // Mismatched agent ID should be rejected conn, _, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL)) if err != nil { t.Fatalf("Dial: %v", err) } regMsg, err := agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{ AgentID: "agent-2", Hostname: "host-2", Version: "1.0.0", Platform: "linux", Token: rawToken, }) if err != nil { conn.Close() t.Fatalf("NewMessage: %v", err) } if err := conn.WriteJSON(regMsg); err != nil { conn.Close() t.Fatalf("WriteJSON: %v", err) } reg := readRegisteredPayload(t, conn) if reg.Success { conn.Close() t.Fatalf("expected registration to be rejected for mismatched bound agent") } conn.Close() // Matching agent ID should succeed conn, _, err = websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL)) if err != nil { t.Fatalf("Dial: %v", err) } regMsg, err = agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{ AgentID: "agent-1", Hostname: "host-1", Version: "1.0.0", Platform: "linux", Token: rawToken, }) if err != nil { conn.Close() t.Fatalf("NewMessage: %v", err) } if err := conn.WriteJSON(regMsg); err != nil { conn.Close() t.Fatalf("WriteJSON: %v", err) } reg = readRegisteredPayload(t, conn) if !reg.Success { conn.Close() t.Fatalf("expected registration to be accepted for matching bound agent, got %q", reg.Message) } conn.Close() } // TestAgentExecTokenBindingAcceptsHostnameMatch covers the deploy/enroll flow // where the runtime token carries both bound_agent_id (server-canonical // "agent-" form) and bound_hostname, but the agent's runtime // agent_id is derived locally from /etc/machine-id and does NOT match // bound_agent_id. The hostname is the authoritative binding — the agent // proves it is running on the bound host by registering with that hostname. // // Regression: prior to this test, the hardening commit 3ec2c0779 enforced // strict bound_agent_id equality, which silently rejected every deploy-flow // agent (they never produce "agent-" as their runtime ID) and made // the AI command tool report "No agents are currently connected" despite // agents appearing online via HTTP reports. func TestAgentExecTokenBindingAcceptsHostnameMatch(t *testing.T) { rawToken := "agent-deploy-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeAgentExec}, map[string]string{ "bound_agent_id": "agent-prox97", "bound_hostname": "prox97", }) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ts := newIPv4HTTPServer(t, router.Handler()) defer ts.Close() wsURL := wsURLForHTTP(ts.URL) + "/api/agent/ws" // Agent registers with machine-id-style agent_id that does NOT match // bound_agent_id, but hostname matches bound_hostname. Must succeed. conn, _, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL)) if err != nil { t.Fatalf("Dial: %v", err) } regMsg, err := agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{ AgentID: "f0c1b2a3e4d5f60718293a4b5c6d7e8f", Hostname: "prox97", Version: "1.0.0", Platform: "linux", Token: rawToken, }) if err != nil { conn.Close() t.Fatalf("NewMessage: %v", err) } if err := conn.WriteJSON(regMsg); err != nil { conn.Close() t.Fatalf("WriteJSON: %v", err) } reg := readRegisteredPayload(t, conn) if !reg.Success { conn.Close() t.Fatalf("expected registration to be accepted when hostname matches bound_hostname, got %q", reg.Message) } conn.Close() // Mismatched hostname AND mismatched agent_id must still be rejected — // a leaked token cannot be used from a different host claiming a different // agent_id. conn, _, err = websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL)) if err != nil { t.Fatalf("Dial: %v", err) } regMsg, err = agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{ AgentID: "attacker-id", Hostname: "attacker-host", Version: "1.0.0", Platform: "linux", Token: rawToken, }) if err != nil { conn.Close() t.Fatalf("NewMessage: %v", err) } if err := conn.WriteJSON(regMsg); err != nil { conn.Close() t.Fatalf("WriteJSON: %v", err) } reg = readRegisteredPayload(t, conn) if reg.Success { conn.Close() t.Fatalf("expected registration to be rejected when neither hostname nor agent_id match the binding") } conn.Close() } func TestSecurityTokens_AgentExecRejectsUnboundToken(t *testing.T) { rawToken := "agent-unbound-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeAgentExec}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ts := newIPv4HTTPServer(t, router.Handler()) defer ts.Close() wsURL := wsURLForHTTP(ts.URL) + "/api/agent/ws" conn, _, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL)) if err != nil { t.Fatalf("Dial: %v", err) } regMsg, err := agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{ AgentID: "agent-1", Hostname: "host-1", Version: "1.0.0", Platform: "linux", Token: rawToken, }) if err != nil { conn.Close() t.Fatalf("NewMessage: %v", err) } if err := conn.WriteJSON(regMsg); err != nil { conn.Close() t.Fatalf("WriteJSON: %v", err) } reg := readRegisteredPayload(t, conn) if reg.Success { conn.Close() t.Fatalf("expected registration to be rejected for unbound token") } conn.Close() } func TestSecurityTokens_ProxmoxInstallExecTokenBindsOnFirstCommandRegistration(t *testing.T) { rawToken := "agent-install-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeAgentExec, config.ScopeAgentReport}, map[string]string{ "install_type": "pve", "issued_via": agentInstallIssuedViaConfig, }) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ts := newIPv4HTTPServer(t, router.Handler()) defer ts.Close() wsURL := wsURLForHTTP(ts.URL) + "/api/agent/ws" conn, _, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL)) if err != nil { t.Fatalf("Dial: %v", err) } regMsg, err := agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{ AgentID: "machine-id-agent", Hostname: "delly", Version: "1.0.0", Platform: "linux", Token: rawToken, }) if err != nil { conn.Close() t.Fatalf("NewMessage: %v", err) } if err := conn.WriteJSON(regMsg); err != nil { conn.Close() t.Fatalf("WriteJSON: %v", err) } reg := readRegisteredPayload(t, conn) if !reg.Success { conn.Close() t.Fatalf("expected Pulse-minted Proxmox install token to bind on first command registration, got %q", reg.Message) } conn.Close() config.Mu.Lock() if got := cfg.APITokens[0].Metadata["bound_hostname"]; got != "delly" { config.Mu.Unlock() t.Fatalf("bound_hostname = %q, want delly", got) } if got := cfg.APITokens[0].Metadata["bound_agent_id"]; got != "machine-id-agent" { config.Mu.Unlock() t.Fatalf("bound_agent_id = %q, want machine-id-agent", got) } if got := cfg.APITokens[0].Metadata["bound_at"]; got == "" { config.Mu.Unlock() t.Fatalf("bound_at was not recorded") } config.Mu.Unlock() persisted, err := config.NewConfigPersistence(cfg.DataPath).LoadAPITokens() if err != nil { t.Fatalf("LoadAPITokens: %v", err) } if len(persisted) != 1 { t.Fatalf("persisted token count = %d, want 1", len(persisted)) } if got := persisted[0].Metadata["bound_hostname"]; got != "delly" { t.Fatalf("persisted bound_hostname = %q, want delly", got) } conn, _, err = websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL)) if err != nil { t.Fatalf("Dial attacker: %v", err) } regMsg, err = agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{ AgentID: "attacker-id", Hostname: "other-host", Version: "1.0.0", Platform: "linux", Token: rawToken, }) if err != nil { conn.Close() t.Fatalf("NewMessage attacker: %v", err) } if err := conn.WriteJSON(regMsg); err != nil { conn.Close() t.Fatalf("WriteJSON attacker: %v", err) } reg = readRegisteredPayload(t, conn) if reg.Success { conn.Close() t.Fatalf("expected first-use-bound install token to reject a different host") } conn.Close() } func TestSecurityTokens_AgentExecRequiresAgentExecScope(t *testing.T) { rawToken := "agent-scope-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeAIChat}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ts := newIPv4HTTPServer(t, router.Handler()) defer ts.Close() wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/agent/ws" conn, _, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL)) if err != nil { t.Fatalf("Dial: %v", err) } regMsg, err := agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{ AgentID: "agent-1", Hostname: "host-1", Version: "1.0.0", Platform: "linux", Token: rawToken, }) if err != nil { conn.Close() t.Fatalf("NewMessage: %v", err) } if err := conn.WriteJSON(regMsg); err != nil { conn.Close() t.Fatalf("WriteJSON: %v", err) } reg := readRegisteredPayload(t, conn) if reg.Success { conn.Close() t.Fatalf("expected registration to be rejected without agent:exec scope") } conn.Close() } func TestSecurityTokens_AgentExecRejectsLegacySingleAPIToken(t *testing.T) { rawToken := "legacy-agent-token-123.12345678" cfg := &config.Config{ DataPath: t.TempDir(), APIToken: rawToken, APITokens: nil, AllowedOrigins: "*", TemperatureMonitoringEnabled: true, } router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ts := newIPv4HTTPServer(t, router.Handler()) defer ts.Close() wsURL := wsURLForHTTP(ts.URL) + "/api/agent/ws" conn, _, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL)) if err != nil { t.Fatalf("Dial: %v", err) } regMsg, err := agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{ AgentID: "agent-legacy", Hostname: "host-legacy", Version: "1.0.0", Platform: "linux", Token: rawToken, }) if err != nil { conn.Close() t.Fatalf("NewMessage: %v", err) } if err := conn.WriteJSON(regMsg); err != nil { conn.Close() t.Fatalf("WriteJSON: %v", err) } reg := readRegisteredPayload(t, conn) if reg.Success { conn.Close() t.Fatalf("expected registration to be rejected for legacy single API token") } conn.Close() } func TestWebSocketAllowsMonitoringReadScope(t *testing.T) { rawToken := "ws-allow-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) hub := pulsews.NewHub(nil) go hub.Run() defer hub.Stop() router := NewRouter(cfg, nil, nil, hub, nil, "1.0.0") ts := newIPv4HTTPServer(t, router.Handler()) defer ts.Close() wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?org_id=default" headers := wsHeadersForHTTP(t, ts.URL) headers.Set("X-API-Token", rawToken) conn, _, err := websocket.DefaultDialer.Dial(wsURL, headers) if err != nil { t.Fatalf("Dial: %v", err) } conn.Close() } func TestWebSocketAllowsBearerToken(t *testing.T) { rawToken := "ws-bearer-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) hub := pulsews.NewHub(nil) go hub.Run() defer hub.Stop() router := NewRouter(cfg, nil, nil, hub, nil, "1.0.0") ts := newIPv4HTTPServer(t, router.Handler()) defer ts.Close() wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?org_id=default" headers := wsHeadersForHTTP(t, ts.URL) headers.Set("Authorization", "Bearer "+rawToken) conn, _, err := websocket.DefaultDialer.Dial(wsURL, headers) if err != nil { t.Fatalf("Dial: %v", err) } conn.Close() } func TestWebSocketAllowsTokenQueryParam(t *testing.T) { rawToken := "ws-query-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) hub := pulsews.NewHub(nil) go hub.Run() defer hub.Stop() router := NewRouter(cfg, nil, nil, hub, nil, "1.0.0") ts := newIPv4HTTPServer(t, router.Handler()) defer ts.Close() wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?org_id=default&token=" + rawToken conn, resp, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL)) if err != nil { t.Fatalf("Dial: %v", err) } if resp == nil || resp.StatusCode != http.StatusSwitchingProtocols { conn.Close() t.Fatalf("expected 101 switching protocols, got %v", resp) } conn.Close() } func TestWebSocketRejectsOrgQueryMismatchWithTenantContext(t *testing.T) { rawToken := "ws-org-mismatch-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) hub := pulsews.NewHub(nil) go hub.Run() defer hub.Stop() router := NewRouter(cfg, nil, nil, hub, nil, "1.0.0") ts := newIPv4HTTPServer(t, router.Handler()) defer ts.Close() wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?org_id=tenant-b&token=" + rawToken conn, resp, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL)) if err == nil { conn.Close() t.Fatalf("expected websocket org mismatch rejection") } if resp == nil { t.Fatalf("expected HTTP response for websocket org mismatch") } if resp.StatusCode != http.StatusForbidden { t.Fatalf("expected 403 for websocket org mismatch, got %d", resp.StatusCode) } } func TestWebSocketRejectsInvalidOrgQueryID(t *testing.T) { rawToken := "ws-invalid-org-query-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) hub := pulsews.NewHub(nil) go hub.Run() defer hub.Stop() router := NewRouter(cfg, nil, nil, hub, nil, "1.0.0") ts := newIPv4HTTPServer(t, router.Handler()) defer ts.Close() wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?org_id=../tenant-b&token=" + rawToken conn, resp, err := websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL)) if err == nil { conn.Close() t.Fatalf("expected websocket invalid org rejection") } if resp == nil { t.Fatalf("expected HTTP response for websocket invalid org") } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400 for websocket invalid org, got %d", resp.StatusCode) } } func TestQueryTokenIgnoredForHTTPRequests(t *testing.T) { rawToken := "query-token-ignored-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/config?token="+rawToken, nil) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 when token is only in query string, got %d", rec.Code) } } func TestLogEndpointsRequireSettingsReadScope(t *testing.T) { rawToken := "logs-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []string{ "/api/logs/stream", "/api/logs/download", } for _, path := range paths { req := httptest.NewRequest(http.MethodGet, path, nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:read scope on %s, got %d", path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String()) } } } func TestLogEndpointsRequireAuthInAPIMode(t *testing.T) { record := newTokenRecord(t, "log-auth-token-123.12345678", []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []string{ "/api/logs/stream", "/api/logs/download", } for _, path := range paths { req := httptest.NewRequest(http.MethodGet, path, nil) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 without auth on %s, got %d", path, rec.Code) } } } func TestLogLevelReadRequiresSettingsReadScope(t *testing.T) { rawToken := "log-level-read-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/logs/level", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String()) } } func TestLogLevelUpdateRequiresSettingsWriteScope(t *testing.T) { rawToken := "log-level-write-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/logs/level", strings.NewReader(`{"level":"info"}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } func TestUpdateReadEndpointsRequireSettingsReadScope(t *testing.T) { rawToken := "updates-read-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []string{ "/api/updates/check", "/api/updates/status", "/api/updates/plan", } for _, path := range paths { req := httptest.NewRequest(http.MethodGet, path, nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:read scope on %s, got %d", path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String()) } } } func TestUpdateStatusRequiresAuthInAPIMode(t *testing.T) { record := newTokenRecord(t, "update-auth-token-123.12345678", []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/updates/status", nil) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 without auth, got %d", rec.Code) } } func TestUpdateApplyRequiresSettingsWriteScope(t *testing.T) { rawToken := "updates-write-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/updates/apply", strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } func TestLicenseMutationsRequireSettingsWriteScope(t *testing.T) { rawToken := "license-write-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []string{ "/api/license/activate", "/api/license/clear", } for _, path := range paths { req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope on %s, got %d", path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } } func TestSetupScriptURLRequiresSettingsWriteScope(t *testing.T) { rawToken := "setup-script-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/setup-script-url", strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } func TestSetupScriptURLRejectsSetupTokenAuthWhenPulseAuthIsConfigured(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.AuthUser = "admin" cfg.AuthPass = "hashed" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") token := "0123456789abcdef0123456789abcdef" tokenHash := auth.HashAPIToken(token) router.configHandlers.codeMutex.Lock() router.configHandlers.setupTokens[tokenHash] = &SetupTokenRecord{ ExpiresAt: time.Now().Add(time.Minute), NodeType: "pve", } router.configHandlers.codeMutex.Unlock() req := httptest.NewRequest(http.MethodPost, "/api/setup-script-url", strings.NewReader(`{"type":"pve","host":"pve.local"}`)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Setup-Token", token) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected setup token auth on /api/setup-script-url to be rejected with 401, got %d (%s)", rec.Code, rec.Body.String()) } if body := rec.Body.String(); body != `{"error":"Authentication required"}` { t.Fatalf("body = %q, want authentication-required JSON", body) } } func TestAgentInstallCommandRequiresSettingsWriteScope(t *testing.T) { rawToken := "agent-install-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/agent-install-command", strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } func TestDiscoverRequiresSettingsWriteScope(t *testing.T) { rawToken := "discover-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []struct { name string method string body string }{ {name: "get", method: http.MethodGet, body: ""}, {name: "post", method: http.MethodPost, body: `{}`}, } for _, tc := range paths { req := httptest.NewRequest(tc.method, "/api/discover", strings.NewReader(tc.body)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope on %s, got %d", tc.name, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } } func TestAIOAuthEndpointsRequireSettingsWriteScope(t *testing.T) { rawToken := "ai-oauth-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []string{ "/api/ai/oauth/start", "/api/ai/oauth/exchange", "/api/ai/oauth/disconnect", } for _, path := range paths { req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope on %s, got %d", path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } } func TestAIExecuteEndpointsRequireAIExecuteScope(t *testing.T) { rawToken := "ai-exec-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeAIChat}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []string{ "/api/ai/execute", "/api/ai/execute/stream", "/api/actions/plan", "/api/actions/act_test/decision", "/api/actions/act_test/execute", } for _, path := range paths { req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing ai:execute scope on %s, got %d", path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeAIExecute) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAIExecute, rec.Body.String()) } } } func TestAIRemediationMutationsRequireAIExecuteScope(t *testing.T) { rawToken := "ai-remediate-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeAIChat}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []string{ "/api/ai/remediation/execute", "/api/ai/remediation/rollback", } for _, path := range paths { req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing ai:execute scope on %s, got %d", path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeAIExecute) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAIExecute, rec.Body.String()) } } } func TestAIAgentsRequiresAIExecuteScope(t *testing.T) { rawToken := "ai-agents-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeAIChat}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/ai/agents", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing ai:execute scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeAIExecute) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAIExecute, rec.Body.String()) } } func TestAICostEndpointsRequireSettingsScopes(t *testing.T) { rawToken := "ai-cost-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") // Summary requires settings:read req := httptest.NewRequest(http.MethodGet, "/api/ai/cost/summary", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String()) } // Reset requires settings:write req = httptest.NewRequest(http.MethodPost, "/api/ai/cost/reset", strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec = httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } // Export requires settings:read req = httptest.NewRequest(http.MethodGet, "/api/ai/cost/export", nil) req.Header.Set("X-API-Token", rawToken) rec = httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String()) } } func TestAIDebugContextRequiresSettingsReadScope(t *testing.T) { rawToken := "ai-debug-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/ai/debug/context", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String()) } } func TestAIRunCommandRequiresAIExecuteScope(t *testing.T) { rawToken := "ai-run-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeAIChat}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/ai/run-command", strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing ai:execute scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeAIExecute) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAIExecute, rec.Body.String()) } } func TestAIPatrolRunRequiresAIExecuteScope(t *testing.T) { rawToken := "ai-patrol-run-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeAIChat}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/ai/patrol/run", strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing ai:execute scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeAIExecute) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAIExecute, rec.Body.String()) } } func TestAIPatrolAutonomyRequiresSettingsWriteScope(t *testing.T) { rawToken := "ai-patrol-autonomy-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/autonomy", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } func TestAIPatrolAutonomyUpdateRequiresSettingsWriteScope(t *testing.T) { rawToken := "ai-patrol-autonomy-update-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPut, "/api/ai/patrol/autonomy", strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } func TestAIExecuteReadEndpointsRequireAIExecuteScope(t *testing.T) { rawToken := "ai-exec-read-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeAIChat}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []string{ "/api/ai/patrol/status", "/api/ai/patrol/stream", "/api/ai/patrol/findings", "/api/ai/patrol/history", "/api/ai/patrol/runs", "/api/ai/patrol/runs/run-1", "/api/ai/patrol/dismissed", "/api/ai/patrol/suppressions", "/api/ai/approvals", "/api/ai/approvals/approval-1", "/api/ai/intelligence", "/api/ai/intelligence/patterns", "/api/ai/intelligence/predictions", "/api/ai/intelligence/correlations", "/api/ai/intelligence/changes", "/api/ai/intelligence/baselines", "/api/ai/intelligence/remediations", "/api/ai/intelligence/anomalies", "/api/ai/intelligence/learning", "/api/ai/unified/findings", "/api/ai/forecast", "/api/ai/forecasts/overview", "/api/ai/learning/preferences", "/api/ai/proxmox/events", "/api/ai/proxmox/correlations", "/api/ai/remediation/plans", "/api/ai/remediation/plan", "/api/ai/circuit/status", "/api/ai/incidents", "/api/ai/incidents/incident-1", } for _, path := range paths { req := httptest.NewRequest(http.MethodGet, path, nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing ai:execute scope on %s, got %d", path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeAIExecute) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAIExecute, rec.Body.String()) } } } func TestRelayMobileAccessScopeAllowsGovernedMobileRuntimeEndpoints(t *testing.T) { rawToken := "relay-mobile-runtime-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeRelayMobileAccess}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []struct { method string path string body string }{ {method: http.MethodGet, path: "/api/onboarding/qr", body: ""}, {method: http.MethodPost, path: "/api/onboarding/validate", body: `{}`}, {method: http.MethodGet, path: "/api/onboarding/deep-link", body: ""}, {method: http.MethodGet, path: "/api/ai/patrol/findings", body: ""}, {method: http.MethodGet, path: "/api/ai/findings/finding-1/investigation", body: ""}, {method: http.MethodGet, path: "/api/ai/findings/finding-1/investigation/messages", body: ""}, {method: http.MethodPost, path: "/api/ai/patrol/acknowledge", body: `{}`}, {method: http.MethodPost, path: "/api/ai/patrol/dismiss", body: `{}`}, {method: http.MethodPost, path: "/api/ai/patrol/snooze", body: `{}`}, {method: http.MethodGet, path: "/api/ai/approvals", body: ""}, {method: http.MethodPost, path: "/api/ai/approvals/approval-1/approve", body: `{}`}, {method: http.MethodPost, path: "/api/ai/approvals/approval-1/deny", body: `{}`}, {method: http.MethodPost, path: "/api/ai/chat", body: `{}`}, {method: http.MethodGet, path: "/api/ai/sessions", body: ""}, {method: http.MethodGet, path: "/api/ai/sessions/session-1/messages", body: ""}, {method: http.MethodPost, path: "/api/ai/sessions/session-1/abort", body: `{}`}, {method: http.MethodPatch, path: "/api/ai/sessions/session-1", body: `{"title":"Renamed session"}`}, {method: http.MethodDelete, path: "/api/ai/sessions/session-1", body: ""}, } for _, tc := range paths { req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code == http.StatusForbidden && strings.Contains(rec.Body.String(), "missing_scope") { t.Fatalf("expected relay mobile scope to pass scope gating on %s %s, got %d with body %q", tc.method, tc.path, rec.Code, rec.Body.String()) } } } func TestRelayMobileApprovalsListReturnsEmptyBeforeAIChatConfigured(t *testing.T) { rawToken := "relay-mobile-approvals-empty-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeRelayMobileAccess}, nil) cfg := newTestConfigWithTokens(t, record) previousStore := approval.GetStore() approval.SetStore(nil) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") t.Cleanup(func() { router.shutdownBackgroundWorkers() approval.SetStore(previousStore) }) req := httptest.NewRequest(http.MethodGet, "/api/ai/approvals", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("approvals list status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String()) } var response struct { Approvals []approval.ApprovalRequest `json:"approvals"` Stats map[string]int `json:"stats"` } if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { t.Fatalf("decode approvals list response: %v", err) } if response.Approvals == nil { t.Fatal("approvals must be an empty array, not null") } if len(response.Approvals) != 0 { t.Fatalf("approvals length = %d, want 0", len(response.Approvals)) } if response.Stats["pending"] != 0 || response.Stats["approved"] != 0 || response.Stats["denied"] != 0 || response.Stats["expired"] != 0 || response.Stats["executions"] != 0 { t.Fatalf("expected zero approval stats, got %#v", response.Stats) } } func TestRelayMobileAccessScopeDeniesAdjacentAIRoutes(t *testing.T) { rawToken := "relay-mobile-adjacent-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeRelayMobileAccess}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []struct { method string path string body string want string }{ {method: http.MethodGet, path: "/api/ai/models", body: "", want: config.ScopeAIChat}, {method: http.MethodGet, path: "/api/ai/status", body: "", want: config.ScopeAIChat}, {method: http.MethodGet, path: "/api/ai/assistant/surface-tools", body: "", want: config.ScopeAIChat}, {method: http.MethodPost, path: "/api/ai/workflow-prompts/render", body: `{}`, want: config.ScopeAIChat}, {method: http.MethodPost, path: "/api/ai/workflow-prompts/activity", body: `{}`, want: config.ScopeAIChat}, {method: http.MethodGet, path: "/api/ai/agents", body: "", want: config.ScopeAIExecute}, {method: http.MethodGet, path: "/api/ai/approvals/approval-1", body: "", want: config.ScopeAIExecute}, {method: http.MethodDelete, path: "/api/ai/patrol/findings", body: "", want: config.ScopeAIExecute}, {method: http.MethodPost, path: "/api/ai/findings/finding-1/reinvestigate", body: `{}`, want: config.ScopeAIExecute}, {method: http.MethodPost, path: "/api/ai/findings/finding-1/reapprove", body: `{}`, want: config.ScopeAIExecute}, {method: http.MethodGet, path: "/api/ai/sessions/session-1", body: "", want: config.ScopeAIChat}, {method: http.MethodPost, path: "/api/ai/sessions/session-1/summarize", body: `{}`, want: config.ScopeAIChat}, {method: http.MethodGet, path: "/api/ai/sessions/session-1/diff", body: "", want: config.ScopeAIChat}, {method: http.MethodPost, path: "/api/ai/sessions/session-1/fork", body: `{}`, want: config.ScopeAIChat}, } for _, tc := range paths { req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for relay mobile token on %s %s, got %d", tc.method, tc.path, rec.Code) } if !strings.Contains(rec.Body.String(), tc.want) { t.Fatalf("expected missing scope response to mention %q for %s %s, got %q", tc.want, tc.method, tc.path, rec.Body.String()) } } } func TestAIExecuteMutationEndpointsRequireAIExecuteScope(t *testing.T) { rawToken := "ai-exec-mutate-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeAIChat}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []struct { method string path string body string }{ {method: http.MethodPost, path: "/api/ai/patrol/acknowledge", body: `{}`}, {method: http.MethodPost, path: "/api/ai/patrol/dismiss", body: `{}`}, {method: http.MethodPost, path: "/api/ai/patrol/findings/note", body: `{}`}, {method: http.MethodPost, path: "/api/ai/patrol/suppress", body: `{}`}, {method: http.MethodPost, path: "/api/ai/patrol/snooze", body: `{}`}, {method: http.MethodPost, path: "/api/ai/patrol/resolve", body: `{}`}, {method: http.MethodPost, path: "/api/ai/patrol/suppressions", body: `{}`}, {method: http.MethodDelete, path: "/api/ai/patrol/suppressions/rule-1", body: ""}, {method: http.MethodPost, path: "/api/ai/remediation/approve", body: `{}`}, {method: http.MethodPost, path: "/api/ai/findings/f-1/reapprove", body: `{}`}, {method: http.MethodPost, path: "/api/ai/approvals/approval-1/approve", body: `{}`}, {method: http.MethodPost, path: "/api/ai/approvals/approval-1/deny", body: `{}`}, } for _, tc := range paths { req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing ai:execute scope on %s %s, got %d", tc.method, tc.path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeAIExecute) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAIExecute, rec.Body.String()) } } } func TestInfraUpdateReadEndpointsRequireMonitoringReadScope(t *testing.T) { rawToken := "infra-read-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []string{ "/api/infra-updates", "/api/infra-updates/summary", "/api/infra-updates/agent/host-1", "/api/infra-updates/docker:host-1/c1", } for _, path := range paths { req := httptest.NewRequest(http.MethodGet, path, nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing monitoring:read scope on %s, got %d", path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeMonitoringRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringRead, rec.Body.String()) } } } func TestInfraUpdateCheckRequiresMonitoringWriteScope(t *testing.T) { rawToken := "infra-write-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/infra-updates/check", strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing monitoring:write scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeMonitoringWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringWrite, rec.Body.String()) } } func TestAlertReadEndpointsRequireMonitoringReadScope(t *testing.T) { rawToken := "alerts-read-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []string{ "/api/alerts/config", "/api/alerts/active", "/api/alerts/history", "/api/alerts/incidents", } for _, path := range paths { req := httptest.NewRequest(http.MethodGet, path, nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing monitoring:read scope on %s, got %d", path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeMonitoringRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringRead, rec.Body.String()) } } } func TestAlertMutationEndpointsRequireMonitoringWriteScope(t *testing.T) { rawToken := "alerts-write-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []struct { method string path string body string }{ {method: http.MethodPut, path: "/api/alerts/config", body: `{}`}, {method: http.MethodPost, path: "/api/alerts/activate", body: `{}`}, {method: http.MethodDelete, path: "/api/alerts/history", body: ""}, {method: http.MethodPost, path: "/api/alerts/bulk/acknowledge", body: `{}`}, {method: http.MethodPost, path: "/api/alerts/bulk/clear", body: `{}`}, {method: http.MethodPost, path: "/api/alerts/acknowledge", body: `{}`}, {method: http.MethodPost, path: "/api/alerts/unacknowledge", body: `{}`}, {method: http.MethodPost, path: "/api/alerts/clear", body: `{}`}, {method: http.MethodPost, path: "/api/alerts/incidents/note", body: `{}`}, } for _, tc := range paths { req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing monitoring:write scope on %s %s, got %d", tc.method, tc.path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeMonitoringWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringWrite, rec.Body.String()) } } } func TestAlertMutationEndpointsAllowMonitoringWriteWithoutReadScope(t *testing.T) { rawToken := "alerts-write-only-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringWrite}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/alerts/acknowledge", strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code == http.StatusForbidden { t.Fatalf("expected write-only token to reach alert mutation handler, got 403 body=%q", rec.Body.String()) } if rec.Code != http.StatusBadRequest { t.Fatalf("expected validation failure (400) after passing scope checks, got %d body=%q", rec.Code, rec.Body.String()) } readReq := httptest.NewRequest(http.MethodGet, "/api/alerts/config", nil) readReq.Header.Set("X-API-Token", rawToken) readRec := httptest.NewRecorder() router.Handler().ServeHTTP(readRec, readReq) if readRec.Code != http.StatusForbidden { t.Fatalf("expected 403 for read endpoint with write-only token, got %d", readRec.Code) } if !strings.Contains(readRec.Body.String(), config.ScopeMonitoringRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringRead, readRec.Body.String()) } } func TestNotificationQueueStatsRequireSettingsReadScope(t *testing.T) { rawToken := "queue-stats-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/notifications/queue/stats", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String()) } } func TestConfigSystemRequiresSettingsReadScope(t *testing.T) { rawToken := "config-system-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/config/system", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String()) } } func TestSystemSettingsRequiresSettingsReadScope(t *testing.T) { rawToken := "system-settings-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/system/settings", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String()) } } func TestSystemSettingsUpdateRequiresSettingsWriteScope(t *testing.T) { rawToken := "system-settings-write-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/system/settings/update", strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } func TestSystemSettingsUpdatePreservesAPIOnlyAuthError(t *testing.T) { rawToken := "system-settings-api-only-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsWrite}, nil) cfg := newTestConfigWithTokens(t, record) handler := NewSystemSettingsHandler( cfg, config.NewConfigPersistence(cfg.DataPath), nil, nil, nil, func() {}, func() error { return nil }, ) req := httptest.NewRequest(http.MethodPost, "/api/system/settings/update", strings.NewReader(`{}`)) rec := httptest.NewRecorder() handler.HandleUpdateSystemSettings(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 for missing API token, got %d", rec.Code) } body := rec.Body.String() if !strings.Contains(body, "API token required") { t.Fatalf("expected API-token-specific auth response, got %q", body) } if strings.Contains(body, `"unauthorized"`) { t.Fatalf("expected route to preserve API-token-specific auth response, got generic body %q", body) } } func TestSystemSettingsReportBrandingValidationRejectsUnsafePayload(t *testing.T) { tests := []struct { name string raw map[string]interface{} want string }{ { name: "non_object", raw: map[string]interface{}{"reportBranding": "Client"}, want: "reportBranding must be an object", }, { name: "unsupported_key", raw: map[string]interface{}{"reportBranding": map[string]interface{}{ "displayName": "Client", "tenantID": "other-client", }}, want: "reportBranding.tenantID is not supported", }, { name: "workspace_logo_path_unsupported", raw: map[string]interface{}{"reportBranding": map[string]interface{}{ "logoPath": "/etc/pulse/secrets/handoff.key", }}, want: "reportBranding.logoPath is not supported", }, { name: "newline", raw: map[string]interface{}{"reportBranding": map[string]interface{}{ "displayName": "Client\nName", }}, want: "reportBranding.displayName must not contain newlines", }, { name: "invalid_logo_base64", raw: map[string]interface{}{"reportBranding": map[string]interface{}{ "logoBase64": "not base64!", }}, want: "reportBranding.logoBase64 must be valid base64", }, { name: "invalid_logo_format", raw: map[string]interface{}{"reportBranding": map[string]interface{}{ "logoFormat": "svg", }}, want: "reportBranding.logoFormat must be png, jpg, jpeg, gif, or empty", }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { err := validateSystemSettings(nil, tt.raw) if err == nil { t.Fatalf("expected validation error containing %q", tt.want) } if !strings.Contains(err.Error(), tt.want) { t.Fatalf("unexpected validation error: %v", err) } }) } } func TestMockModeReadRequiresSettingsReadScope(t *testing.T) { rawToken := "mock-mode-read-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/system/mock-mode", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String()) } } func TestMockModeWriteRequiresSettingsWriteScope(t *testing.T) { rawToken := "mock-mode-write-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/system/mock-mode", strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } func TestConfigNodesReadRequiresSettingsReadScope(t *testing.T) { rawToken := "config-nodes-read-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/config/nodes", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String()) } } func TestConfigNodesWriteRequiresSettingsWriteScope(t *testing.T) { rawToken := "config-nodes-write-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/config/nodes", strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } func TestConfigNodeMutationsRequireSettingsWriteScope(t *testing.T) { rawToken := "config-node-mutate-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []struct { method string path string body string }{ {method: http.MethodPost, path: "/api/config/nodes/test-config", body: `{}`}, {method: http.MethodPost, path: "/api/config/nodes/test-connection", body: `{}`}, {method: http.MethodPut, path: "/api/config/nodes/node-1", body: `{}`}, {method: http.MethodDelete, path: "/api/config/nodes/node-1", body: ""}, {method: http.MethodPost, path: "/api/config/nodes/node-1/test", body: `{}`}, {method: http.MethodPost, path: "/api/config/nodes/node-1/refresh-cluster", body: `{}`}, } for _, tc := range paths { req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope on %s %s, got %d", tc.method, tc.path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } } func TestTrueNASConnectionMutationsRequireSettingsWriteScope(t *testing.T) { rawToken := "truenas-mutate-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []struct { method string path string body string }{ {method: http.MethodPost, path: "/api/truenas/connections", body: `{}`}, {method: http.MethodPost, path: "/api/truenas/connections/preview", body: `{}`}, {method: http.MethodPost, path: "/api/truenas/connections/test", body: `{}`}, {method: http.MethodPut, path: "/api/truenas/connections/conn-1", body: `{}`}, {method: http.MethodDelete, path: "/api/truenas/connections/conn-1", body: ""}, {method: http.MethodPost, path: "/api/truenas/connections/conn-1/preview", body: ``}, {method: http.MethodPost, path: "/api/truenas/connections/conn-1/test", body: ""}, } for _, tc := range paths { req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope on %s %s, got %d", tc.method, tc.path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } } func TestVMwareConnectionMutationsRequireSettingsWriteScope(t *testing.T) { rawToken := "vmware-mutate-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []struct { method string path string body string }{ {method: http.MethodPost, path: "/api/vmware/connections", body: `{}`}, {method: http.MethodPost, path: "/api/vmware/connections/preview", body: `{}`}, {method: http.MethodPost, path: "/api/vmware/connections/test", body: `{}`}, {method: http.MethodPut, path: "/api/vmware/connections/conn-1", body: `{}`}, {method: http.MethodDelete, path: "/api/vmware/connections/conn-1", body: ""}, {method: http.MethodPost, path: "/api/vmware/connections/conn-1/preview", body: ``}, {method: http.MethodPost, path: "/api/vmware/connections/conn-1/test", body: ""}, } for _, tc := range paths { req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope on %s %s, got %d", tc.method, tc.path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } } func TestConfigExportRequiresSettingsReadScope(t *testing.T) { rawToken := "config-export-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/config/export", strings.NewReader(`{}`)) req.RemoteAddr = "127.0.0.1:1234" req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String()) } } func TestConfigImportRequiresSettingsWriteScope(t *testing.T) { rawToken := "config-import-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/config/import", strings.NewReader(`{}`)) req.RemoteAddr = "127.0.0.1:1234" req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } func TestConfigExportRejectsShortPassphrase(t *testing.T) { rawToken := "config-export-pass-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/config/export", strings.NewReader(`{"passphrase":"short"}`)) req.RemoteAddr = "127.0.0.1:1234" req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400 for short passphrase, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "Passphrase must be at least 12 characters") { t.Fatalf("expected passphrase length error, got %q", rec.Body.String()) } } func TestConfigExportRequiresPassphrase(t *testing.T) { rawToken := "config-export-missing-pass-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/config/export", strings.NewReader(`{"passphrase":""}`)) req.RemoteAddr = "127.0.0.1:1234" req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400 for missing passphrase, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "Passphrase is required") { t.Fatalf("expected passphrase required error, got %q", rec.Body.String()) } } func TestConfigImportRejectsMissingData(t *testing.T) { rawToken := "config-import-data-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsWrite}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/config/import", strings.NewReader(`{"passphrase":"long-enough-passphrase","data":""}`)) req.RemoteAddr = "127.0.0.1:1234" req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400 for missing data, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "Import data is required") { t.Fatalf("expected import data error, got %q", rec.Body.String()) } } func TestConfigImportRequiresPassphrase(t *testing.T) { rawToken := "config-import-pass-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsWrite}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/config/import", strings.NewReader(`{"passphrase":"","data":"encrypted"}`)) req.RemoteAddr = "127.0.0.1:1234" req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400 for missing passphrase, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "Passphrase is required") { t.Fatalf("expected passphrase required error, got %q", rec.Body.String()) } } func TestConfigExportRequiresAuthInAPIMode(t *testing.T) { record := newTokenRecord(t, "config-export-auth-token", []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/config/export", strings.NewReader(`{}`)) req.RemoteAddr = "127.0.0.1:1234" rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 without auth, got %d", rec.Code) } } func TestConfigImportRequiresAuthInAPIMode(t *testing.T) { record := newTokenRecord(t, "config-import-auth-token", []string{config.ScopeSettingsWrite}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/config/import", strings.NewReader(`{}`)) req.RemoteAddr = "127.0.0.1:1234" rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 without auth, got %d", rec.Code) } } func TestConfigExportImportRejectsNonAdminSession(t *testing.T) { hashed, err := auth.HashPassword("Password!1") if err != nil { t.Fatalf("HashPassword: %v", err) } cfg := &config.Config{ DataPath: t.TempDir(), ConfigPath: t.TempDir(), AuthUser: "admin", AuthPass: hashed, } router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") sessionToken := "config-member-session-" + strconv.FormatInt(time.Now().UnixNano(), 10) GetSessionStore().CreateSession(sessionToken, time.Hour, "agent", "127.0.0.1", "member") csrfToken := generateCSRFToken(sessionToken) tests := []struct { name string path string body string }{ { name: "export", path: "/api/config/export", body: `{"passphrase":"long-enough-passphrase"}`, }, { name: "import", path: "/api/config/import", body: `{"passphrase":"long-enough-passphrase","data":"invalid"}`, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.body)) req.RemoteAddr = "127.0.0.1:1234" req.Header.Set("Content-Type", "application/json") req.Header.Set("X-CSRF-Token", csrfToken) req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken}) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for non-admin session on %s, got %d: %s", tc.path, rec.Code, rec.Body.String()) } if !strings.Contains(rec.Body.String(), "Admin privileges required") { t.Fatalf("expected admin privileges error on %s, got %q", tc.path, rec.Body.String()) } }) } } func TestConfigExportBlocksPublicNetworkWithoutAuth(t *testing.T) { cfg := &config.Config{ DataPath: t.TempDir(), ConfigPath: t.TempDir(), } router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/config/export", strings.NewReader(`{}`)) req.RemoteAddr = "203.0.113.10:1234" ResetRateLimitForIP("203.0.113.10") rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for public network without auth, got %d", rec.Code) } } func TestConfigImportBlocksPublicNetworkWithoutAuth(t *testing.T) { cfg := &config.Config{ DataPath: t.TempDir(), ConfigPath: t.TempDir(), } router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/config/import", strings.NewReader(`{}`)) req.RemoteAddr = "203.0.113.11:1234" ResetRateLimitForIP("203.0.113.11") rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for public network without auth, got %d", rec.Code) } } func TestAutoRegisterRequiresAuth(t *testing.T) { cfg := newTestConfigWithTokens(t) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") router.configHandlers.SetConfig(cfg) req := httptest.NewRequest(http.MethodPost, "/api/auto-register", strings.NewReader(`{}`)) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 without auth, got %d", rec.Code) } } func TestAutoRegisterRejectsAPITokenWithoutSetupToken(t *testing.T) { rawToken := "auto-register-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") router.configHandlers.SetConfig(cfg) req := httptest.NewRequest(http.MethodPost, "/api/auto-register", strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 when API token is provided without a Pulse setup token, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "Pulse setup token required") { t.Fatalf("expected setup-token requirement guidance, got %q", rec.Body.String()) } } func TestAutoRegisterRejectsAgentTokenWithoutSetupToken(t *testing.T) { stubAutoRegisterNetworkDeps(t) rawToken := "agent-register-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeAgentReport}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") router.configHandlers.SetConfig(cfg) // Agent-report tokens can authenticate auto-register but are restricted to // updating existing nodes. Attempting to register a brand-new node must // return 403, not succeed. body := `{"type":"pve","host":"https://192.168.1.1:8006","tokenId":"pulse-monitor@pve!pulse-192-168-1-1","tokenValue":"secret","source":"agent","serverName":"newhost"}` req := httptest.NewRequest(http.MethodPost, "/api/auto-register", strings.NewReader(body)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 when agent API token is used to register a new node, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "agent token auth permits token updates for existing nodes only") { t.Fatalf("expected agent-token restriction message, got %q", rec.Body.String()) } } func TestConfigExportRequiresProxyAdmin(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.ProxyAuthSecret = "proxy-secret" cfg.ProxyAuthUserHeader = "X-Remote-User" cfg.ProxyAuthRoleHeader = "X-Remote-Roles" cfg.ProxyAuthAdminRole = "admin" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/config/export", strings.NewReader(`{}`)) req.RemoteAddr = "127.0.0.1:1234" req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret) req.Header.Set("X-Remote-User", "viewer-user") req.Header.Set("X-Remote-Roles", "viewer") rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for non-admin proxy user, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "Admin privileges required") { t.Fatalf("expected admin privilege error, got %q", rec.Body.String()) } } func TestConfigImportRequiresProxyAdmin(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.ProxyAuthSecret = "proxy-secret" cfg.ProxyAuthUserHeader = "X-Remote-User" cfg.ProxyAuthRoleHeader = "X-Remote-Roles" cfg.ProxyAuthAdminRole = "admin" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/config/import", strings.NewReader(`{}`)) req.RemoteAddr = "127.0.0.1:1234" req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret) req.Header.Set("X-Remote-User", "viewer-user") req.Header.Set("X-Remote-Roles", "viewer") rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for non-admin proxy user, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "Admin privileges required") { t.Fatalf("expected admin privilege error, got %q", rec.Body.String()) } } func TestDiscoveryReadEndpointsRequireMonitoringReadScope(t *testing.T) { rawToken := "discovery-read-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []string{ "/api/discovery", "/api/discovery/status", "/api/discovery/info/host-1", "/api/discovery/type/pve", "/api/discovery/agent/host-1", "/api/discovery/agent/host-1/resource-1", "/api/discovery/agent/host-1/resource-1/progress", "/api/discovery/agent/host-1", "/api/discovery/agent/host-1/resource-1", "/api/discovery/agent/host-1/resource-1/progress", "/api/discovery/resource-1", "/api/discovery/resource-1/progress", } for _, path := range paths { req := httptest.NewRequest(http.MethodGet, path, nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing monitoring:read scope on %s, got %d", path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeMonitoringRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringRead, rec.Body.String()) } } } func TestDiscoveryMutationEndpointsRequireWriteScopes(t *testing.T) { rawToken := "discovery-write-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []struct { method string path string body string requiredScope string }{ {method: http.MethodPost, path: "/api/discovery/run", body: `{}`, requiredScope: config.ScopeSettingsWrite}, {method: http.MethodPost, path: "/api/discovery/agent/host-1/resource-1", body: `{}`, requiredScope: config.ScopeSettingsWrite}, {method: http.MethodPut, path: "/api/discovery/agent/host-1/resource-1/notes", body: `{}`, requiredScope: config.ScopeMonitoringWrite}, {method: http.MethodDelete, path: "/api/discovery/agent/host-1/resource-1", body: "", requiredScope: config.ScopeMonitoringWrite}, {method: http.MethodPost, path: "/api/discovery/agent/host-1/resource-1", body: `{}`, requiredScope: config.ScopeSettingsWrite}, {method: http.MethodPut, path: "/api/discovery/agent/host-1/resource-1/notes", body: `{}`, requiredScope: config.ScopeMonitoringWrite}, {method: http.MethodDelete, path: "/api/discovery/agent/host-1/resource-1", body: "", requiredScope: config.ScopeMonitoringWrite}, {method: http.MethodPost, path: "/api/discovery/resource-1", body: `{}`, requiredScope: config.ScopeSettingsWrite}, {method: http.MethodPut, path: "/api/discovery/resource-1/notes", body: `{}`, requiredScope: config.ScopeMonitoringWrite}, {method: http.MethodDelete, path: "/api/discovery/resource-1", body: "", requiredScope: config.ScopeMonitoringWrite}, } for _, tc := range paths { req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing %s scope on %s %s, got %d", tc.requiredScope, tc.method, tc.path, rec.Code) } if !strings.Contains(rec.Body.String(), tc.requiredScope) { t.Fatalf("expected missing scope response to mention %q, got %q", tc.requiredScope, rec.Body.String()) } } } func TestDiscoverySettingsRequiresSettingsWriteScope(t *testing.T) { rawToken := "discovery-settings-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/discovery/settings", strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } func TestDiscoverySettingsRejectsProxyNonAdmin(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.ProxyAuthSecret = "proxy-secret" cfg.ProxyAuthUserHeader = "X-Remote-User" cfg.ProxyAuthRoleHeader = "X-Remote-Roles" cfg.ProxyAuthAdminRole = "admin" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") service := servicediscovery.NewService(nil, nil, servicediscovery.DefaultConfig()) router.SetDiscoveryService(service) req := httptest.NewRequest(http.MethodPost, "/api/discovery/settings", strings.NewReader(`{"max_discovery_age_days":5}`)) req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret) req.Header.Set("X-Remote-User", "viewer-user") req.Header.Set("X-Remote-Roles", "viewer") rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for non-admin proxy discovery settings, got %d", rec.Code) } } func TestDiscoveryNotesRejectsProxyUserSecrets(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.ProxyAuthSecret = "proxy-secret" cfg.ProxyAuthUserHeader = "X-Remote-User" cfg.ProxyAuthRoleHeader = "X-Remote-Roles" cfg.ProxyAuthAdminRole = "admin" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") service := servicediscovery.NewService(nil, nil, servicediscovery.DefaultConfig()) router.SetDiscoveryService(service) payload := `{"user_notes":"note","user_secrets":{"token":"abc"}}` req := httptest.NewRequest(http.MethodPut, "/api/discovery/agent/host-1/resource-1/notes", strings.NewReader(payload)) req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret) req.Header.Set("X-Remote-User", "viewer-user") req.Header.Set("X-Remote-Roles", "viewer") rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for non-admin proxy discovery secrets, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "user_secrets") { t.Fatalf("expected user_secrets error, got %q", rec.Body.String()) } } func TestNotificationsRequireProxyAdmin(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.ProxyAuthSecret = "proxy-secret" cfg.ProxyAuthUserHeader = "X-Remote-User" cfg.ProxyAuthRoleHeader = "X-Remote-Roles" cfg.ProxyAuthAdminRole = "admin" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/notifications/queue/stats", nil) req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret) req.Header.Set("X-Remote-User", "viewer-user") req.Header.Set("X-Remote-Roles", "viewer") rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for non-admin proxy user, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "Admin privileges required") { t.Fatalf("expected admin privilege error, got %q", rec.Body.String()) } } func TestProxyAuthNonAdminDeniedAdminEndpoints(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.ProxyAuthSecret = "proxy-secret" cfg.ProxyAuthUserHeader = "X-Remote-User" cfg.ProxyAuthRoleHeader = "X-Remote-Roles" cfg.ProxyAuthAdminRole = "admin" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") router.aiSettingsHandler.defaultConfig = cfg cases := []struct { method string path string body string }{ {method: http.MethodGet, path: "/api/logs/stream", body: ""}, {method: http.MethodGet, path: "/api/logs/download", body: ""}, {method: http.MethodGet, path: "/api/logs/level", body: ""}, {method: http.MethodPost, path: "/api/logs/level", body: `{}`}, {method: http.MethodGet, path: "/api/updates/check", body: ""}, {method: http.MethodPost, path: "/api/updates/apply", body: `{}`}, {method: http.MethodGet, path: "/api/updates/status", body: ""}, {method: http.MethodGet, path: "/api/updates/stream", body: ""}, {method: http.MethodGet, path: "/api/updates/plan", body: ""}, {method: http.MethodGet, path: "/api/updates/history", body: ""}, {method: http.MethodGet, path: "/api/updates/history/entry", body: ""}, {method: http.MethodGet, path: "/api/diagnostics", body: ""}, {method: http.MethodPost, path: "/api/diagnostics/docker/prepare-token", body: `{}`}, {method: http.MethodGet, path: "/api/config/system", body: ""}, {method: http.MethodPost, path: "/api/config/export", body: `{}`}, {method: http.MethodPost, path: "/api/config/import", body: `{}`}, {method: http.MethodGet, path: "/api/config/nodes", body: ""}, {method: http.MethodPost, path: "/api/config/nodes", body: `{}`}, {method: http.MethodPost, path: "/api/config/nodes/test-config", body: `{}`}, {method: http.MethodPost, path: "/api/config/nodes/test-connection", body: `{}`}, {method: http.MethodPut, path: "/api/config/nodes/node-1", body: `{}`}, {method: http.MethodDelete, path: "/api/config/nodes/node-1", body: ``}, {method: http.MethodPost, path: "/api/config/nodes/node-1/test", body: `{}`}, {method: http.MethodPost, path: "/api/config/nodes/node-1/refresh-cluster", body: `{}`}, {method: http.MethodGet, path: "/api/system/settings", body: ""}, {method: http.MethodPost, path: "/api/system/settings/update", body: `{}`}, {method: http.MethodPost, path: "/api/security/reset-lockout", body: `{}`}, {method: http.MethodPost, path: "/api/security/apply-restart", body: `{}`}, {method: http.MethodPost, path: "/api/security/tokens/relay-mobile", body: `{}`}, {method: http.MethodGet, path: "/api/security/tokens", body: ``}, {method: http.MethodDelete, path: "/api/security/tokens/token-1", body: ``}, {method: http.MethodPost, path: "/api/security/regenerate-token", body: `{}`}, {method: http.MethodPost, path: "/api/security/validate-token", body: `{"token":"abc"}`}, {method: http.MethodPost, path: "/api/system/verify-temperature-ssh", body: `{}`}, {method: http.MethodPost, path: "/api/system/ssh-config", body: `{}`}, {method: http.MethodGet, path: "/api/audit", body: ""}, {method: http.MethodGet, path: "/api/admin/roles", body: ""}, {method: http.MethodGet, path: "/api/admin/users", body: ""}, {method: http.MethodGet, path: "/api/admin/reports/generate", body: ""}, {method: http.MethodGet, path: "/api/admin/webhooks/audit", body: ""}, {method: http.MethodGet, path: "/api/settings/ai", body: ""}, {method: http.MethodGet, path: "/api/ai/debug/context", body: ""}, {method: http.MethodPost, path: "/api/ai/execute", body: `{}`}, {method: http.MethodPost, path: "/api/ai/execute/stream", body: `{}`}, {method: http.MethodPost, path: "/api/ai/kubernetes/analyze", body: `{}`}, {method: http.MethodPost, path: "/api/ai/investigate-alert", body: `{}`}, {method: http.MethodPost, path: "/api/ai/run-command", body: `{}`}, {method: http.MethodPost, path: "/api/ai/remediation/execute", body: `{}`}, {method: http.MethodPost, path: "/api/ai/remediation/rollback", body: `{}`}, {method: http.MethodPost, path: "/api/ai/patrol/run", body: `{}`}, {method: http.MethodGet, path: "/api/ai/patrol/autonomy", body: ""}, {method: http.MethodPost, path: "/api/ai/patrol/autonomy/acknowledgements", body: `{}`}, {method: http.MethodDelete, path: "/api/ai/patrol/autonomy/acknowledgements/ack-test", body: `{}`}, {method: http.MethodPost, path: "/api/ai/cost/reset", body: `{}`}, {method: http.MethodGet, path: "/api/ai/cost/export", body: ""}, {method: http.MethodPost, path: "/api/ai/oauth/start", body: `{}`}, {method: http.MethodPost, path: "/api/ai/oauth/exchange", body: `{}`}, {method: http.MethodPost, path: "/api/ai/oauth/disconnect", body: `{}`}, {method: http.MethodPost, path: "/api/ai/test", body: `{}`}, {method: http.MethodPost, path: "/api/ai/test/openai", body: `{}`}, {method: http.MethodPost, path: "/api/agents/docker/containers/update", body: `{}`}, {method: http.MethodPost, path: "/api/agents/docker/runtimes/host-1/update-all", body: ``}, {method: http.MethodDelete, path: "/api/agents/docker/runtimes/host-1", body: ``}, {method: http.MethodDelete, path: "/api/agents/kubernetes/clusters/cluster-1", body: ``}, {method: http.MethodPost, path: "/api/agents/agent/link", body: `{}`}, {method: http.MethodPost, path: "/api/agents/agent/unlink", body: `{}`}, {method: http.MethodPatch, path: "/api/agents/agent/host-1/config", body: `{}`}, {method: http.MethodDelete, path: "/api/agents/agent/agent-1", body: ``}, {method: http.MethodPost, path: "/api/agents/host/link", body: `{}`}, {method: http.MethodPost, path: "/api/agents/host/unlink", body: `{}`}, {method: http.MethodPatch, path: "/api/agents/host/host-1/config", body: `{}`}, {method: http.MethodDelete, path: "/api/agents/host/agent-1", body: ``}, {method: http.MethodGet, path: "/api/admin/profiles/", body: ""}, {method: http.MethodPost, path: "/api/agent-install-command", body: `{}`}, {method: http.MethodPost, path: "/api/setup-script-url", body: `{}`}, {method: http.MethodPost, path: "/api/test-notification", body: `{}`}, {method: http.MethodGet, path: "/api/discover", body: ""}, {method: http.MethodPost, path: "/api/license/activate", body: `{}`}, {method: http.MethodPost, path: "/api/license/clear", body: `{}`}, {method: http.MethodGet, path: "/api/license/status", body: ""}, {method: http.MethodGet, path: "/api/notifications/queue/stats", body: ""}, {method: http.MethodGet, path: "/api/notifications/", body: ""}, {method: http.MethodGet, path: "/api/notifications/dlq", body: ""}, {method: http.MethodPost, path: "/api/notifications/dlq/retry", body: `{}`}, {method: http.MethodPost, path: "/api/notifications/dlq/delete", body: `{}`}, } for _, tc := range cases { req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret) req.Header.Set("X-Remote-User", "viewer-user") req.Header.Set("X-Remote-Roles", "viewer") rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for non-admin proxy user on %s %s, got %d", tc.method, tc.path, rec.Code) } if !strings.Contains(rec.Body.String(), "Admin privileges required") { t.Fatalf("expected admin privilege error on %s %s, got %q", tc.method, tc.path, rec.Body.String()) } } } func TestDockerAgentEndpointsRequireDockerReportScope(t *testing.T) { rawToken := "docker-report-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []string{ "/api/agents/docker/report", "/api/agents/docker/commands/command-1", } for _, path := range paths { req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing docker:report scope on %s, got %d", path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeDockerReport) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeDockerReport, rec.Body.String()) } } } func TestDockerManageEndpointsRequireDockerManageScope(t *testing.T) { rawToken := "docker-manage-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeDockerReport}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []string{ "/api/agents/docker/runtimes/host-1", "/api/agents/docker/containers/update", } for _, path := range paths { req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing docker:manage scope on %s, got %d", path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeDockerManage) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeDockerManage, rec.Body.String()) } } } func TestKubernetesAgentEndpointsRequireKubernetesReportScope(t *testing.T) { rawToken := "kube-report-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/agents/kubernetes/report", strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing kubernetes:report scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeKubernetesReport) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeKubernetesReport, rec.Body.String()) } } func TestKubernetesManageEndpointsRequireKubernetesManageScope(t *testing.T) { rawToken := "kube-manage-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeKubernetesReport}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/agents/kubernetes/clusters/cluster-1", strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing kubernetes:manage scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeKubernetesManage) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeKubernetesManage, rec.Body.String()) } } func TestUnifiedAgentEndpointsRequireAgentReportScope(t *testing.T) { rawToken := "host-report-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []string{ "/api/agents/agent/report", "/api/agents/agent/lookup", "/api/agents/agent/uninstall", "/api/agents/host/report", "/api/agents/host/lookup", "/api/agents/host/uninstall", } for _, path := range paths { req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing agent:report scope on %s, got %d", path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeAgentReport) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAgentReport, rec.Body.String()) } } } func TestUnifiedAgentEndpointsAcceptLegacyUnifiedAgentReportScopeAlias(t *testing.T) { rawToken := "legacy-host-report-token-123.12345678" record := newTokenRecord(t, rawToken, []string{"host-agent:report"}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") for _, path := range []string{ "/api/agents/agent/report", "/api/agents/host/report", } { req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code == http.StatusForbidden { t.Fatalf("expected legacy host-agent:report scope alias to pass scope gate on %s, got 403: %s", path, rec.Body.String()) } if rec.Code != http.StatusBadRequest { t.Fatalf("expected request to reach handler and fail on invalid JSON for %s, got %d", path, rec.Code) } } } func TestUnifiedAgentConfigPatchRequiresAgentManageScope(t *testing.T) { rawToken := "host-config-manage-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeAgentConfigRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") for _, path := range []string{"/api/agents/agent/host-1/config", "/api/agents/host/host-1/config"} { req := httptest.NewRequest(http.MethodPatch, path, strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing agent:manage scope on %s, got %d", path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeAgentManage) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAgentManage, rec.Body.String()) } } } func TestMonitoringReadEndpointsRequireMonitoringReadScope(t *testing.T) { rawToken := "monitoring-read-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []string{ "/api/config", "/api/storage/host-1", "/api/storage-charts", "/api/charts", "/api/charts/workloads", "/api/charts/storage-summary", "/api/metrics-store/stats", "/api/metrics-store/history", "/api/guests/metadata", "/api/guests/metadata/guest-1", "/api/docker/metadata", "/api/docker/metadata/container-1", "/api/docker/runtimes/metadata", "/api/docker/runtimes/metadata/host-1", "/api/agents/metadata", "/api/agents/metadata/host-1", "/api/agents/metadata", "/api/agents/metadata/host-1", } for _, path := range paths { req := httptest.NewRequest(http.MethodGet, path, nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing monitoring:read scope on %s, got %d", path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeMonitoringRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringRead, rec.Body.String()) } } } func TestMetadataMutationEndpointsRequireMonitoringWriteScope(t *testing.T) { rawToken := "metadata-write-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []struct { method string path string body string }{ {method: http.MethodPost, path: "/api/guests/metadata/guest-1", body: `{}`}, {method: http.MethodPut, path: "/api/guests/metadata/guest-1", body: `{}`}, {method: http.MethodDelete, path: "/api/guests/metadata/guest-1", body: ""}, {method: http.MethodPost, path: "/api/docker/metadata/container-1", body: `{}`}, {method: http.MethodPut, path: "/api/docker/metadata/container-1", body: `{}`}, {method: http.MethodDelete, path: "/api/docker/metadata/container-1", body: ""}, {method: http.MethodPost, path: "/api/docker/runtimes/metadata/host-1", body: `{}`}, {method: http.MethodPut, path: "/api/docker/runtimes/metadata/host-1", body: `{}`}, {method: http.MethodDelete, path: "/api/docker/runtimes/metadata/host-1", body: ""}, {method: http.MethodPost, path: "/api/agents/metadata/host-1", body: `{}`}, {method: http.MethodPut, path: "/api/agents/metadata/host-1", body: `{}`}, {method: http.MethodDelete, path: "/api/agents/metadata/host-1", body: ""}, {method: http.MethodPost, path: "/api/agents/metadata/host-1", body: `{}`}, {method: http.MethodPut, path: "/api/agents/metadata/host-1", body: `{}`}, {method: http.MethodDelete, path: "/api/agents/metadata/host-1", body: ""}, } for _, tc := range paths { req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing monitoring:write scope on %s %s, got %d", tc.method, tc.path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeMonitoringWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringWrite, rec.Body.String()) } } } func TestAISettingsReadRequiresSettingsReadScope(t *testing.T) { rawToken := "ai-settings-read-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/settings/ai", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String()) } } func TestAISettingsWriteRequiresSettingsWriteScope(t *testing.T) { rawToken := "ai-settings-write-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []string{ "/api/settings/ai/update", "/api/ai/test", "/api/ai/test/openai", } for _, path := range paths { req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope on %s, got %d", path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } } func TestAISettingsUpdateRejectsProxyNonAdmin(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.ProxyAuthSecret = "proxy-secret" cfg.ProxyAuthUserHeader = "X-Remote-User" cfg.ProxyAuthRoleHeader = "X-Remote-Roles" cfg.ProxyAuthAdminRole = "admin" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") router.aiSettingsHandler.defaultConfig = cfg req := httptest.NewRequest(http.MethodPost, "/api/settings/ai/update", strings.NewReader(`{}`)) req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret) req.Header.Set("X-Remote-User", "viewer-user") req.Header.Set("X-Remote-Roles", "viewer") rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for non-admin proxy AI settings update, got %d", rec.Code) } } func TestAITestConnectionRejectsProxyNonAdmin(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.ProxyAuthSecret = "proxy-secret" cfg.ProxyAuthUserHeader = "X-Remote-User" cfg.ProxyAuthRoleHeader = "X-Remote-Roles" cfg.ProxyAuthAdminRole = "admin" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") router.aiSettingsHandler.defaultConfig = cfg req := httptest.NewRequest(http.MethodPost, "/api/ai/test", strings.NewReader(`{}`)) req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret) req.Header.Set("X-Remote-User", "viewer-user") req.Header.Set("X-Remote-Roles", "viewer") rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for non-admin proxy AI test, got %d", rec.Code) } } func TestAITestProviderRejectsProxyNonAdmin(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.ProxyAuthSecret = "proxy-secret" cfg.ProxyAuthUserHeader = "X-Remote-User" cfg.ProxyAuthRoleHeader = "X-Remote-Roles" cfg.ProxyAuthAdminRole = "admin" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") router.aiSettingsHandler.defaultConfig = cfg req := httptest.NewRequest(http.MethodPost, "/api/ai/test/openai", strings.NewReader(`{}`)) req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret) req.Header.Set("X-Remote-User", "viewer-user") req.Header.Set("X-Remote-Roles", "viewer") rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for non-admin proxy AI provider test, got %d", rec.Code) } } func TestAITestConnectionRouteWithValidScope(t *testing.T) { t.Parallel() // Set up a mock Ollama server for the AI connection test ollama := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/version" { _ = json.NewEncoder(w).Encode(map[string]any{"version": "0.1.0"}) return } if r.URL.Path == "/api/tags" { _ = json.NewEncoder(w).Encode(map[string]any{ "models": []map[string]any{ {"name": "llama3", "modified_at": "2026-04-03T09:00:00Z", "size": 1}, }, }) return } http.NotFound(w, r) })) defer ollama.Close() router, rawToken := setupTestConnectionRouter(t, ollama.URL) req := httptest.NewRequest(http.MethodPost, "/api/ai/test", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 for valid settings:write scope on /api/ai/test, got %d: %s", rec.Code, rec.Body.String()) } var resp struct { Success bool `json:"success"` Message string `json:"message"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } if !resp.Success { t.Fatalf("expected success=true, got %+v", resp) } } func TestAITestProviderRouteWithValidScope(t *testing.T) { t.Parallel() // Set up a mock Ollama server for the provider test ollama := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/version" { _ = json.NewEncoder(w).Encode(map[string]any{"version": "0.1.0"}) return } if r.URL.Path == "/api/tags" { _ = json.NewEncoder(w).Encode(map[string]any{ "models": []map[string]any{ {"name": "llama3", "modified_at": "2026-04-03T09:00:00Z", "size": 1}, }, }) return } http.NotFound(w, r) })) defer ollama.Close() router, rawToken := setupTestProviderRouter(t, ollama.URL) req := httptest.NewRequest(http.MethodPost, "/api/ai/test/ollama", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 for valid settings:write scope on /api/ai/test/ollama, got %d: %s", rec.Code, rec.Body.String()) } var resp struct { Success bool `json:"success"` Provider string `json:"provider"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } if !resp.Success || resp.Provider != "ollama" { t.Fatalf("expected success=true provider=ollama, got %+v", resp) } } func TestAITestConnectionRouteRejectsWrongScope(t *testing.T) { t.Parallel() router, rawToken := setupAIRouteRouter(t, newAIRouteTestOptions([]string{config.ScopeMonitoringRead}, "http://192.0.2.1:11434")) req := httptest.NewRequest(http.MethodPost, "/api/ai/test", strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for wrong scope on /api/ai/test, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } func TestAITestProviderRouteRejectsWrongScope(t *testing.T) { t.Parallel() router, rawToken := setupTestProviderRouterForScopes(t, "http://192.0.2.1:11434", []string{config.ScopeMonitoringRead}) req := httptest.NewRequest(http.MethodPost, "/api/ai/test/openai", strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for wrong scope on /api/ai/test/openai, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } func TestAIChatEndpointsRequireAIChatScope(t *testing.T) { rawToken := "ai-chat-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []struct { method string path string body string }{ {method: http.MethodGet, path: "/api/ai/models", body: ""}, {method: http.MethodGet, path: "/api/ai/assistant/surface-tools", body: ""}, {method: http.MethodPost, path: "/api/ai/workflow-prompts/render", body: `{}`}, {method: http.MethodPost, path: "/api/ai/workflow-prompts/activity", body: `{}`}, {method: http.MethodPost, path: "/api/ai/chat", body: `{}`}, {method: http.MethodGet, path: "/api/ai/sessions", body: ""}, {method: http.MethodGet, path: "/api/ai/sessions/session-1", body: ""}, {method: http.MethodGet, path: "/api/ai/question/q-1", body: ""}, {method: http.MethodGet, path: "/api/ai/knowledge", body: ""}, {method: http.MethodGet, path: "/api/ai/knowledge/export", body: ""}, } for _, tc := range paths { req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing ai:chat scope on %s %s, got %d", tc.method, tc.path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeAIChat) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeAIChat, rec.Body.String()) } } } func TestAIKnowledgeMutationEndpointsRequireAIExecuteScope(t *testing.T) { rawToken := "ai-chat-only-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeAIChat}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") for _, path := range []string{ "/api/ai/knowledge/save", "/api/ai/knowledge/delete", "/api/ai/knowledge/import", "/api/ai/knowledge/clear", } { req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), config.ScopeAIExecute) { t.Fatalf("expected %s to require %q, got %d: %s", path, config.ScopeAIExecute, rec.Code, rec.Body.String()) } } } func TestAuditEndpointsRequireLicenseFeature(t *testing.T) { rawToken := "audit-license-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead, config.ScopeAuditRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/audit", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusPaymentRequired { t.Fatalf("expected 402 for missing audit logging license, got %d", rec.Code) } } func TestAuditVerifyRequiresLicenseFeature(t *testing.T) { rawToken := "audit-verify-license-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead, config.ScopeAuditRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/audit/event-1/verify", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusPaymentRequired { t.Fatalf("expected 402 for missing audit logging license, got %d", rec.Code) } } func TestReportingCatalogDoesNotRequireLicenseFeature(t *testing.T) { rawToken := "reporting-license-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/admin/reports/catalog", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 for reporting catalog without reporting license, got %d", rec.Code) } } func TestReportingExecutionEndpointsRequireLicenseFeature(t *testing.T) { rawToken := "reporting-license-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") cases := []struct { method string path string body string }{ {method: http.MethodGet, path: "/api/admin/reports/generate", body: ""}, {method: http.MethodPost, path: "/api/admin/reports/generate-multi", body: `{}`}, {method: http.MethodGet, path: "/api/admin/reports/inventory/vms/export", body: ""}, {method: http.MethodGet, path: "/api/admin/reports/schedules", body: ""}, {method: http.MethodPost, path: "/api/admin/reports/schedules", body: `{}`}, {method: http.MethodPut, path: "/api/admin/reports/schedules/schedule-1", body: `{}`}, {method: http.MethodDelete, path: "/api/admin/reports/schedules/schedule-1", body: ""}, {method: http.MethodPost, path: "/api/admin/reports/schedules/schedule-1/run", body: ""}, } for _, tc := range cases { req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusPaymentRequired { t.Fatalf("expected 402 for missing reporting license on %s %s, got %d", tc.method, tc.path, rec.Code) } } } func TestRBACEndpointsRequireLicenseFeature(t *testing.T) { rawToken := "rbac-license-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []string{ "/api/admin/roles", "/api/admin/users", } for _, path := range paths { req := httptest.NewRequest(http.MethodGet, path, nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusPaymentRequired { t.Fatalf("expected 402 for missing RBAC license on %s, got %d", path, rec.Code) } } } func TestRBACMutationsRequireLicenseFeature(t *testing.T) { rawToken := "rbac-license-mutation-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") cases := []struct { method string path string body string }{ {method: http.MethodPost, path: "/api/admin/roles", body: `{"id":"role-1","name":"Role 1"}`}, {method: http.MethodPut, path: "/api/admin/roles/role-1", body: `{"id":"role-1","name":"Role 1"}`}, {method: http.MethodDelete, path: "/api/admin/roles/role-1", body: ``}, {method: http.MethodPut, path: "/api/admin/users/alice/roles", body: `{"roleIds":["role-1"]}`}, {method: http.MethodPost, path: "/api/admin/users/alice/roles", body: `{"roleIds":["role-1"]}`}, } for _, tc := range cases { req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusPaymentRequired { t.Fatalf("expected 402 for missing RBAC license on %s %s, got %d", tc.method, tc.path, rec.Code) } } } func TestAuditWebhookRequiresLicenseFeature(t *testing.T) { rawToken := "audit-webhook-license-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/admin/webhooks/audit", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusPaymentRequired { t.Fatalf("expected 402 for missing audit logging license, got %d", rec.Code) } } func TestSecurityTokensReadRequiresSettingsReadScope(t *testing.T) { rawToken := "security-tokens-read-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/security/tokens", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String()) } } func TestSecurityTokensWriteRequiresSettingsWriteScope(t *testing.T) { rawToken := "security-tokens-write-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []string{ "/api/security/tokens/relay-mobile", "/api/security/tokens", "/api/security/tokens/token-1", } for _, path := range paths { req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`)) if strings.Contains(path, "/token-") { req = httptest.NewRequest(http.MethodDelete, path, nil) } req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope on %s, got %d", path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } } func TestAgentProfilesRequireLicenseFeature(t *testing.T) { rawToken := "profiles-license-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsWrite}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/admin/profiles/", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusPaymentRequired { t.Fatalf("expected 402 for missing agent profiles license, got %d", rec.Code) } } func TestAILicensedEndpointsRequireLicenseFeature(t *testing.T) { rawToken := "ai-license-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeAIExecute}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []string{ "/api/ai/kubernetes/analyze", "/api/ai/investigate-alert", "/api/ai/findings/f-1/reinvestigate", "/api/ai/findings/f-1/reapprove", } for _, path := range paths { req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusPaymentRequired { t.Fatalf("expected 402 for missing AI license on %s, got %d", path, rec.Code) } } } func TestUpdateHistoryEndpointsRequireSettingsReadScope(t *testing.T) { rawToken := "updates-history-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []string{ "/api/updates/history", "/api/updates/history/entry", "/api/updates/stream", } for _, path := range paths { req := httptest.NewRequest(http.MethodGet, path, nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:read scope on %s, got %d", path, rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String()) } } } func TestDiagnosticsRequireSettingsReadScope(t *testing.T) { rawToken := "diag-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/diagnostics", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String()) } } func TestAgentFleetDiagnosticsRequireSettingsReadScope(t *testing.T) { rawToken := "agent-fleet-diag-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/agents/diagnostics", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:read scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsRead, rec.Body.String()) } } func TestDiagnosticsPrepareTokenRequiresSettingsWriteScope(t *testing.T) { rawToken := "diag-write-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/diagnostics/docker/prepare-token", strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } func TestPermissionProtectedEndpointsDenyWhenAuthorizerBlocks(t *testing.T) { prevAuthorizer := auth.GetAuthorizer() auth.SetAuthorizer(&denyAuthorizer{}) defer auth.SetAuthorizer(prevAuthorizer) rawToken := "perm-deny-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead, config.ScopeAuditRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") cases := []struct { method string path string body string }{ {method: http.MethodGet, path: "/api/audit", body: ""}, {method: http.MethodGet, path: "/api/audit/event-1/verify", body: ""}, {method: http.MethodGet, path: "/api/admin/roles", body: ""}, {method: http.MethodGet, path: "/api/admin/roles/", body: ""}, {method: http.MethodPost, path: "/api/admin/roles", body: `{"id":"role-1","name":"Role 1"}`}, {method: http.MethodPut, path: "/api/admin/roles/role-1", body: `{"id":"role-1","name":"Role 1"}`}, {method: http.MethodDelete, path: "/api/admin/roles/role-1", body: ""}, {method: http.MethodGet, path: "/api/admin/users", body: ""}, {method: http.MethodGet, path: "/api/admin/users/", body: ""}, {method: http.MethodPut, path: "/api/admin/users/alice/roles", body: `{"roleIds":["role-1"]}`}, {method: http.MethodPost, path: "/api/admin/users/alice/roles", body: `{"roleIds":["role-1"]}`}, {method: http.MethodGet, path: "/api/admin/users/alice/permissions", body: ""}, {method: http.MethodGet, path: "/api/admin/reports/catalog", body: ""}, {method: http.MethodGet, path: "/api/admin/reports/generate", body: ""}, {method: http.MethodPost, path: "/api/admin/reports/generate-multi", body: `{}`}, {method: http.MethodGet, path: "/api/admin/reports/schedules", body: ""}, {method: http.MethodPost, path: "/api/admin/reports/schedules", body: `{}`}, {method: http.MethodPut, path: "/api/admin/reports/schedules/schedule-1", body: `{}`}, {method: http.MethodDelete, path: "/api/admin/reports/schedules/schedule-1", body: ""}, {method: http.MethodPost, path: "/api/admin/reports/schedules/schedule-1/run", body: ""}, {method: http.MethodGet, path: "/api/admin/webhooks/audit", body: ""}, {method: http.MethodPost, path: "/api/security/tokens/relay-mobile", body: `{}`}, {method: http.MethodGet, path: "/api/security/tokens", body: ""}, {method: http.MethodDelete, path: "/api/security/tokens/token-1", body: ""}, {method: http.MethodGet, path: "/api/settings/ai", body: ""}, {method: http.MethodPost, path: "/api/settings/ai/update", body: `{}`}, {method: http.MethodPost, path: "/api/ai/test", body: `{}`}, {method: http.MethodPost, path: "/api/ai/test/openai", body: `{}`}, } for _, tc := range cases { req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for permission denial on %s %s, got %d", tc.method, tc.path, rec.Code) } } } func TestPermissionEndpointsRejectProxyNonAdmin(t *testing.T) { prevAuthorizer := auth.GetAuthorizer() auth.SetAuthorizer(&auth.DefaultAuthorizer{}) defer auth.SetAuthorizer(prevAuthorizer) cfg := newTestConfigWithTokens(t) cfg.ProxyAuthSecret = "proxy-secret" cfg.ProxyAuthUserHeader = "X-Remote-User" cfg.ProxyAuthRoleHeader = "X-Remote-Roles" cfg.ProxyAuthAdminRole = "admin" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/security/tokens", nil) req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret) req.Header.Set("X-Remote-User", "viewer-user") req.Header.Set("X-Remote-Roles", "viewer") rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for non-admin proxy on permissioned endpoint, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "Admin privileges required") { t.Fatalf("expected admin privilege error, got %q", rec.Body.String()) } } func TestApplyRestartRequiresAuthInAPIMode(t *testing.T) { record := newTokenRecord(t, "apply-restart-token-123.12345678", []string{config.ScopeSettingsWrite}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/security/apply-restart", nil) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 without auth, got %d", rec.Code) } } func TestApplyRestartRequiresBootstrapTokenBeforeAuthentication(t *testing.T) { t.Setenv("INVOCATION_ID", "") t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "") resetTrustedProxyConfig() cfg := newTestConfigWithTokens(t) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") bootstrapToken, _, _, err := loadOrCreateBootstrapToken(cfg.DataPath) if err != nil { t.Fatalf("load bootstrap token: %v", err) } tests := []struct { name string token string wantStatus int }{ {name: "missing token", wantStatus: http.StatusUnauthorized}, {name: "invalid token", token: "invalid-token", wantStatus: http.StatusUnauthorized}, {name: "valid token", token: bootstrapToken, wantStatus: http.StatusOK}, } for i, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/security/apply-restart", nil) if tt.token == bootstrapToken { req.RemoteAddr = "127.0.0.1:54321" } else { req.RemoteAddr = fmt.Sprintf("198.51.100.%d:54321", 101+i) } if tt.token != "" { req.Header.Set(bootstrapTokenHeader, tt.token) } rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != tt.wantStatus { t.Fatalf("status = %d, want %d (%s)", rec.Code, tt.wantStatus, rec.Body.String()) } }) } } func TestApplyRestartRequiresSettingsWriteScope(t *testing.T) { rawToken := "apply-restart-scope-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/security/apply-restart", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } func TestApplyRestartRequiresProxyAdmin(t *testing.T) { record := newTokenRecord(t, "apply-restart-proxy-token-123.12345678", []string{config.ScopeSettingsWrite}, nil) cfg := newTestConfigWithTokens(t, record) cfg.ProxyAuthSecret = "proxy-secret" cfg.ProxyAuthUserHeader = "X-Remote-User" cfg.ProxyAuthRoleHeader = "X-Remote-Roles" cfg.ProxyAuthAdminRole = "admin" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/security/apply-restart", nil) req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret) req.Header.Set("X-Remote-User", "viewer-user") req.Header.Set("X-Remote-Roles", "viewer") rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for non-admin proxy user, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "Admin privileges required") { t.Fatalf("expected admin privilege error, got %q", rec.Body.String()) } } func TestPrivilegedSecurityEndpointsRejectNonAdminSession(t *testing.T) { hashed, err := auth.HashPassword("Password!1") if err != nil { t.Fatalf("HashPassword: %v", err) } cfg := newTestConfigWithTokens(t) cfg.AuthUser = "admin" cfg.AuthPass = hashed router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") sessionToken := "security-member-session-" + strconv.FormatInt(time.Now().UnixNano(), 10) GetSessionStore().CreateSession(sessionToken, time.Hour, "agent", "127.0.0.1", "member") tests := []struct { method string path string body string }{ {method: http.MethodGet, path: "/api/system/settings", body: ``}, {method: http.MethodPost, path: "/api/system/settings/update", body: `{}`}, {method: http.MethodGet, path: "/api/diagnostics", body: ``}, {method: http.MethodGet, path: "/api/diagnostics/docker/prepare-token", body: ``}, {method: http.MethodGet, path: "/api/license/status", body: ``}, {method: http.MethodGet, path: "/api/updates/check", body: ``}, {method: http.MethodGet, path: "/api/updates/status", body: ``}, {method: http.MethodGet, path: "/api/updates/plan", body: ``}, {method: http.MethodGet, path: "/api/updates/history", body: ``}, {method: http.MethodPost, path: "/api/updates/apply", body: `{}`}, {method: http.MethodGet, path: "/api/logs/stream", body: ``}, {method: http.MethodGet, path: "/api/logs/download", body: ``}, {method: http.MethodGet, path: "/api/settings/relay", body: ``}, {method: http.MethodGet, path: "/api/settings/relay/status", body: ``}, {method: http.MethodPut, path: "/api/settings/relay", body: `{}`}, {method: http.MethodGet, path: "/api/settings/ai", body: ``}, {method: http.MethodGet, path: "/api/ai/cost/summary", body: ``}, {method: http.MethodPost, path: "/api/security/tokens/relay-mobile", body: `{}`}, {method: http.MethodGet, path: "/api/security/tokens", body: ``}, {method: http.MethodDelete, path: "/api/security/tokens/token-1", body: ``}, {method: http.MethodGet, path: "/api/security/sso/providers", body: ``}, {method: http.MethodPost, path: "/api/security/sso/providers", body: `{}`}, {method: http.MethodPost, path: "/api/security/sso/providers/test", body: `{}`}, {method: http.MethodPost, path: "/api/security/sso/providers/metadata/preview", body: `{}`}, {method: http.MethodGet, path: "/api/admin/roles", body: ``}, {method: http.MethodGet, path: "/api/admin/users", body: ``}, {method: http.MethodGet, path: "/api/admin/rbac/integrity", body: ``}, {method: http.MethodPost, path: "/api/admin/rbac/reset-admin", body: `{}`}, {method: http.MethodGet, path: "/api/admin/webhooks/audit", body: ``}, {method: http.MethodGet, path: "/api/onboarding/qr", body: ``}, {method: http.MethodGet, path: "/api/onboarding/deep-link", body: ``}, {method: http.MethodPost, path: "/api/onboarding/validate", body: `{}`}, {method: http.MethodPost, path: "/api/security/apply-restart", body: `{}`}, {method: http.MethodPost, path: "/api/security/regenerate-token", body: `{}`}, {method: http.MethodPost, path: "/api/security/validate-token", body: `{"token":"abc"}`}, {method: http.MethodPost, path: "/api/settings/ai/update", body: `{}`}, {method: http.MethodPost, path: "/api/ai/test", body: `{}`}, {method: http.MethodPost, path: "/api/ai/test/openai", body: `{}`}, {method: http.MethodPost, path: "/api/security/reset-lockout", body: `{"identifier":"admin"}`}, {method: http.MethodPost, path: "/api/system/verify-temperature-ssh", body: `{}`}, {method: http.MethodPost, path: "/api/system/ssh-config", body: `{}`}, } for _, tc := range tests { req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) req.RemoteAddr = "127.0.0.1:1234" req.Header.Set("Content-Type", "application/json") if tc.method != http.MethodGet && tc.method != http.MethodHead { req.Header.Set("X-CSRF-Token", generateCSRFToken(sessionToken)) } req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken}) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for non-admin session on %s %s, got %d: %s", tc.method, tc.path, rec.Code, rec.Body.String()) } if !strings.Contains(rec.Body.String(), "Admin privileges required") { t.Fatalf("expected admin privilege error on %s %s, got %q", tc.method, tc.path, rec.Body.String()) } } } func TestVerifyTemperatureSSHRequiresAuthInAPIMode(t *testing.T) { record := newTokenRecord(t, "verify-ssh-token-123.12345678", []string{config.ScopeSettingsWrite}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/system/verify-temperature-ssh", strings.NewReader(`{}`)) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 without auth, got %d", rec.Code) } } func TestVerifyTemperatureSSHRequiresSettingsWriteScope(t *testing.T) { rawToken := "verify-ssh-scope-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/system/verify-temperature-ssh", strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } func TestSSHConfigRequiresAuthInAPIMode(t *testing.T) { record := newTokenRecord(t, "ssh-config-token-123.12345678", []string{config.ScopeSettingsWrite}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/system/ssh-config", strings.NewReader(`{}`)) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 without auth, got %d", rec.Code) } } func TestSSHConfigRequiresSettingsWriteScope(t *testing.T) { rawToken := "ssh-config-scope-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/system/ssh-config", strings.NewReader(`{}`)) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } func TestQuickSetupRequiresAuthWhenConfigured(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.AuthUser = "admin" cfg.AuthPass = "hashed-password" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ResetRateLimitForIP("203.0.113.20") req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(`{"username":"admin","password":"Password!1"}`)) req.RemoteAddr = "203.0.113.20:1234" rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 without auth, got %d", rec.Code) } } func TestQuickSetupRejectsProxyNonAdmin(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.AuthUser = "admin" cfg.AuthPass = "hashed-password" cfg.ProxyAuthSecret = "proxy-secret" cfg.ProxyAuthUserHeader = "X-Remote-User" cfg.ProxyAuthRoleHeader = "X-Remote-Roles" cfg.ProxyAuthAdminRole = "admin" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ResetRateLimitForIP("203.0.113.27") req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(`{}`)) req.RemoteAddr = "203.0.113.27:1234" req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret) req.Header.Set("X-Remote-User", "viewer-user") req.Header.Set("X-Remote-Roles", "viewer") rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for non-admin proxy quick setup, got %d", rec.Code) } } func TestRegenerateTokenRequiresAuthInAPIMode(t *testing.T) { record := newTokenRecord(t, "regen-token-123.12345678", []string{config.ScopeSettingsWrite}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ResetRateLimitForIP("203.0.113.21") req := httptest.NewRequest(http.MethodPost, "/api/security/regenerate-token", nil) req.RemoteAddr = "203.0.113.21:1234" rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 without auth, got %d", rec.Code) } } func TestRegenerateTokenRequiresSettingsWriteScope(t *testing.T) { rawToken := "regen-scope-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ResetRateLimitForIP("203.0.113.22") req := httptest.NewRequest(http.MethodPost, "/api/security/regenerate-token", nil) req.RemoteAddr = "203.0.113.22:1234" req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } func TestRegenerateTokenRejectsProxyNonAdmin(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.ProxyAuthSecret = "proxy-secret" cfg.ProxyAuthUserHeader = "X-Remote-User" cfg.ProxyAuthRoleHeader = "X-Remote-Roles" cfg.ProxyAuthAdminRole = "admin" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ResetRateLimitForIP("203.0.113.25") req := httptest.NewRequest(http.MethodPost, "/api/security/regenerate-token", nil) req.RemoteAddr = "203.0.113.25:1234" req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret) req.Header.Set("X-Remote-User", "viewer-user") req.Header.Set("X-Remote-Roles", "viewer") rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for non-admin proxy regenerate-token, got %d", rec.Code) } } func TestValidateTokenRequiresAuthInAPIMode(t *testing.T) { record := newTokenRecord(t, "validate-token-123.12345678", []string{config.ScopeSettingsWrite}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ResetRateLimitForIP("203.0.113.23") req := httptest.NewRequest(http.MethodPost, "/api/security/validate-token", strings.NewReader(`{"token":"abc"}`)) req.RemoteAddr = "203.0.113.23:1234" rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 without auth, got %d", rec.Code) } } func TestValidateTokenRequiresSettingsWriteScope(t *testing.T) { rawToken := "validate-scope-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ResetRateLimitForIP("203.0.113.24") req := httptest.NewRequest(http.MethodPost, "/api/security/validate-token", strings.NewReader(`{"token":"abc"}`)) req.RemoteAddr = "203.0.113.24:1234" req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String()) } } func TestValidateTokenRejectsProxyNonAdmin(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.ProxyAuthSecret = "proxy-secret" cfg.ProxyAuthUserHeader = "X-Remote-User" cfg.ProxyAuthRoleHeader = "X-Remote-Roles" cfg.ProxyAuthAdminRole = "admin" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ResetRateLimitForIP("203.0.113.26") req := httptest.NewRequest(http.MethodPost, "/api/security/validate-token", strings.NewReader(`{"token":"abc"}`)) req.RemoteAddr = "203.0.113.26:1234" req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret) req.Header.Set("X-Remote-User", "viewer-user") req.Header.Set("X-Remote-Roles", "viewer") rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for non-admin proxy validate-token, got %d", rec.Code) } } func TestRecoveryEndpointRejectsRemoteWithoutToken(t *testing.T) { cfg := newTestConfigWithTokens(t) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ResetRateLimitForIP("203.0.113.30") req := httptest.NewRequest(http.MethodPost, "/api/security/recovery", strings.NewReader(`{"action":"status"}`)) req.RemoteAddr = "203.0.113.30:1234" rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for remote recovery request, got %d", rec.Code) } } func TestHealthEndpointIsPublicEvenWhenAuthConfigured(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.AuthUser = "admin" cfg.AuthPass = "hashed" monitor, err := monitoring.New(cfg) if err != nil { t.Fatalf("monitoring.New: %v", err) } defer monitor.Stop() router := NewRouter(cfg, monitor, nil, nil, nil, "1.0.0") ResetRateLimitForIP("203.0.113.40") req := httptest.NewRequest(http.MethodGet, "/api/health", nil) req.RemoteAddr = "203.0.113.40:1234" rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 for public health endpoint, got %d", rec.Code) } } func TestVersionEndpointIsPublicEvenWhenAuthConfigured(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.AuthUser = "admin" cfg.AuthPass = "hashed" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ResetRateLimitForIP("203.0.113.41") req := httptest.NewRequest(http.MethodGet, "/api/version", nil) req.RemoteAddr = "203.0.113.41:1234" rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 for public version endpoint, got %d", rec.Code) } } func TestAgentVersionEndpointIsPublicEvenWhenAuthConfigured(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.AuthUser = "admin" cfg.AuthPass = "hashed" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ResetRateLimitForIP("203.0.113.42") req := httptest.NewRequest(http.MethodGet, "/api/agent/version", nil) req.RemoteAddr = "203.0.113.42:1234" rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 for public agent version endpoint, got %d", rec.Code) } } func TestServerInfoEndpointIsPublicEvenWhenAuthConfigured(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.AuthUser = "admin" cfg.AuthPass = "hashed" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ResetRateLimitForIP("203.0.113.43") req := httptest.NewRequest(http.MethodGet, "/api/server/info", nil) req.RemoteAddr = "203.0.113.43:1234" rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 for public server info endpoint, got %d", rec.Code) } } func TestSecurityStatusIsPublicEvenWhenAuthConfigured(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.AuthUser = "admin" cfg.AuthPass = "hashed" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ResetRateLimitForIP("203.0.113.44") req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil) req.RemoteAddr = "203.0.113.44:1234" rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 for public security status endpoint, got %d", rec.Code) } } func TestValidateBootstrapTokenBypassesAuth(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.AuthUser = "admin" cfg.AuthPass = "hashed" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ResetRateLimitForIP("203.0.113.45") req := httptest.NewRequest(http.MethodPost, "/api/security/validate-bootstrap-token", strings.NewReader(`{}`)) req.RemoteAddr = "203.0.113.45:1234" rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusConflict { t.Fatalf("expected 409 when bootstrap token is unavailable, got %d", rec.Code) } } func TestSecurityStatusHidesBootstrapTokenWhenAuthConfigured(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.AuthUser = "admin" cfg.AuthPass = "hashed" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ResetRateLimitForIP("203.0.113.49") req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil) req.RemoteAddr = "203.0.113.49:1234" rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 for security status, got %d", rec.Code) } var payload map[string]interface{} if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode response: %v", err) } if _, ok := payload["bootstrapTokenPath"]; ok { t.Fatalf("expected bootstrapTokenPath to be omitted when auth is configured") } } func TestSecurityStatusOmitsPrivilegedDetailsWhenUnauthenticated(t *testing.T) { cfg := newTestConfigWithTokens(t) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ResetRateLimitForIP("203.0.113.50") req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil) req.RemoteAddr = "203.0.113.50:1234" rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 for security status, got %d", rec.Code) } var payload map[string]interface{} if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode response: %v", err) } if got := payload["detailLevel"]; got != securityStatusDetailPublic { t.Fatalf("detailLevel = %v, want %q", got, securityStatusDetailPublic) } if got, _ := payload["hasAuthentication"].(bool); got { t.Fatalf("hasAuthentication = %v, want false", payload["hasAuthentication"]) } privilegedKeys := []string{ "apiTokenConfigured", "apiTokenHint", "authLastModified", "bootstrapTokenPath", "clientIP", "agentUrl", "hasAuditLogging", "isDocker", "inContainer", "lxcCtid", "dockerContainerName", "settingsCapabilities", } for _, key := range privilegedKeys { if _, ok := payload[key]; ok { t.Errorf("unauthenticated status exposed privileged field %q", key) } } } func TestAuditRequiresAuthInAPIMode(t *testing.T) { record := newTokenRecord(t, "audit-auth-token-123.12345678", []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/audit", nil) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 without auth, got %d", rec.Code) } } func TestSecurityStatusIgnoresTokenQueryParam(t *testing.T) { rawToken := "status-query-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/security/status?token="+rawToken, nil) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 for security status, got %d", rec.Code) } var payload map[string]interface{} if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode response: %v", err) } if hint, ok := payload["apiTokenHint"].(string); ok && hint != "" { t.Fatalf("expected apiTokenHint to be empty when token passed via query param, got %q", hint) } if _, ok := payload["tokenScopes"]; ok { t.Fatalf("expected tokenScopes to be omitted when unauthenticated") } } func TestSecurityStatusAcceptsTokenHeader(t *testing.T) { rawToken := "status-header-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 for security status, got %d", rec.Code) } var payload map[string]interface{} if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode response: %v", err) } if _, ok := payload["apiTokenHint"]; ok { t.Fatalf("scoped token status exposed apiTokenHint: %v", payload["apiTokenHint"]) } if scopes, ok := payload["tokenScopes"].([]interface{}); !ok || len(scopes) == 0 { t.Fatalf("expected tokenScopes to be present when authenticated via API token") } if got := payload["detailLevel"]; got != securityStatusDetailAuthenticated { t.Fatalf("detailLevel = %v, want %q", got, securityStatusDetailAuthenticated) } if _, ok := payload["clientIP"]; ok { t.Fatalf("scoped token status exposed privileged clientIP: %v", payload["clientIP"]) } } func TestSecurityStatusExposesPrivilegedDetailsToSettingsReadToken(t *testing.T) { rawToken := "status-privileged-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil) req.RemoteAddr = "198.51.100.63:54321" req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String()) } var payload map[string]interface{} if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode response: %v", err) } if got := payload["detailLevel"]; got != securityStatusDetailPrivileged { t.Fatalf("detailLevel = %v, want %q", got, securityStatusDetailPrivileged) } if got, _ := payload["clientIP"].(string); got != "198.51.100.63" { t.Fatalf("clientIP = %q, want %q", got, "198.51.100.63") } if _, ok := payload["apiTokenConfigured"]; !ok { t.Fatal("privileged status omitted apiTokenConfigured") } if got, _ := payload["apiTokenHint"].(string); got != cfg.PrimaryAPITokenHint() { t.Fatalf("apiTokenHint = %q, want %q", got, cfg.PrimaryAPITokenHint()) } } func TestAuditVerifyRequiresAuthInAPIMode(t *testing.T) { record := newTokenRecord(t, "audit-verify-auth-token-123.12345678", []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/audit/event-1/verify", nil) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 without auth, got %d", rec.Code) } } func TestPathTraversalBlockedForAPIPaths(t *testing.T) { cfg := newTestConfigWithTokens(t) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/../api/security/status", nil) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 for path traversal on api, got %d", rec.Code) } } func TestPathTraversalBlockedForNonAPIPaths(t *testing.T) { cfg := newTestConfigWithTokens(t) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/../etc/passwd", nil) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400 for path traversal on non-api, got %d", rec.Code) } } func TestPathTraversalBlockedForEncodedAPIPaths(t *testing.T) { cfg := newTestConfigWithTokens(t) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/%2e%2e/api/security/status", nil) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 for encoded path traversal on api, got %d", rec.Code) } } func TestPathTraversalBlockedForEncodedNonAPIPaths(t *testing.T) { cfg := newTestConfigWithTokens(t) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/%2e%2e/%2e%2e/etc/passwd", nil) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400 for encoded path traversal on non-api, got %d", rec.Code) } } func TestSetupScriptIsPublicEvenWhenAuthConfigured(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.AuthUser = "admin" cfg.AuthPass = "hashed" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/setup-script", nil) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400 for missing params on public setup script, got %d", rec.Code) } } func TestPublicDownloadEndpointsBypassAuth(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.AuthUser = "admin" cfg.AuthPass = "hashed" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []string{ "/install.sh", "/install.ps1", "/download/pulse-agent", } for idx, path := range paths { ip := "203.0.113." + strconv.Itoa(70+idx) ResetRateLimitForIP(ip) req := httptest.NewRequest(http.MethodPost, path, nil) req.RemoteAddr = ip + ":1234" rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusMethodNotAllowed { t.Fatalf("expected 405 for public download endpoint %s, got %d", path, rec.Code) } } } func TestPublicEndpointsBypassAuthInAPIMode(t *testing.T) { record := newTokenRecord(t, "public-api-token-123.12345678", []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) monitor, err := monitoring.New(cfg) if err != nil { t.Fatalf("monitoring.New: %v", err) } defer monitor.Stop() router := NewRouter(cfg, monitor, nil, nil, nil, "1.0.0") paths := []string{ "/api/health", "/api/version", "/api/agent/version", "/api/server/info", "/api/security/status", } for _, path := range paths { req := httptest.NewRequest(http.MethodGet, path, nil) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 for public endpoint %s, got %d", path, rec.Code) } } } func TestSSHKeyGenerationBlockedInContainer(t *testing.T) { t.Setenv("PULSE_DOCKER", "true") t.Setenv("PULSE_DEV_ALLOW_CONTAINER_SSH", "") homeDir := t.TempDir() t.Setenv("HOME", homeDir) handler := NewConfigHandlers(nil, nil, func() error { return nil }, nil, nil, func() {}) keys := handler.getOrGenerateSSHKeys() if keys.SensorsPublicKey != "" { t.Fatalf("expected empty key when container SSH generation is blocked") } pubKeyPath := filepath.Join(homeDir, ".ssh", "id_ed25519_sensors.pub") if _, err := os.Stat(pubKeyPath); !errors.Is(err, os.ErrNotExist) { t.Fatalf("expected no key files to be written, got err=%v", err) } } func TestSetupScriptRejectsInvalidSetupToken(t *testing.T) { cfg := newTestConfigWithTokens(t) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/setup-script?type=pve&host=https://example.com&pulse_url=https://pulse.example.com&setup_token=not-hex", nil) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400 for invalid setup_token, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "Invalid setup_token parameter") { t.Fatalf("expected invalid setup_token error, got %q", rec.Body.String()) } } func TestSetupScriptRejectsInvalidHostURL(t *testing.T) { cfg := newTestConfigWithTokens(t) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/setup-script?type=pve&host=ftp://example.com&pulse_url=https://pulse.example.com", nil) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400 for invalid host, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "Invalid host parameter") { t.Fatalf("expected invalid host error, got %q", rec.Body.String()) } } func TestSetupScriptRejectsInvalidPulseURL(t *testing.T) { cfg := newTestConfigWithTokens(t) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/setup-script?type=pve&host=https://example.com&pulse_url=ftp://pulse.example.com", nil) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400 for invalid pulse_url, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "Invalid pulse_url parameter") { t.Fatalf("expected invalid pulse_url error, got %q", rec.Body.String()) } } func TestSSOOIDCLoginBypassesAuth(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.AuthUser = "admin" cfg.AuthPass = "hashed" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ResetRateLimitForIP("203.0.113.46") req := httptest.NewRequest(http.MethodGet, "/api/oidc/test-provider/login", nil) req.RemoteAddr = "203.0.113.46:1234" rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("expected 404 for unknown OIDC provider on public endpoint, got %d", rec.Code) } } func TestSSOOIDCCallbackBypassesAuth(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.AuthUser = "admin" cfg.AuthPass = "hashed" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ResetRateLimitForIP("203.0.113.51") req := httptest.NewRequest(http.MethodGet, "/api/oidc/test-provider/callback", nil) req.RemoteAddr = "203.0.113.51:1234" rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusFound { t.Fatalf("expected 302 redirect for unknown OIDC provider callback on public endpoint, got %d", rec.Code) } } // TestSSOOIDCLegacyCallbackBypassesAuthTokenOnly reproduces the v5->v6 upgrade // bug from issue #1533. An upgraded OIDC provider keeps its v5 redirect URL // (/api/oidc/callback, the 3-segment DefaultOIDCCallbackPath), so the IdP // redirects the browser back to the legacy path with only code/state, no // session cookie and no API token. In API-token-only mode // (AuthUser=="" && AuthPass=="" && HasAPITokens()) the global auth middleware // used to reject that inbound redirect with // "API token required via Authorization header or X-API-Token header" // because the public-path allowlist only recognised the 4-segment // per-provider path. The legacy login and callback paths must bypass auth and // reach handleSSOOIDC*, which map them to the migrated legacy provider. func TestSSOOIDCLegacyCallbackBypassesAuthTokenOnly(t *testing.T) { // Token-only mode: this is the exact config that emits the reported error. record := newTokenRecord(t, "legacy-oidc-token-123.12345678", []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) if cfg.AuthUser != "" || cfg.AuthPass != "" || !cfg.HasAPITokens() { t.Fatalf("test setup must reproduce token-only mode: authUser=%q authPass set=%v hasTokens=%v", cfg.AuthUser, cfg.AuthPass != "", cfg.HasAPITokens()) } router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") // No legacy provider is configured, so the callback handler redirects with a // provider_not_found error (302) and the login handler returns 404. Either // way the request reached the handler, proving auth was bypassed. Before the // fix both returned 401 with the API-token error. cases := []struct { name string path string clientIP string wantCode int }{ {"legacy callback", "/api/oidc/callback?code=abc&state=xyz", "203.0.113.61", http.StatusFound}, {"legacy login", "/api/oidc/login", "203.0.113.62", http.StatusNotFound}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { ResetRateLimitForIP(tc.clientIP) req := httptest.NewRequest(http.MethodGet, tc.path, nil) req.RemoteAddr = tc.clientIP + ":1234" rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code == http.StatusUnauthorized { t.Fatalf("legacy OIDC path %q was rejected by API-token auth (401): %s", tc.path, strings.TrimSpace(rec.Body.String())) } if strings.Contains(rec.Body.String(), "API token required") { t.Fatalf("legacy OIDC path %q returned the API-token error body: %s", tc.path, strings.TrimSpace(rec.Body.String())) } if rec.Code != tc.wantCode { t.Fatalf("legacy OIDC path %q: expected status %d (auth bypassed, reached handler), got %d", tc.path, tc.wantCode, rec.Code) } }) } } func TestAIOAuthCallbackBypassesAuth(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.AuthUser = "admin" cfg.AuthPass = "hashed" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ResetRateLimitForIP("203.0.113.47") req := httptest.NewRequest(http.MethodGet, "/api/ai/oauth/callback", nil) req.RemoteAddr = "203.0.113.47:1234" rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusTemporaryRedirect { t.Fatalf("expected 307 redirect for OAuth callback, got %d", rec.Code) } } func TestLoginEndpointBypassesAuth(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.AuthUser = "admin" cfg.AuthPass = "hashed" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") ResetRateLimitForIP("203.0.113.48") req := httptest.NewRequest(http.MethodGet, "/api/login", nil) req.RemoteAddr = "203.0.113.48:1234" rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusMethodNotAllowed { t.Fatalf("expected 405 for login GET, got %d", rec.Code) } } func TestLegacyInstallScriptAliasesDoNotBypassAuth(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.AuthUser = "admin" cfg.AuthPass = "hashed" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") paths := []string{ "/api/install/install-docker.sh", "/api/install/install.sh", "/api/install/install.ps1", } for idx, path := range paths { ip := "203.0.113." + strconv.Itoa(80+idx) ResetRateLimitForIP(ip) req := httptest.NewRequest(http.MethodPost, path, nil) req.RemoteAddr = ip + ":1234" rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 for removed install alias %s, got %d", path, rec.Code) } } } func TestLogoutRequiresAuthInAPIMode(t *testing.T) { record := newTokenRecord(t, "logout-token-123.12345678", []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodPost, "/api/logout", nil) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 without auth, got %d", rec.Code) } } func TestStateRequiresAuthInAPIMode(t *testing.T) { record := newTokenRecord(t, "state-token-123.12345678", []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/state", nil) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 without auth, got %d", rec.Code) } } func TestStateRequiresMonitoringReadScope(t *testing.T) { rawToken := "state-scope-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/state", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing monitoring:read scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeMonitoringRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringRead, rec.Body.String()) } } func TestStateSummaryRequiresAuthInAPIMode(t *testing.T) { record := newTokenRecord(t, "state-summary-token-123.12345678", []string{config.ScopeMonitoringRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/state/summary", nil) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 without auth, got %d", rec.Code) } } func TestStateSummaryRequiresMonitoringReadScope(t *testing.T) { rawToken := "state-summary-scope-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := newTestConfigWithTokens(t, record) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/state/summary", nil) req.Header.Set("X-API-Token", rawToken) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 for missing monitoring:read scope, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), config.ScopeMonitoringRead) { t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeMonitoringRead, rec.Body.String()) } } func TestVerifyTemperatureSSHAllowsSetupToken(t *testing.T) { cfg := newTestConfigWithTokens(t) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") token := "0123456789abcdef0123456789abcdef" tokenHash := auth.HashAPIToken(token) router.configHandlers.codeMutex.Lock() router.configHandlers.setupTokens[tokenHash] = &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)} router.configHandlers.codeMutex.Unlock() req := httptest.NewRequest(http.MethodPost, "/api/system/verify-temperature-ssh", strings.NewReader(`{"nodes":""}`)) req.Header.Set("X-Setup-Token", token) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 with setup token, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), "No nodes to verify") { t.Fatalf("expected verify response, got %q", rec.Body.String()) } } func TestVerifyTemperatureSSHRejectsSetupTokenOrgMismatch(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.AuthUser = "admin" cfg.AuthPass = "hashed" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") token := "fedcba9876543210fedcba9876543210" tokenHash := auth.HashAPIToken(token) router.configHandlers.codeMutex.Lock() router.configHandlers.setupTokens[tokenHash] = &SetupTokenRecord{ ExpiresAt: time.Now().Add(time.Minute), OrgID: "org-a", } router.configHandlers.codeMutex.Unlock() req := httptest.NewRequest(http.MethodPost, "/api/system/verify-temperature-ssh", strings.NewReader(`{"nodes":""}`)) req.Header.Set("X-Setup-Token", token) req.Header.Set("X-Pulse-Org-ID", "org-b") rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code == http.StatusOK { t.Fatalf("expected setup token org mismatch to be rejected, got %d", rec.Code) } } func TestVerifyTemperatureSSHRejectsSetupTokenOrgIDQueryBypass(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.AuthUser = "admin" cfg.AuthPass = "hashed" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") token := "11223344556677889900aabbccddeeff" tokenHash := auth.HashAPIToken(token) router.configHandlers.codeMutex.Lock() router.configHandlers.setupTokens[tokenHash] = &SetupTokenRecord{ ExpiresAt: time.Now().Add(time.Minute), OrgID: "org-a", } router.configHandlers.codeMutex.Unlock() req := httptest.NewRequest(http.MethodPost, "/api/system/verify-temperature-ssh?org_id=org-a", strings.NewReader(`{"nodes":""}`)) req.Header.Set("X-Setup-Token", token) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code == http.StatusOK { t.Fatalf("expected org_id query bypass attempt to be rejected, got %d", rec.Code) } } func TestSSHConfigAllowsSetupToken(t *testing.T) { cfg := newTestConfigWithTokens(t) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") t.Setenv("HOME", t.TempDir()) token := "abcdef0123456789abcdef0123456789" tokenHash := auth.HashAPIToken(token) router.configHandlers.codeMutex.Lock() router.configHandlers.setupTokens[tokenHash] = &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)} router.configHandlers.codeMutex.Unlock() req := httptest.NewRequest(http.MethodPost, "/api/system/ssh-config", strings.NewReader("Host example\nHostname example\n")) req.Header.Set("X-Setup-Token", token) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 with setup token, got %d", rec.Code) } if !strings.Contains(rec.Body.String(), `"success":true`) { t.Fatalf("expected success response, got %q", rec.Body.String()) } } func TestSSHConfigRejectsSetupTokenOrgMismatch(t *testing.T) { cfg := newTestConfigWithTokens(t) cfg.AuthUser = "admin" cfg.AuthPass = "hashed" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") t.Setenv("HOME", t.TempDir()) token := "00112233445566778899aabbccddeeff" tokenHash := auth.HashAPIToken(token) router.configHandlers.codeMutex.Lock() router.configHandlers.setupTokens[tokenHash] = &SetupTokenRecord{ ExpiresAt: time.Now().Add(time.Minute), OrgID: "org-a", } router.configHandlers.codeMutex.Unlock() req := httptest.NewRequest(http.MethodPost, "/api/system/ssh-config", strings.NewReader("Host example\nHostname example\n")) req.Header.Set("X-Setup-Token", token) req.Header.Set("X-Pulse-Org-ID", "org-b") rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code == http.StatusOK { t.Fatalf("expected setup token org mismatch to be rejected, got %d", rec.Code) } } func TestVerifyTemperatureSSHRejectsSetupTokenQueryParam(t *testing.T) { cfg := newTestConfigWithTokens(t, newTokenRecord(t, "settings-write-token", []string{config.ScopeSettingsWrite}, nil)) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") token := "abcdefabcdefabcdefabcdefabcdefab" tokenHash := auth.HashAPIToken(token) router.configHandlers.codeMutex.Lock() router.configHandlers.setupTokens[tokenHash] = &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)} router.configHandlers.codeMutex.Unlock() req := httptest.NewRequest(http.MethodPost, "/api/system/verify-temperature-ssh?auth_token="+token, strings.NewReader(`{"nodes":""}`)) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 when setup token is only provided in query string, got %d", rec.Code) } } func TestSSHConfigRejectsSetupTokenQueryParam(t *testing.T) { cfg := newTestConfigWithTokens(t, newTokenRecord(t, "settings-write-token", []string{config.ScopeSettingsWrite}, nil)) router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") t.Setenv("HOME", t.TempDir()) token := "deadbeefdeadbeefdeadbeefdeadbeef" tokenHash := auth.HashAPIToken(token) router.configHandlers.codeMutex.Lock() router.configHandlers.setupTokens[tokenHash] = &SetupTokenRecord{ExpiresAt: time.Now().Add(time.Minute)} router.configHandlers.codeMutex.Unlock() req := httptest.NewRequest(http.MethodPost, "/api/system/ssh-config?auth_token="+token, strings.NewReader("Host example\nHostname example\n")) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 when setup token is only provided in query string, got %d", rec.Code) } } // TestCheckCSRF_HeaderDoesNotBypassWhenSessionCookiePresent regression-tests the // fix for a CSRF bypass: CheckCSRF was skipping the entire CSRF check whenever // the request carried Authorization: Basic, Authorization: Bearer, or // X-API-Token, without validating the credential. A cross-origin attacker // could fetch() with credentials: 'include' and an arbitrary Authorization // header — the browser would still auto-attach the victim's session cookie // and the server would skip CSRF, fully bypassing protection. The contract is // now: a session cookie is the only signal for whether CSRF applies. If a // session cookie is present, CSRF must be valid regardless of any other // auth-style header. func TestCheckCSRF_HeaderDoesNotBypassWhenSessionCookiePresent(t *testing.T) { cases := []struct { name string setHeader func(*http.Request) description string }{ { name: "x_api_token", setHeader: func(r *http.Request) { r.Header.Set("X-API-Token", "some-api-token") }, description: "X-API-Token must not bypass CSRF when a session cookie is present", }, { name: "authorization_basic", setHeader: func(r *http.Request) { r.Header.Set("Authorization", "Basic dXNlcjpwYXNz") }, description: "Authorization: Basic must not bypass CSRF when a session cookie is present", }, { name: "authorization_bearer", setHeader: func(r *http.Request) { r.Header.Set("Authorization", "Bearer some-token") }, description: "Authorization: Bearer must not bypass CSRF when a session cookie is present", }, { name: "authorization_bearer_mixed_case", setHeader: func(r *http.Request) { // Mixed-case scheme to ensure the old lower-case prefix check // is not reintroduced as a guarded skip. r.Header.Set("Authorization", "BeArEr some-token") }, description: "Mixed-case Bearer must not bypass CSRF when a session cookie is present", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { w := httptest.NewRecorder() req := httptest.NewRequest("POST", "/api/test", nil) tc.setHeader(req) req.AddCookie(&http.Cookie{ Name: "pulse_session", Value: "test-session-id-1234567890", }) if CheckCSRF(w, req) { t.Fatal(tc.description) } }) } } // TestRequirePermissionUsesContextUsername regresses the X-Authenticated-User // response-header-as-identity bug. RequirePermission previously read the // authenticated username from w.Header().Get("X-Authenticated-User"). That // header is mutable across the handler chain; any middleware sitting between // checkAuth and RequirePermission that wrote to the response header could // substitute an arbitrary identity, and the RBAC authorizer would make its // decision against the substituted value. The contract is now: the identity // comes from the REQUEST context (set by attachUserContext during auth). // When the response header and the context disagree, the context wins. func TestRequirePermissionUsesContextUsername(t *testing.T) { cfg := &config.Config{} var observedSubject string authorizer := &mockAuthorizerFn{fn: func(ctx context.Context, action string, resource string) (bool, error) { observedSubject = auth.GetUser(ctx) return true, nil }} handler := RequirePermission(cfg, authorizer, "read", "logs", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) req := newLoopbackRequest("GET", "/api/test", nil) // Simulate a downstream middleware that has written a different value // to the response header AFTER checkAuth ran. The header below is the // would-be-attacker-supplied value; the context (set by attachUserContext) // holds the real authenticated user. rr := httptest.NewRecorder() rr.Header().Set("X-Authenticated-User", "evil-spoofed-user") ctxReq := req.WithContext(auth.WithUser(req.Context(), "real-authenticated-user")) handler.ServeHTTP(rr, ctxReq) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rr.Code) } if observedSubject != "real-authenticated-user" { t.Fatalf("RBAC subject must come from context, got %q (header was %q)", observedSubject, "evil-spoofed-user") } } // Instance-wide webhook security settings must reach every tenant org's // notification manager: the allowlist is stored globally, so applying it only // to the request's org (or only to the default org on reload) leaves other // tenants' webhook targets failing SSRF validation with no org-side remedy. func TestSystemSettingsWebhookAllowlistPropagatesToAllTenantManagers(t *testing.T) { t.Setenv("PULSE_MULTI_TENANT_ENABLED", "true") defer SetMultiTenantEnabled(false) SetMultiTenantEnabled(true) dataDir := t.TempDir() tokenVal := "allowlist-propagation-token-123.12345678" cfg := &config.Config{ DataPath: dataDir, APITokens: []config.APITokenRecord{ {ID: "tok-allowlist", Hash: auth.HashAPIToken(tokenVal), Name: "Allowlist Test Token"}, }, } persistence := config.NewConfigPersistence(dataDir) mtp := config.NewMultiTenantPersistence(dataDir) mtm := monitoring.NewMultiTenantMonitor(cfg, mtp, nil) t.Cleanup(mtm.Stop) for _, org := range []struct{ id, name string }{ {"org-a", "Org A"}, {"org-b", "Org B"}, } { if err := mtp.SaveOrganization(&models.Organization{ID: org.id, DisplayName: org.name}); err != nil { t.Fatalf("SaveOrganization(%s): %v", org.id, err) } if _, err := mtm.GetMonitor(org.id); err != nil { t.Fatalf("GetMonitor(%s): %v", org.id, err) } } h := NewSystemSettingsHandler(cfg, persistence, nil, mtm, nil, func() {}, nil) // Both org managers must reject the private target before the update. for _, orgID := range []string{"org-a", "org-b"} { m, err := mtm.GetMonitor(orgID) if err != nil { t.Fatalf("GetMonitor(%s): %v", orgID, err) } if err := m.GetNotificationManager().ValidateWebhookURL("http://192.0.2.10:9999/hook"); err == nil { t.Fatalf("%s: expected private webhook target to be rejected before allowlist update", orgID) } } body := bytes.NewBufferString(`{"webhookAllowedPrivateCIDRs":"192.0.2.0/24"}`) req := httptest.NewRequest(http.MethodPost, "/api/system/settings/update", body) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-API-Token", tokenVal) rec := httptest.NewRecorder() h.HandleUpdateSystemSettings(rec, req) if rec.Code != http.StatusOK { t.Fatalf("HandleUpdateSystemSettings responded %d: %s", rec.Code, rec.Body.String()) } // Every live tenant manager must observe the new allowlist, regardless of // which org the admin's request context pointed at. for _, orgID := range []string{"org-a", "org-b"} { m, err := mtm.GetMonitor(orgID) if err != nil { t.Fatalf("GetMonitor(%s): %v", orgID, err) } if err := m.GetNotificationManager().ValidateWebhookURL("http://192.0.2.10:9999/hook"); err != nil { t.Fatalf("%s: expected private webhook target to be allowed after allowlist update, got %v", orgID, err) } } }