Pulse/internal/api/security_status_additional_test.go

458 lines
16 KiB
Go

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 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}, 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) != 2 {
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 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())
cfg := &config.Config{DemoMode: true}
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 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())
cfg := &config.Config{}
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)
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)
}
}