package api import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/pkg/auth" ) type allowRulesAuthorizer struct { rules map[string]bool } func (a *allowRulesAuthorizer) Authorize(_ context.Context, action string, resource string) (bool, error) { if a == nil { return false, nil } return a.rules[action+":"+resource], nil } func TestSecurityStatusIgnoresInvalidTokenHeader(t *testing.T) { rawToken := "status-valid-token-123.12345678" record := newTokenRecord(t, rawToken, nil, 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", "invalid-token") 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 for invalid token, got %q", hint) } if _, ok := payload["tokenScopes"]; ok { t.Fatalf("expected tokenScopes to be omitted for invalid token") } } func TestSecurityStatusIgnoresBearerTokenHeader(t *testing.T) { rawToken := "status-bearer-token-123.12345678" record := newTokenRecord(t, rawToken, nil, 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("Authorization", "Bearer "+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 hint, ok := payload["apiTokenHint"].(string); ok && hint != "" { t.Fatalf("expected apiTokenHint to be empty for bearer token, got %q", hint) } if _, ok := payload["tokenScopes"]; ok { t.Fatalf("expected tokenScopes to be omitted for bearer token") } } func TestSecurityStatusExposesPersistedSSOProvider(t *testing.T) { cfg := newTestConfigWithTokens(t) ssoCfg := config.NewSSOConfig() if err := ssoCfg.AddProvider(config.SSOProvider{ ID: "test-oidc", Name: "Test OIDC", Type: config.SSOProviderTypeOIDC, Enabled: true, OIDC: &config.OIDCProviderConfig{ IssuerURL: "https://id.example.test", ClientID: "pulse-client", ClientSecret: "secret", }, }); err != nil { t.Fatalf("failed to add SSO provider: %v", err) } if err := config.NewConfigPersistence(cfg.DataPath).SaveSSOConfig(ssoCfg); err != nil { t.Fatalf("failed to persist SSO config: %v", err) } router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/security/status", 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) } rawProviders, ok := payload["ssoProviders"].([]interface{}) if !ok || len(rawProviders) == 0 { t.Fatalf("expected persisted SSO providers in security status, got %#v", payload["ssoProviders"]) } firstProvider, ok := rawProviders[0].(map[string]interface{}) if !ok { t.Fatalf("expected provider object, got %#v", rawProviders[0]) } if got := firstProvider["id"]; got != "test-oidc" { t.Fatalf("provider id = %v, want test-oidc", got) } if got := firstProvider["type"]; got != "oidc" { t.Fatalf("provider type = %v, want oidc", got) } if got := firstProvider["loginUrl"]; got != "/api/oidc/test-oidc/login" { t.Fatalf("provider loginUrl = %v, want /api/oidc/test-oidc/login", got) } } func TestSecurityStatusExposesLegacyOIDCEnvProvider(t *testing.T) { t.Setenv("OIDC_ENABLED", "true") t.Setenv("OIDC_ISSUER_URL", "https://id.example.test") t.Setenv("OIDC_CLIENT_ID", "pulse-client") t.Setenv("OIDC_CLIENT_SECRET", "secret") cfg := newTestConfigWithTokens(t) cfg.PublicURL = "https://pulse.example.test" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/security/status", 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) } rawProviders, ok := payload["ssoProviders"].([]interface{}) if !ok || len(rawProviders) != 1 { t.Fatalf("expected one legacy OIDC provider in security status, got %#v", payload["ssoProviders"]) } firstProvider, ok := rawProviders[0].(map[string]interface{}) if !ok { t.Fatalf("expected provider object, got %#v", rawProviders[0]) } if got := firstProvider["id"]; got != config.LegacyOIDCProviderID { t.Fatalf("provider id = %v, want %s", got, config.LegacyOIDCProviderID) } if got := firstProvider["displayName"]; got != "Single Sign-On" { t.Fatalf("provider displayName = %v, want Single Sign-On", got) } if got := firstProvider["loginUrl"]; got != "/api/oidc/legacy-oidc/login" { t.Fatalf("provider loginUrl = %v, want /api/oidc/legacy-oidc/login", got) } } func TestSecurityStatusExposesSSOSessionPrincipalAndDisplayName(t *testing.T) { resetSessionStoreForTests() resetSessionTracking() t.Cleanup(resetSessionStoreForTests) t.Cleanup(resetSessionTracking) cfg := newTestConfigWithTokens(t) ssoCfg := config.NewSSOConfig() if err := ssoCfg.AddProvider(config.SSOProvider{ ID: "test-oidc", Name: "Test OIDC", Type: config.SSOProviderTypeOIDC, Enabled: true, OIDC: &config.OIDCProviderConfig{ IssuerURL: "https://id.example.test", ClientID: "pulse-client", ClientSecret: "secret", }, }); err != nil { t.Fatalf("failed to add SSO provider: %v", err) } if err := config.NewConfigPersistence(cfg.DataPath).SaveSSOConfig(ssoCfg); err != nil { t.Fatalf("failed to persist SSO config: %v", err) } router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") sessionToken := "status-sso-display-token" principal := "sso:oidc:test-oidc:stable-principal" displayUsername := "alice@example.com" GetSessionStore().CreateOIDCSessionWithDisplayName(sessionToken, time.Hour, "agent", "127.0.0.1", principal, displayUsername, &OIDCTokenInfo{ Issuer: "https://id.example.test", ClientID: "pulse-client", }) req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil) req.AddCookie(&http.Cookie{Name: cookieNameSession, Value: sessionToken}) rec := httptest.NewRecorder() router.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 for security status, got %d: %s", rec.Code, rec.Body.String()) } var payload struct { SSOEnabled bool `json:"ssoEnabled"` SSOSessionUsername string `json:"ssoSessionUsername"` SSOSessionDisplayName string `json:"ssoSessionDisplayName"` } if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode response: %v", err) } if !payload.SSOEnabled { t.Fatal("expected SSO to be enabled") } if payload.SSOSessionUsername != principal { t.Fatalf("ssoSessionUsername = %q, want %q", payload.SSOSessionUsername, principal) } if payload.SSOSessionDisplayName != displayUsername { t.Fatalf("ssoSessionDisplayName = %q, want %q", payload.SSOSessionDisplayName, displayUsername) } } func TestSecurityStatusExposesSettingsCapabilitiesForScopedToken(t *testing.T) { prevAuthorizer := auth.GetAuthorizer() auth.SetAuthorizer(&allowRulesAuthorizer{ rules: map[string]bool{ "admin:users": true, "read:audit_logs": true, "admin:audit_logs": true, }, }) defer auth.SetAuthorizer(prevAuthorizer) rawToken := "status-cap-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead, config.ScopeSettingsWrite, config.ScopeAuditRead}, 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 struct { SettingsCapabilities securityStatusSettingsCapabilities `json:"settingsCapabilities"` TokenScopes []string `json:"tokenScopes"` } if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode response: %v", err) } if !payload.SettingsCapabilities.APIAccessRead || !payload.SettingsCapabilities.APIAccessWrite { t.Fatalf("expected api access capabilities for scoped admin token") } if !payload.SettingsCapabilities.AuthenticationRead { t.Fatalf("expected authentication read capability for settings admin token") } if !payload.SettingsCapabilities.AuthenticationWrite { t.Fatalf("expected authentication write capability for settings admin token") } if !payload.SettingsCapabilities.SingleSignOnRead || !payload.SettingsCapabilities.SingleSignOnWrite { t.Fatalf("expected single sign-on capabilities for scoped admin token") } if !payload.SettingsCapabilities.Roles || !payload.SettingsCapabilities.Users { t.Fatalf("expected RBAC capabilities for scoped admin token") } if !payload.SettingsCapabilities.AuditLog { t.Fatalf("expected audit log capability for scoped admin token") } if !payload.SettingsCapabilities.AuditWebhooksRead || !payload.SettingsCapabilities.AuditWebhooksWrite { t.Fatalf("expected audit webhook capabilities for scoped admin token") } if !payload.SettingsCapabilities.RelayRead || !payload.SettingsCapabilities.RelayWrite { t.Fatalf("expected relay capabilities for settings admin token") } if payload.SettingsCapabilities.BillingAdmin { t.Fatalf("did not expect billingAdmin capability for API token") } if len(payload.TokenScopes) != 3 { t.Fatalf("expected token scopes in response, got %#v", payload.TokenScopes) } } func TestSecurityStatusSplitsReadAndWriteSettingsCapabilities(t *testing.T) { prevAuthorizer := auth.GetAuthorizer() auth.SetAuthorizer(&allowRulesAuthorizer{ rules: map[string]bool{ "admin:users": true, "read:audit_logs": true, "admin:audit_logs": true, }, }) defer auth.SetAuthorizer(prevAuthorizer) rawToken := "status-read-only-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.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 struct { SettingsCapabilities securityStatusSettingsCapabilities `json:"settingsCapabilities"` } if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode response: %v", err) } if !payload.SettingsCapabilities.APIAccessRead || payload.SettingsCapabilities.APIAccessWrite { t.Fatalf("expected api access to be read-only, got %#v", payload.SettingsCapabilities) } if !payload.SettingsCapabilities.AuthenticationRead || payload.SettingsCapabilities.AuthenticationWrite { t.Fatalf("expected authentication to be read-only, got %#v", payload.SettingsCapabilities) } if !payload.SettingsCapabilities.SingleSignOnRead || payload.SettingsCapabilities.SingleSignOnWrite { t.Fatalf("expected single sign-on to be read-only, got %#v", payload.SettingsCapabilities) } if !payload.SettingsCapabilities.AuditWebhooksRead || payload.SettingsCapabilities.AuditWebhooksWrite { t.Fatalf("expected audit webhooks to be read-only, got %#v", payload.SettingsCapabilities) } if !payload.SettingsCapabilities.RelayRead || payload.SettingsCapabilities.RelayWrite { t.Fatalf("expected relay to be read-only, got %#v", payload.SettingsCapabilities) } } func TestSecurityStatusRequiresAuditReadScopeForAuditLogCapability(t *testing.T) { prevAuthorizer := auth.GetAuthorizer() auth.SetAuthorizer(&allowRulesAuthorizer{ rules: map[string]bool{ "read:audit_logs": true, "admin:audit_logs": true, }, }) defer auth.SetAuthorizer(prevAuthorizer) rawToken := "status-audit-split-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.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 struct { SettingsCapabilities securityStatusSettingsCapabilities `json:"settingsCapabilities"` } if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode response: %v", err) } if payload.SettingsCapabilities.AuditLog { t.Fatalf("did not expect audit log capability without audit:read scope") } if !payload.SettingsCapabilities.AuditWebhooksRead { t.Fatalf("expected audit webhook read capability to remain settings-scoped") } } func TestSecurityStatusUsesOwnerBoundTokenPrincipalForRBAC(t *testing.T) { prevAuthorizer := auth.GetAuthorizer() auth.SetAuthorizer(&mockAuthorizerFn{ fn: func(ctx context.Context, action string, resource string) (bool, error) { return auth.GetUser(ctx) == "alice" && action == "admin" && resource == "users", nil }, }) defer auth.SetAuthorizer(prevAuthorizer) rawToken := "status-owner-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead, config.ScopeSettingsWrite}, nil) record.Metadata = map[string]string{ apiTokenMetadataOwnerUserID: "alice", } 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 struct { SettingsCapabilities securityStatusSettingsCapabilities `json:"settingsCapabilities"` } if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode response: %v", err) } if !payload.SettingsCapabilities.APIAccessRead || !payload.SettingsCapabilities.APIAccessWrite { t.Fatalf("expected owner-bound token to inherit RBAC settings capabilities, got %#v", payload.SettingsCapabilities) } if !payload.SettingsCapabilities.Roles || !payload.SettingsCapabilities.Users { t.Fatalf("expected owner-bound token to inherit RBAC user-management capabilities, got %#v", payload.SettingsCapabilities) } } func TestSecurityStatusUsesRBACForProxyCapabilities(t *testing.T) { prevAuthorizer := auth.GetAuthorizer() auth.SetAuthorizer(&allowRulesAuthorizer{ rules: map[string]bool{ "admin:users": true, }, }) defer auth.SetAuthorizer(prevAuthorizer) t.Setenv("PULSE_DATA_DIR", t.TempDir()) cfg := &config.Config{ ProxyAuthSecret: "proxy-secret", ProxyAuthUserHeader: "X-Proxy-User", ProxyAuthRoleHeader: "X-Proxy-Roles", ProxyAuthAdminRole: "admin", } router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil) req.Header.Set("X-Proxy-Secret", "proxy-secret") req.Header.Set("X-Proxy-User", "viewer") req.Header.Set("X-Proxy-Roles", "viewer") 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 struct { SettingsCapabilities securityStatusSettingsCapabilities `json:"settingsCapabilities"` } if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode response: %v", err) } if payload.SettingsCapabilities.AuthenticationRead { t.Fatalf("did not expect authentication capability for non-admin proxy user") } if !payload.SettingsCapabilities.SingleSignOnRead || !payload.SettingsCapabilities.SingleSignOnWrite { t.Fatalf("expected SSO capabilities for RBAC-authorized proxy user") } if !payload.SettingsCapabilities.Roles || !payload.SettingsCapabilities.Users { t.Fatalf("expected RBAC management capabilities for RBAC-authorized proxy user") } if payload.SettingsCapabilities.RelayRead || payload.SettingsCapabilities.RelayWrite { t.Fatalf("did not expect relay capabilities for non-admin proxy user") } } func TestSecurityStatusRestrictsSessionCapabilitiesToConfiguredAdmin(t *testing.T) { prevAuthorizer := auth.GetAuthorizer() auth.SetAuthorizer(&allowRulesAuthorizer{ rules: map[string]bool{ "admin:users": true, "read:audit_logs": true, }, }) defer auth.SetAuthorizer(prevAuthorizer) cfg := newTestConfigWithTokens(t) cfg.AuthUser = "admin" cfg.AuthPass = "hash" router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") sessionToken := "session-capabilities-token" GetSessionStore().CreateSession(sessionToken, time.Hour, "agent", "127.0.0.1", "member") req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil) req.AddCookie(&http.Cookie{Name: cookieNameSession, Value: sessionToken}) 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 struct { SettingsCapabilities securityStatusSettingsCapabilities `json:"settingsCapabilities"` } if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode response: %v", err) } if payload.SettingsCapabilities.AuthenticationRead { t.Fatalf("did not expect authentication capability for non-admin session user") } if payload.SettingsCapabilities.AuthenticationWrite { t.Fatalf("did not expect authentication write capability for non-admin session user") } if payload.SettingsCapabilities.Roles || payload.SettingsCapabilities.Users { t.Fatalf("did not expect RBAC capabilities for non-admin session user") } if payload.SettingsCapabilities.AuditLog { t.Fatalf("did not expect audit capability for non-admin session user") } } func TestSecurityStatusIncludesDemoModeSessionCapabilities(t *testing.T) { t.Setenv("PULSE_DATA_DIR", t.TempDir()) rawToken := "status-demo-capabilities-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := &config.Config{DemoMode: true, APITokens: []config.APITokenRecord{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 struct { SessionCapabilities securityStatusSessionCapabilities `json:"sessionCapabilities"` PresentationPolicy securityStatusPresentationPolicy `json:"presentationPolicy"` } if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode response: %v", err) } if !payload.SessionCapabilities.DemoMode { t.Fatalf("expected sessionCapabilities.demoMode to be true, got %#v", payload.SessionCapabilities) } if payload.SessionCapabilities.AssistantEnabled { t.Fatalf("expected sessionCapabilities.assistantEnabled to default false, got %#v", payload.SessionCapabilities) } if !payload.PresentationPolicy.DemoMode || !payload.PresentationPolicy.ReadOnly || !payload.PresentationPolicy.HideCommercial || !payload.PresentationPolicy.HideUpgrade { t.Fatalf("expected demo presentation policy, got %#v", payload.PresentationPolicy) } } func TestSecurityStatusIncludesAssistantAvailability(t *testing.T) { t.Setenv("PULSE_DATA_DIR", t.TempDir()) rawToken := "status-assistant-capabilities-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) cfg := &config.Config{APITokens: []config.APITokenRecord{record}} router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") persistence := router.aiSettingsHandler.getPersistence(context.Background()) if persistence == nil { t.Fatal("expected AI persistence for security status test") } if err := persistence.SaveAIConfig(config.AIConfig{ Enabled: true, OpenAIAPIKey: "sk-test", }); err != nil { t.Fatalf("SaveAIConfig(): %v", err) } 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 struct { SessionCapabilities securityStatusSessionCapabilities `json:"sessionCapabilities"` } if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode response: %v", err) } if !payload.SessionCapabilities.AssistantEnabled { t.Fatalf("expected sessionCapabilities.assistantEnabled to be true, got %#v", payload.SessionCapabilities) } }