diff --git a/.github/instructions/ARCHITECTURE.instructions.md b/.github/instructions/ARCHITECTURE.instructions.md index 82c2a95c..b60eedda 100644 --- a/.github/instructions/ARCHITECTURE.instructions.md +++ b/.github/instructions/ARCHITECTURE.instructions.md @@ -130,7 +130,7 @@ graph TB | **WebSocket** | gorilla/websocket | Latest | Real-time log streaming | | **Crypto** | golang.org/x/crypto | Latest | Password hashing, encryption | | **Metrics** | Prometheus Client | Latest | Application metrics | -| **Notifications** | Shoutrrr | Latest | Multi-platform alerts | +| **Notifications** | Notify | Latest | Multi-platform alerts | | **Docker Client** | Docker SDK | Latest | Container discovery | | **Logging** | Logrus + Lumberjack | Latest | Structured logging with rotation | @@ -1263,8 +1263,8 @@ docker exec charon /app/scripts/restore-backup.sh \ - Future: Dynamic plugin loading for custom providers 2. **Notification Channels:** - - Shoutrrr provides 40+ channels (Discord, Slack, Email, etc.) - - Custom channels via Shoutrrr service URLs + - Notify provides multi-platform channels (Discord, Slack, Gotify, etc.) + - Provider-based configuration with per-channel feature flags 3. **Authentication Providers:** - Current: Local database authentication diff --git a/.gitignore b/.gitignore index 515443b2..f9747c9d 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,11 @@ backend/node_modules/ backend/package.json backend/package-lock.json +# Root-level artifact files (non-documentation) +FIREFOX_E2E_FIXES_SUMMARY.md +verify-security-state-for-ui-tests +categories.txt + # ----------------------------------------------------------------------------- # Databases # ----------------------------------------------------------------------------- @@ -297,6 +302,7 @@ docs/plans/current_spec_notes.md tests/etc/passwd trivy-image-report.json trivy-fs-report.json +trivy-report.json backend/# Tools Configuration.md docs/plans/requirements.md docs/plans/design.md diff --git a/backend/internal/api/handlers/feature_flags_blocker3_test.go b/backend/internal/api/handlers/feature_flags_blocker3_test.go index a3863f0b..25cfe9dd 100644 --- a/backend/internal/api/handlers/feature_flags_blocker3_test.go +++ b/backend/internal/api/handlers/feature_flags_blocker3_test.go @@ -1,7 +1,6 @@ package handlers import ( - "bytes" "encoding/json" "net/http" "net/http/httptest" @@ -127,179 +126,3 @@ func TestBlocker3_SecurityProviderEventsFlagCanBeEnabled(t *testing.T) { assert.True(t, response["feature.notifications.security_provider_events.enabled"], "security_provider_events flag should be true when enabled in DB") } - -// TestLegacyFallbackRemoved_UpdateFlagsRejectsTrue tests that attempting to set legacy fallback to true returns error code LEGACY_FALLBACK_REMOVED. -func TestLegacyFallbackRemoved_UpdateFlagsRejectsTrue(t *testing.T) { - gin.SetMode(gin.TestMode) - - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - assert.NoError(t, err) - assert.NoError(t, db.AutoMigrate(&models.Setting{})) - - handler := NewFeatureFlagsHandler(db) - - // Attempt to set legacy fallback to true - payload := map[string]bool{ - "feature.notifications.legacy.fallback_enabled": true, - } - jsonPayload, err := json.Marshal(payload) - assert.NoError(t, err) - - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request, _ = http.NewRequest("PUT", "/api/v1/feature-flags", bytes.NewBuffer(jsonPayload)) - c.Request.Header.Set("Content-Type", "application/json") - - handler.UpdateFlags(c) - - // Must return 400 with code LEGACY_FALLBACK_REMOVED - assert.Equal(t, http.StatusBadRequest, w.Code) - - var response map[string]interface{} - err = json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err) - assert.Contains(t, response["error"], "retired") - assert.Equal(t, "LEGACY_FALLBACK_REMOVED", response["code"]) -} - -// TestLegacyFallbackRemoved_UpdateFlagsAcceptsFalse tests that setting legacy fallback to false is allowed (forced false). -func TestLegacyFallbackRemoved_UpdateFlagsAcceptsFalse(t *testing.T) { - gin.SetMode(gin.TestMode) - - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - assert.NoError(t, err) - assert.NoError(t, db.AutoMigrate(&models.Setting{})) - - handler := NewFeatureFlagsHandler(db) - - // Set legacy fallback to false (should be accepted and forced) - payload := map[string]bool{ - "feature.notifications.legacy.fallback_enabled": false, - } - jsonPayload, err := json.Marshal(payload) - assert.NoError(t, err) - - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request, _ = http.NewRequest("PUT", "/api/v1/feature-flags", bytes.NewBuffer(jsonPayload)) - c.Request.Header.Set("Content-Type", "application/json") - - handler.UpdateFlags(c) - - assert.Equal(t, http.StatusOK, w.Code) - - // Verify in DB that it's false - var setting models.Setting - db.Where("key = ?", "feature.notifications.legacy.fallback_enabled").First(&setting) - assert.Equal(t, "false", setting.Value) -} - -// TestLegacyFallbackRemoved_GetFlagsReturnsHardFalse tests that GET always returns false for legacy fallback. -func TestLegacyFallbackRemoved_GetFlagsReturnsHardFalse(t *testing.T) { - gin.SetMode(gin.TestMode) - - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - assert.NoError(t, err) - assert.NoError(t, db.AutoMigrate(&models.Setting{})) - - handler := NewFeatureFlagsHandler(db) - - // Scenario 1: No DB entry - t.Run("no_db_entry", func(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request, _ = http.NewRequest("GET", "/api/v1/feature-flags", nil) - - handler.GetFlags(c) - - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]bool - err = json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err) - assert.False(t, response["feature.notifications.legacy.fallback_enabled"], "Must return hard-false when no DB entry") - }) - - // Scenario 2: DB entry says true (invalid, forced false) - t.Run("db_entry_true", func(t *testing.T) { - // Force a true value in DB (simulating legacy state) - setting := models.Setting{ - Key: "feature.notifications.legacy.fallback_enabled", - Value: "true", - Type: "bool", - Category: "feature", - } - db.Create(&setting) - - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request, _ = http.NewRequest("GET", "/api/v1/feature-flags", nil) - - handler.GetFlags(c) - - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]bool - err = json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err) - assert.False(t, response["feature.notifications.legacy.fallback_enabled"], "Must return hard-false even when DB says true") - - // Clean up - db.Unscoped().Delete(&setting) - }) - - // Scenario 3: DB entry says false - t.Run("db_entry_false", func(t *testing.T) { - setting := models.Setting{ - Key: "feature.notifications.legacy.fallback_enabled", - Value: "false", - Type: "bool", - Category: "feature", - } - db.Create(&setting) - - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request, _ = http.NewRequest("GET", "/api/v1/feature-flags", nil) - - handler.GetFlags(c) - - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]bool - err = json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err) - assert.False(t, response["feature.notifications.legacy.fallback_enabled"], "Must return hard-false when DB says false") - - // Clean up - db.Unscoped().Delete(&setting) - }) -} - -// TestLegacyFallbackRemoved_InvalidEnvValue tests that invalid environment variable values are handled (lines 157-158) -func TestLegacyFallbackRemoved_InvalidEnvValue(t *testing.T) { - gin.SetMode(gin.TestMode) - - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - assert.NoError(t, err) - assert.NoError(t, db.AutoMigrate(&models.Setting{})) - - // Set invalid environment variable value - t.Setenv("CHARON_NOTIFICATIONS_LEGACY_FALLBACK", "invalid-value") - - handler := NewFeatureFlagsHandler(db) - - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request, _ = http.NewRequest("GET", "/api/v1/feature-flags", nil) - - // Lines 157-158: Should log warning for invalid env value and return hard-false - handler.GetFlags(c) - - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]bool - err = json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err) - assert.False(t, response["feature.notifications.legacy.fallback_enabled"], "Must return hard-false even with invalid env value") -} diff --git a/backend/internal/api/handlers/feature_flags_coverage_v2_test.go b/backend/internal/api/handlers/feature_flags_coverage_v2_test.go deleted file mode 100644 index bf7359a2..00000000 --- a/backend/internal/api/handlers/feature_flags_coverage_v2_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/Wikid82/charon/backend/internal/models" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gorm.io/driver/sqlite" - "gorm.io/gorm" -) - -// TestResolveRetiredLegacyFallback_InvalidPersistedValue covers lines 139-140 -func TestResolveRetiredLegacyFallback_InvalidPersistedValue(t *testing.T) { - gin.SetMode(gin.TestMode) - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - require.NoError(t, err) - - require.NoError(t, db.AutoMigrate(&models.Setting{})) - - // Create setting with invalid value for retired fallback flag - db.Create(&models.Setting{ - Key: "feature.notifications.legacy.fallback_enabled", - Value: "invalid_value_not_bool", - Type: "bool", - Category: "feature", - }) - - h := NewFeatureFlagsHandler(db) - r := gin.New() - r.GET("/api/v1/feature-flags", h.GetFlags) - - req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - require.Equal(t, http.StatusOK, w.Code) - - // Should log warning and return false (lines 139-140) - var flags map[string]bool - err = json.Unmarshal(w.Body.Bytes(), &flags) - require.NoError(t, err) - - assert.False(t, flags["feature.notifications.legacy.fallback_enabled"]) -} - -// TestResolveRetiredLegacyFallback_InvalidEnvValue covers lines 149-150 -func TestResolveRetiredLegacyFallback_InvalidEnvValue(t *testing.T) { - gin.SetMode(gin.TestMode) - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - require.NoError(t, err) - - require.NoError(t, db.AutoMigrate(&models.Setting{})) - - // Set invalid env var for retired fallback flag - t.Setenv("CHARON_LEGACY_FALLBACK_ENABLED", "not_a_boolean") - - h := NewFeatureFlagsHandler(db) - r := gin.New() - r.GET("/api/v1/feature-flags", h.GetFlags) - - req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - require.Equal(t, http.StatusOK, w.Code) - - // Should log warning and return false (lines 149-150) - var flags map[string]bool - err = json.Unmarshal(w.Body.Bytes(), &flags) - require.NoError(t, err) - - assert.False(t, flags["feature.notifications.legacy.fallback_enabled"]) -} - -// TestResolveRetiredLegacyFallback_DefaultFalse covers lines 157-158 -func TestResolveRetiredLegacyFallback_DefaultFalse(t *testing.T) { - gin.SetMode(gin.TestMode) - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - require.NoError(t, err) - - require.NoError(t, db.AutoMigrate(&models.Setting{})) - - // No DB value, no env vars - should default to false - h := NewFeatureFlagsHandler(db) - r := gin.New() - r.GET("/api/v1/feature-flags", h.GetFlags) - - req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - require.Equal(t, http.StatusOK, w.Code) - - // Should return false (lines 157-158) - var flags map[string]bool - err = json.Unmarshal(w.Body.Bytes(), &flags) - require.NoError(t, err) - - assert.False(t, flags["feature.notifications.legacy.fallback_enabled"]) -} diff --git a/backend/internal/api/handlers/feature_flags_handler.go b/backend/internal/api/handlers/feature_flags_handler.go index dd991326..b7dfd8f8 100644 --- a/backend/internal/api/handlers/feature_flags_handler.go +++ b/backend/internal/api/handlers/feature_flags_handler.go @@ -32,7 +32,6 @@ var defaultFlags = []string{ "feature.notifications.service.discord.enabled", "feature.notifications.service.gotify.enabled", "feature.notifications.service.webhook.enabled", - "feature.notifications.legacy.fallback_enabled", "feature.notifications.security_provider_events.enabled", // Blocker 3: Add security_provider_events gate } @@ -44,15 +43,9 @@ var defaultFlagValues = map[string]bool{ "feature.notifications.service.discord.enabled": false, "feature.notifications.service.gotify.enabled": false, "feature.notifications.service.webhook.enabled": false, - "feature.notifications.legacy.fallback_enabled": false, "feature.notifications.security_provider_events.enabled": false, // Blocker 3: Default disabled for this stage } -var retiredLegacyFallbackEnvAliases = []string{ - "FEATURE_NOTIFICATIONS_LEGACY_FALLBACK_ENABLED", - "NOTIFICATIONS_LEGACY_FALLBACK_ENABLED", -} - // GetFlags returns a map of feature flag -> bool. DB setting takes precedence // and falls back to environment variables if present. func (h *FeatureFlagsHandler) GetFlags(c *gin.Context) { @@ -86,11 +79,6 @@ func (h *FeatureFlagsHandler) GetFlags(c *gin.Context) { defaultVal = v } - if key == "feature.notifications.legacy.fallback_enabled" { - result[key] = h.resolveRetiredLegacyFallback(settingsMap) - continue - } - // Check if flag exists in DB if s, exists := settingsMap[key]; exists { v := strings.ToLower(strings.TrimSpace(s.Value)) @@ -131,40 +119,6 @@ func (h *FeatureFlagsHandler) GetFlags(c *gin.Context) { c.JSON(http.StatusOK, result) } -func parseFlagBool(raw string) (bool, bool) { - v := strings.ToLower(strings.TrimSpace(raw)) - switch v { - case "1", "true", "yes": - return true, true - case "0", "false", "no": - return false, true - default: - return false, false - } -} - -func (h *FeatureFlagsHandler) resolveRetiredLegacyFallback(settingsMap map[string]models.Setting) bool { - const retiredKey = "feature.notifications.legacy.fallback_enabled" - - if s, exists := settingsMap[retiredKey]; exists { - if _, ok := parseFlagBool(s.Value); !ok { - log.Printf("[WARN] Invalid persisted retired fallback flag value, forcing disabled: key=%s value=%q", retiredKey, s.Value) - } - return false - } - - for _, alias := range retiredLegacyFallbackEnvAliases { - if ev, ok := os.LookupEnv(alias); ok { - if _, parsed := parseFlagBool(ev); !parsed { - log.Printf("[WARN] Invalid environment retired fallback flag value, forcing disabled: key=%s value=%q", alias, ev) - } - return false - } - } - - return false -} - // UpdateFlags accepts a JSON object map[string]bool and upserts settings. func (h *FeatureFlagsHandler) UpdateFlags(c *gin.Context) { // Phase 0: Performance instrumentation @@ -180,14 +134,6 @@ func (h *FeatureFlagsHandler) UpdateFlags(c *gin.Context) { return } - if v, exists := payload["feature.notifications.legacy.fallback_enabled"]; exists && v { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "feature.notifications.legacy.fallback_enabled is retired and can only be false", - "code": "LEGACY_FALLBACK_REMOVED", - }) - return - } - // Phase 1: Transaction wrapping - all updates in single atomic transaction if err := h.DB.Transaction(func(tx *gorm.DB) error { for k, v := range payload { @@ -203,10 +149,6 @@ func (h *FeatureFlagsHandler) UpdateFlags(c *gin.Context) { continue } - if k == "feature.notifications.legacy.fallback_enabled" { - v = false - } - s := models.Setting{Key: k, Value: strconv.FormatBool(v), Type: "bool", Category: "feature"} if err := tx.Where(models.Setting{Key: k}).Assign(s).FirstOrCreate(&s).Error; err != nil { return err // Rollback on error diff --git a/backend/internal/api/handlers/feature_flags_handler_test.go b/backend/internal/api/handlers/feature_flags_handler_test.go index 771921ff..90881451 100644 --- a/backend/internal/api/handlers/feature_flags_handler_test.go +++ b/backend/internal/api/handlers/feature_flags_handler_test.go @@ -100,147 +100,6 @@ func TestFeatureFlags_EnvFallback(t *testing.T) { } } -func TestFeatureFlags_RetiredFallback_DenyByDefault(t *testing.T) { - db := setupFlagsDB(t) - h := NewFeatureFlagsHandler(db) - - gin.SetMode(gin.TestMode) - r := gin.New() - r.GET("/api/v1/feature-flags", h.GetFlags) - - req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200 got %d body=%s", w.Code, w.Body.String()) - } - - var flags map[string]bool - if err := json.Unmarshal(w.Body.Bytes(), &flags); err != nil { - t.Fatalf("invalid json: %v", err) - } - - if flags["feature.notifications.legacy.fallback_enabled"] { - t.Fatalf("expected retired fallback flag to be false by default") - } -} - -func TestFeatureFlags_RetiredFallback_PersistedAndEnvStillResolveFalse(t *testing.T) { - db := setupFlagsDB(t) - - if err := db.Create(&models.Setting{ - Key: "feature.notifications.legacy.fallback_enabled", - Value: "true", - Type: "bool", - Category: "feature", - }).Error; err != nil { - t.Fatalf("failed to seed setting: %v", err) - } - - t.Setenv("FEATURE_NOTIFICATIONS_LEGACY_FALLBACK_ENABLED", "true") - - h := NewFeatureFlagsHandler(db) - gin.SetMode(gin.TestMode) - r := gin.New() - r.GET("/api/v1/feature-flags", h.GetFlags) - - req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200 got %d body=%s", w.Code, w.Body.String()) - } - - var flags map[string]bool - if err := json.Unmarshal(w.Body.Bytes(), &flags); err != nil { - t.Fatalf("invalid json: %v", err) - } - - if flags["feature.notifications.legacy.fallback_enabled"] { - t.Fatalf("expected retired fallback flag to remain false even when persisted/env are true") - } -} - -func TestFeatureFlags_RetiredFallback_EnvAliasResolvesFalse(t *testing.T) { - db := setupFlagsDB(t) - t.Setenv("NOTIFICATIONS_LEGACY_FALLBACK_ENABLED", "true") - - h := NewFeatureFlagsHandler(db) - gin.SetMode(gin.TestMode) - r := gin.New() - r.GET("/api/v1/feature-flags", h.GetFlags) - - req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("expected 200 got %d body=%s", w.Code, w.Body.String()) - } - - var flags map[string]bool - if err := json.Unmarshal(w.Body.Bytes(), &flags); err != nil { - t.Fatalf("invalid json: %v", err) - } - - if flags["feature.notifications.legacy.fallback_enabled"] { - t.Fatalf("expected retired fallback flag to remain false for env alias") - } -} - -func TestFeatureFlags_UpdateRejectsLegacyFallbackTrue(t *testing.T) { - db := setupFlagsDB(t) - h := NewFeatureFlagsHandler(db) - - gin.SetMode(gin.TestMode) - r := gin.New() - r.PUT("/api/v1/feature-flags", h.UpdateFlags) - - payload := map[string]bool{ - "feature.notifications.legacy.fallback_enabled": true, - } - b, _ := json.Marshal(payload) - - req := httptest.NewRequest(http.MethodPut, "/api/v1/feature-flags", bytes.NewReader(b)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("expected 400 got %d body=%s", w.Code, w.Body.String()) - } -} - -func TestFeatureFlags_UpdatePersistsLegacyFallbackFalse(t *testing.T) { - db := setupFlagsDB(t) - h := NewFeatureFlagsHandler(db) - - gin.SetMode(gin.TestMode) - r := gin.New() - r.PUT("/api/v1/feature-flags", h.UpdateFlags) - - payload := map[string]bool{ - "feature.notifications.legacy.fallback_enabled": false, - } - b, _ := json.Marshal(payload) - - req := httptest.NewRequest(http.MethodPut, "/api/v1/feature-flags", bytes.NewReader(b)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200 got %d body=%s", w.Code, w.Body.String()) - } - - var s models.Setting - if err := db.Where("key = ?", "feature.notifications.legacy.fallback_enabled").First(&s).Error; err != nil { - t.Fatalf("expected setting persisted: %v", err) - } - if s.Value != "false" { - t.Fatalf("expected persisted fallback value false, got %s", s.Value) - } -} - // setupBenchmarkFlagsDB creates an in-memory SQLite database for feature flags benchmarks func setupBenchmarkFlagsDB(b *testing.B) *gorm.DB { b.Helper() @@ -428,32 +287,3 @@ func TestUpdateFlags_TransactionAtomic(t *testing.T) { t.Errorf("expected crowdsec.console_enrollment to be true, got %s", s3.Value) } } - -// TestFeatureFlags_InvalidRetiredEnvAlias covers lines 157-158 (invalid env var warning) -func TestFeatureFlags_InvalidRetiredEnvAlias(t *testing.T) { - db := setupFlagsDB(t) - t.Setenv("NOTIFICATIONS_LEGACY_FALLBACK_ENABLED", "invalid-value") - - h := NewFeatureFlagsHandler(db) - gin.SetMode(gin.TestMode) - r := gin.New() - r.GET("/api/v1/feature-flags", h.GetFlags) - - req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200 got %d body=%s", w.Code, w.Body.String()) - } - - var flags map[string]bool - if err := json.Unmarshal(w.Body.Bytes(), &flags); err != nil { - t.Fatalf("invalid json: %v", err) - } - - // Should force disabled due to invalid value (lines 157-158) - if flags["feature.notifications.legacy.fallback_enabled"] { - t.Fatalf("expected retired fallback flag to be false for invalid env value") - } -} diff --git a/backend/internal/api/handlers/security_notifications_test.go.archived b/backend/internal/api/handlers/security_notifications_test.go.archived deleted file mode 100644 index 1fc3f8df..00000000 --- a/backend/internal/api/handlers/security_notifications_test.go.archived +++ /dev/null @@ -1,681 +0,0 @@ -package handlers - -import ( - "bytes" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "testing" - - "github.com/Wikid82/charon/backend/internal/models" - "github.com/Wikid82/charon/backend/internal/services" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gorm.io/driver/sqlite" - "gorm.io/gorm" -) - -// mockSecurityNotificationService implements the service interface for controlled testing. -type mockSecurityNotificationService struct { - getSettingsFunc func() (*models.NotificationConfig, error) - updateSettingsFunc func(*models.NotificationConfig) error -} - -func (m *mockSecurityNotificationService) GetSettings() (*models.NotificationConfig, error) { - if m.getSettingsFunc != nil { - return m.getSettingsFunc() - } - return &models.NotificationConfig{}, nil -} - -func (m *mockSecurityNotificationService) UpdateSettings(c *models.NotificationConfig) error { - if m.updateSettingsFunc != nil { - return m.updateSettingsFunc(c) - } - return nil -} - -func setupSecNotifTestDB(t *testing.T) *gorm.DB { - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.NotificationConfig{})) - return db -} - -// TestNewSecurityNotificationHandler verifies constructor returns non-nil handler. -func TestNewSecurityNotificationHandler(t *testing.T) { - t.Parallel() - - db := setupSecNotifTestDB(t) - svc := services.NewSecurityNotificationService(db) - handler := NewSecurityNotificationHandler(svc) - - assert.NotNil(t, handler, "Handler should not be nil") -} - -// TestSecurityNotificationHandler_GetSettings_Success tests successful settings retrieval. -func TestSecurityNotificationHandler_GetSettings_Success(t *testing.T) { - t.Parallel() - - expectedConfig := &models.NotificationConfig{ - ID: "test-id", - Enabled: true, - MinLogLevel: "warn", - WebhookURL: "https://example.com/webhook", - NotifyWAFBlocks: true, - NotifyACLDenies: false, - } - - mockService := &mockSecurityNotificationService{ - getSettingsFunc: func() (*models.NotificationConfig, error) { - return expectedConfig, nil - }, - } - - handler := NewSecurityNotificationHandler(mockService) - - gin.SetMode(gin.TestMode) - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest("GET", "/api/v1/security/notifications/settings", http.NoBody) - - handler.GetSettings(c) - - assert.Equal(t, http.StatusOK, w.Code) - - var config models.NotificationConfig - err := json.Unmarshal(w.Body.Bytes(), &config) - require.NoError(t, err) - - assert.Equal(t, expectedConfig.ID, config.ID) - assert.Equal(t, expectedConfig.Enabled, config.Enabled) - assert.Equal(t, expectedConfig.MinLogLevel, config.MinLogLevel) - assert.Equal(t, expectedConfig.WebhookURL, config.WebhookURL) - assert.Equal(t, expectedConfig.NotifyWAFBlocks, config.NotifyWAFBlocks) - assert.Equal(t, expectedConfig.NotifyACLDenies, config.NotifyACLDenies) -} - -// TestSecurityNotificationHandler_GetSettings_ServiceError tests service error handling. -func TestSecurityNotificationHandler_GetSettings_ServiceError(t *testing.T) { - t.Parallel() - - mockService := &mockSecurityNotificationService{ - getSettingsFunc: func() (*models.NotificationConfig, error) { - return nil, errors.New("database connection failed") - }, - } - - handler := NewSecurityNotificationHandler(mockService) - - gin.SetMode(gin.TestMode) - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest("GET", "/api/v1/security/notifications/settings", http.NoBody) - - handler.GetSettings(c) - - assert.Equal(t, http.StatusInternalServerError, w.Code) - - var response map[string]string - err := json.Unmarshal(w.Body.Bytes(), &response) - require.NoError(t, err) - - assert.Contains(t, response["error"], "Failed to retrieve settings") -} - -// TestSecurityNotificationHandler_UpdateSettings_InvalidJSON tests malformed JSON handling. -func TestSecurityNotificationHandler_UpdateSettings_InvalidJSON(t *testing.T) { - t.Parallel() - - mockService := &mockSecurityNotificationService{} - handler := NewSecurityNotificationHandler(mockService) - - malformedJSON := []byte(`{enabled: true, "min_log_level": "error"`) - - gin.SetMode(gin.TestMode) - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - setAdminContext(c) - c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(malformedJSON)) - c.Request.Header.Set("Content-Type", "application/json") - - handler.UpdateSettings(c) - - assert.Equal(t, http.StatusBadRequest, w.Code) - - var response map[string]string - err := json.Unmarshal(w.Body.Bytes(), &response) - require.NoError(t, err) - - assert.Contains(t, response["error"], "Invalid request body") -} - -// TestSecurityNotificationHandler_UpdateSettings_InvalidMinLogLevel tests invalid log level rejection. -func TestSecurityNotificationHandler_UpdateSettings_InvalidMinLogLevel(t *testing.T) { - t.Parallel() - - invalidLevels := []struct { - name string - level string - }{ - {"trace", "trace"}, - {"critical", "critical"}, - {"fatal", "fatal"}, - {"unknown", "unknown"}, - } - - for _, tc := range invalidLevels { - t.Run(tc.name, func(t *testing.T) { - mockService := &mockSecurityNotificationService{} - handler := NewSecurityNotificationHandler(mockService) - - config := models.NotificationConfig{ - Enabled: true, - MinLogLevel: tc.level, - NotifyWAFBlocks: true, - } - - body, err := json.Marshal(config) - require.NoError(t, err) - - gin.SetMode(gin.TestMode) - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - setAdminContext(c) - c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body)) - c.Request.Header.Set("Content-Type", "application/json") - - handler.UpdateSettings(c) - - assert.Equal(t, http.StatusBadRequest, w.Code) - - var response map[string]string - err = json.Unmarshal(w.Body.Bytes(), &response) - require.NoError(t, err) - - assert.Contains(t, response["error"], "Invalid min_log_level") - }) - } -} - -// TestSecurityNotificationHandler_UpdateSettings_InvalidWebhookURL_SSRF tests SSRF protection. -func TestSecurityNotificationHandler_UpdateSettings_InvalidWebhookURL_SSRF(t *testing.T) { - t.Parallel() - - ssrfURLs := []struct { - name string - url string - }{ - {"AWS Metadata", "http://169.254.169.254/latest/meta-data/"}, - {"GCP Metadata", "http://metadata.google.internal/computeMetadata/v1/"}, - {"Azure Metadata", "http://169.254.169.254/metadata/instance"}, - {"Private IP 10.x", "http://10.0.0.1/admin"}, - {"Private IP 172.16.x", "http://172.16.0.1/config"}, - {"Private IP 192.168.x", "http://192.168.1.1/api"}, - {"Link-local", "http://169.254.1.1/"}, - } - - for _, tc := range ssrfURLs { - t.Run(tc.name, func(t *testing.T) { - mockService := &mockSecurityNotificationService{} - handler := NewSecurityNotificationHandler(mockService) - - config := models.NotificationConfig{ - Enabled: true, - MinLogLevel: "error", - WebhookURL: tc.url, - NotifyWAFBlocks: true, - } - - body, err := json.Marshal(config) - require.NoError(t, err) - - gin.SetMode(gin.TestMode) - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - setAdminContext(c) - c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body)) - c.Request.Header.Set("Content-Type", "application/json") - - handler.UpdateSettings(c) - - assert.Equal(t, http.StatusBadRequest, w.Code) - - var response map[string]interface{} - err = json.Unmarshal(w.Body.Bytes(), &response) - require.NoError(t, err) - - assert.Contains(t, response["error"], "Invalid webhook URL") - if help, ok := response["help"]; ok { - assert.Contains(t, help, "private networks") - } - }) - } -} - -// TestSecurityNotificationHandler_UpdateSettings_PrivateIPWebhook tests private IP handling. -func TestSecurityNotificationHandler_UpdateSettings_PrivateIPWebhook(t *testing.T) { - t.Parallel() - - // Note: localhost is allowed by WithAllowLocalhost() option - localhostURLs := []string{ - "http://127.0.0.1/hook", - "http://localhost/webhook", - "http://[::1]/api", - } - - for _, url := range localhostURLs { - t.Run(url, func(t *testing.T) { - mockService := &mockSecurityNotificationService{ - updateSettingsFunc: func(c *models.NotificationConfig) error { - return nil - }, - } - handler := NewSecurityNotificationHandler(mockService) - - config := models.NotificationConfig{ - Enabled: true, - MinLogLevel: "warn", - WebhookURL: url, - } - - body, err := json.Marshal(config) - require.NoError(t, err) - - gin.SetMode(gin.TestMode) - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - setAdminContext(c) - c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body)) - c.Request.Header.Set("Content-Type", "application/json") - - handler.UpdateSettings(c) - - // Localhost should be allowed with AllowLocalhost option - assert.Equal(t, http.StatusOK, w.Code, "Localhost should be allowed: %s", url) - }) - } -} - -// TestSecurityNotificationHandler_UpdateSettings_ServiceError tests database error handling. -func TestSecurityNotificationHandler_UpdateSettings_ServiceError(t *testing.T) { - t.Parallel() - - mockService := &mockSecurityNotificationService{ - updateSettingsFunc: func(c *models.NotificationConfig) error { - return errors.New("database write failed") - }, - } - - handler := NewSecurityNotificationHandler(mockService) - - config := models.NotificationConfig{ - Enabled: true, - MinLogLevel: "error", - WebhookURL: "http://localhost:9090/webhook", // Use localhost - NotifyWAFBlocks: true, - } - - body, err := json.Marshal(config) - require.NoError(t, err) - - gin.SetMode(gin.TestMode) - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - setAdminContext(c) - c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body)) - c.Request.Header.Set("Content-Type", "application/json") - - handler.UpdateSettings(c) - - assert.Equal(t, http.StatusInternalServerError, w.Code) - - var response map[string]string - err = json.Unmarshal(w.Body.Bytes(), &response) - require.NoError(t, err) - - assert.Contains(t, response["error"], "Failed to update settings") -} - -// TestSecurityNotificationHandler_UpdateSettings_Success tests successful settings update. -func TestSecurityNotificationHandler_UpdateSettings_Success(t *testing.T) { - t.Parallel() - - var capturedConfig *models.NotificationConfig - - mockService := &mockSecurityNotificationService{ - updateSettingsFunc: func(c *models.NotificationConfig) error { - capturedConfig = c - return nil - }, - } - - handler := NewSecurityNotificationHandler(mockService) - - config := models.NotificationConfig{ - Enabled: true, - MinLogLevel: "warn", - WebhookURL: "http://localhost:8080/security", // Use localhost which is allowed - NotifyWAFBlocks: true, - NotifyACLDenies: false, - } - - body, err := json.Marshal(config) - require.NoError(t, err) - - gin.SetMode(gin.TestMode) - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - setAdminContext(c) - c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body)) - c.Request.Header.Set("Content-Type", "application/json") - - handler.UpdateSettings(c) - - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]string - err = json.Unmarshal(w.Body.Bytes(), &response) - require.NoError(t, err) - - assert.Equal(t, "Settings updated successfully", response["message"]) - - // Verify the service was called with the correct config - require.NotNil(t, capturedConfig) - assert.Equal(t, config.Enabled, capturedConfig.Enabled) - assert.Equal(t, config.MinLogLevel, capturedConfig.MinLogLevel) - assert.Equal(t, config.WebhookURL, capturedConfig.WebhookURL) - assert.Equal(t, config.NotifyWAFBlocks, capturedConfig.NotifyWAFBlocks) - assert.Equal(t, config.NotifyACLDenies, capturedConfig.NotifyACLDenies) -} - -// TestSecurityNotificationHandler_UpdateSettings_EmptyWebhookURL tests empty webhook is valid. -func TestSecurityNotificationHandler_UpdateSettings_EmptyWebhookURL(t *testing.T) { - t.Parallel() - - mockService := &mockSecurityNotificationService{ - updateSettingsFunc: func(c *models.NotificationConfig) error { - return nil - }, - } - - handler := NewSecurityNotificationHandler(mockService) - - config := models.NotificationConfig{ - Enabled: true, - MinLogLevel: "info", - WebhookURL: "", - NotifyWAFBlocks: true, - NotifyACLDenies: true, - } - - body, err := json.Marshal(config) - require.NoError(t, err) - - gin.SetMode(gin.TestMode) - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - setAdminContext(c) - c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body)) - c.Request.Header.Set("Content-Type", "application/json") - - handler.UpdateSettings(c) - - assert.Equal(t, http.StatusOK, w.Code) - - var response map[string]string - err = json.Unmarshal(w.Body.Bytes(), &response) - require.NoError(t, err) - - assert.Equal(t, "Settings updated successfully", response["message"]) -} - -func TestSecurityNotificationHandler_RouteAliasGet(t *testing.T) { - t.Parallel() - - expectedConfig := &models.NotificationConfig{ - ID: "alias-test-id", - Enabled: true, - MinLogLevel: "info", - WebhookURL: "https://example.com/webhook", - NotifyWAFBlocks: true, - NotifyACLDenies: true, - } - - mockService := &mockSecurityNotificationService{ - getSettingsFunc: func() (*models.NotificationConfig, error) { - return expectedConfig, nil - }, - } - - handler := NewSecurityNotificationHandler(mockService) - - gin.SetMode(gin.TestMode) - router := gin.New() - router.GET("/api/v1/security/notifications/settings", handler.GetSettings) - router.GET("/api/v1/notifications/settings/security", handler.GetSettings) - - originalWriter := httptest.NewRecorder() - originalRequest := httptest.NewRequest(http.MethodGet, "/api/v1/security/notifications/settings", http.NoBody) - router.ServeHTTP(originalWriter, originalRequest) - - aliasWriter := httptest.NewRecorder() - aliasRequest := httptest.NewRequest(http.MethodGet, "/api/v1/notifications/settings/security", http.NoBody) - router.ServeHTTP(aliasWriter, aliasRequest) - - assert.Equal(t, http.StatusOK, originalWriter.Code) - assert.Equal(t, originalWriter.Code, aliasWriter.Code) - assert.Equal(t, originalWriter.Body.String(), aliasWriter.Body.String()) -} - -func TestSecurityNotificationHandler_RouteAliasUpdate(t *testing.T) { - t.Parallel() - - legacyUpdates := 0 - canonicalUpdates := 0 - mockService := &mockSecurityNotificationService{ - updateSettingsFunc: func(c *models.NotificationConfig) error { - if c.WebhookURL == "http://localhost:8080/security" { - canonicalUpdates++ - } - return nil - }, - } - - handler := NewSecurityNotificationHandler(mockService) - - config := models.NotificationConfig{ - Enabled: true, - MinLogLevel: "warn", - WebhookURL: "http://localhost:8080/security", - NotifyWAFBlocks: true, - NotifyACLDenies: false, - } - - body, err := json.Marshal(config) - require.NoError(t, err) - - gin.SetMode(gin.TestMode) - router := gin.New() - router.Use(func(c *gin.Context) { - setAdminContext(c) - c.Next() - }) - router.PUT("/api/v1/security/notifications/settings", handler.DeprecatedUpdateSettings) - router.PUT("/api/v1/notifications/settings/security", handler.UpdateSettings) - - originalWriter := httptest.NewRecorder() - originalRequest := httptest.NewRequest(http.MethodPut, "/api/v1/security/notifications/settings", bytes.NewBuffer(body)) - originalRequest.Header.Set("Content-Type", "application/json") - router.ServeHTTP(originalWriter, originalRequest) - - aliasWriter := httptest.NewRecorder() - aliasRequest := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/settings/security", bytes.NewBuffer(body)) - aliasRequest.Header.Set("Content-Type", "application/json") - router.ServeHTTP(aliasWriter, aliasRequest) - - assert.Equal(t, http.StatusGone, originalWriter.Code) - assert.Equal(t, "true", originalWriter.Header().Get("X-Charon-Deprecated")) - assert.Equal(t, "/api/v1/notifications/settings/security", originalWriter.Header().Get("X-Charon-Canonical-Endpoint")) - - assert.Equal(t, http.StatusOK, aliasWriter.Code) - assert.Equal(t, 0, legacyUpdates) - assert.Equal(t, 1, canonicalUpdates) -} - -func TestSecurityNotificationHandler_DeprecatedRouteHeaders(t *testing.T) { - t.Parallel() - - mockService := &mockSecurityNotificationService{ - getSettingsFunc: func() (*models.NotificationConfig, error) { - return &models.NotificationConfig{Enabled: true, MinLogLevel: "warn"}, nil - }, - updateSettingsFunc: func(c *models.NotificationConfig) error { - return nil - }, - } - - handler := NewSecurityNotificationHandler(mockService) - - gin.SetMode(gin.TestMode) - router := gin.New() - router.Use(func(c *gin.Context) { - setAdminContext(c) - c.Next() - }) - router.GET("/api/v1/security/notifications/settings", handler.DeprecatedGetSettings) - router.PUT("/api/v1/security/notifications/settings", handler.DeprecatedUpdateSettings) - router.GET("/api/v1/notifications/settings/security", handler.GetSettings) - router.PUT("/api/v1/notifications/settings/security", handler.UpdateSettings) - - legacyGet := httptest.NewRecorder() - legacyGetReq := httptest.NewRequest(http.MethodGet, "/api/v1/security/notifications/settings", http.NoBody) - router.ServeHTTP(legacyGet, legacyGetReq) - require.Equal(t, http.StatusOK, legacyGet.Code) - assert.Equal(t, "true", legacyGet.Header().Get("X-Charon-Deprecated")) - assert.Equal(t, "/api/v1/notifications/settings/security", legacyGet.Header().Get("X-Charon-Canonical-Endpoint")) - - canonicalGet := httptest.NewRecorder() - canonicalGetReq := httptest.NewRequest(http.MethodGet, "/api/v1/notifications/settings/security", http.NoBody) - router.ServeHTTP(canonicalGet, canonicalGetReq) - require.Equal(t, http.StatusOK, canonicalGet.Code) - assert.Empty(t, canonicalGet.Header().Get("X-Charon-Deprecated")) - - body, err := json.Marshal(models.NotificationConfig{Enabled: true, MinLogLevel: "warn"}) - require.NoError(t, err) - - legacyPut := httptest.NewRecorder() - legacyPutReq := httptest.NewRequest(http.MethodPut, "/api/v1/security/notifications/settings", bytes.NewBuffer(body)) - legacyPutReq.Header.Set("Content-Type", "application/json") - router.ServeHTTP(legacyPut, legacyPutReq) - require.Equal(t, http.StatusGone, legacyPut.Code) - assert.Equal(t, "true", legacyPut.Header().Get("X-Charon-Deprecated")) - assert.Equal(t, "/api/v1/notifications/settings/security", legacyPut.Header().Get("X-Charon-Canonical-Endpoint")) - - var legacyBody map[string]string - err = json.Unmarshal(legacyPut.Body.Bytes(), &legacyBody) - require.NoError(t, err) - assert.Len(t, legacyBody, 2) - assert.Equal(t, "This endpoint is deprecated and no longer accepts updates", legacyBody["error"]) - assert.Equal(t, "/api/v1/notifications/settings/security", legacyBody["canonical_endpoint"]) - - canonicalPut := httptest.NewRecorder() - canonicalPutReq := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/settings/security", bytes.NewBuffer(body)) - canonicalPutReq.Header.Set("Content-Type", "application/json") - router.ServeHTTP(canonicalPut, canonicalPutReq) - require.Equal(t, http.StatusOK, canonicalPut.Code) -} - -func TestNormalizeEmailRecipients(t *testing.T) { - tests := []struct { - name string - input string - want string - wantErr string - }{ - { - name: "empty input", - input: " ", - want: "", - }, - { - name: "single valid", - input: "admin@example.com", - want: "admin@example.com", - }, - { - name: "multiple valid with spaces and blanks", - input: " admin@example.com, , ops@example.com ,security@example.com ", - want: "admin@example.com, ops@example.com, security@example.com", - }, - { - name: "duplicates and mixed case preserved", - input: "Admin@Example.com, admin@example.com, Admin@Example.com", - want: "Admin@Example.com, admin@example.com, Admin@Example.com", - }, - { - name: "invalid only", - input: "not-an-email", - wantErr: "invalid email recipients: not-an-email", - }, - { - name: "mixed invalid and valid", - input: "admin@example.com, bad-address,ops@example.com", - wantErr: "invalid email recipients: bad-address", - }, - { - name: "multiple invalids", - input: "bad-address,also-bad", - wantErr: "invalid email recipients: bad-address, also-bad", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := normalizeEmailRecipients(tt.input) - if tt.wantErr != "" { - require.Error(t, err) - assert.Equal(t, tt.wantErr, err.Error()) - return - } - - require.NoError(t, err) - assert.Equal(t, tt.want, got) - }) - } -} - -// TestSecurityNotificationHandler_DeprecatedUpdateSettings_AllFields tests that all JSON fields are returned -func TestSecurityNotificationHandler_DeprecatedUpdateSettings_AllFields(t *testing.T) { - t.Parallel() - - mockService := &mockSecurityNotificationService{} - handler := NewSecurityNotificationHandler(mockService) - - body, err := json.Marshal(models.NotificationConfig{Enabled: true, MinLogLevel: "warn"}) - require.NoError(t, err) - - gin.SetMode(gin.TestMode) - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - setAdminContext(c) - c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/security/notifications/settings", bytes.NewBuffer(body)) - c.Request.Header.Set("Content-Type", "application/json") - - handler.DeprecatedUpdateSettings(c) - - assert.Equal(t, http.StatusGone, w.Code) - assert.Equal(t, "true", w.Header().Get("X-Charon-Deprecated")) - assert.Equal(t, "/api/v1/notifications/settings/security", w.Header().Get("X-Charon-Canonical-Endpoint")) - - var response map[string]string - err = json.Unmarshal(w.Body.Bytes(), &response) - require.NoError(t, err) - - // Verify both JSON fields are present with exact values - assert.Equal(t, "This endpoint is deprecated and no longer accepts updates", response["error"]) - assert.Equal(t, "/api/v1/notifications/settings/security", response["canonical_endpoint"]) - assert.Len(t, response, 2, "Should have exactly 2 fields in JSON response") -} diff --git a/backend/internal/api/handlers/settings_handler.go b/backend/internal/api/handlers/settings_handler.go index 935cd9d8..8f6cde94 100644 --- a/backend/internal/api/handlers/settings_handler.go +++ b/backend/internal/api/handlers/settings_handler.go @@ -131,16 +131,6 @@ func (h *SettingsHandler) UpdateSetting(c *gin.Context) { return } - // Block legacy fallback flag writes (LEGACY_FALLBACK_REMOVED) - if req.Key == "feature.notifications.legacy.fallback_enabled" && - strings.EqualFold(strings.TrimSpace(req.Value), "true") { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Legacy fallback has been removed and cannot be re-enabled", - "code": "LEGACY_FALLBACK_REMOVED", - }) - return - } - if req.Key == "security.admin_whitelist" { if err := validateAdminWhitelist(req.Value); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid admin_whitelist: %v", err)}) @@ -279,12 +269,6 @@ func (h *SettingsHandler) PatchConfig(c *gin.Context) { if err := h.DB.Transaction(func(tx *gorm.DB) error { for key, value := range updates { - // Block legacy fallback flag writes (LEGACY_FALLBACK_REMOVED) - if key == "feature.notifications.legacy.fallback_enabled" && - strings.EqualFold(strings.TrimSpace(value), "true") { - return fmt.Errorf("legacy fallback has been removed and cannot be re-enabled") - } - if key == "security.admin_whitelist" { if err := validateAdminWhitelist(value); err != nil { return fmt.Errorf("invalid admin_whitelist: %w", err) @@ -321,13 +305,6 @@ func (h *SettingsHandler) PatchConfig(c *gin.Context) { return nil }); err != nil { - if strings.Contains(err.Error(), "legacy fallback has been removed") { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Legacy fallback has been removed and cannot be re-enabled", - "code": "LEGACY_FALLBACK_REMOVED", - }) - return - } if errors.Is(err, services.ErrInvalidAdminCIDR) || strings.Contains(err.Error(), "invalid admin_whitelist") { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid admin_whitelist"}) return diff --git a/backend/internal/api/handlers/settings_handler_test.go b/backend/internal/api/handlers/settings_handler_test.go index f36a28d3..b8d5ae6d 100644 --- a/backend/internal/api/handlers/settings_handler_test.go +++ b/backend/internal/api/handlers/settings_handler_test.go @@ -516,81 +516,6 @@ func TestSettingsHandler_UpdateSetting_SecurityKeyInvalidatesCache(t *testing.T) assert.Equal(t, 1, mgr.calls) } -func TestSettingsHandler_UpdateSetting_BlocksLegacyFallbackFlag(t *testing.T) { - gin.SetMode(gin.TestMode) - db := setupSettingsTestDB(t) - - handler := handlers.NewSettingsHandler(db) - router := newAdminRouter() - router.POST("/settings", handler.UpdateSetting) - - testCases := []struct { - name string - value string - }{ - {"true lowercase", "true"}, - {"true uppercase", "TRUE"}, - {"true mixed case", "True"}, - {"true with whitespace", " true "}, - {"true with tabs", "\ttrue\t"}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - payload := map[string]string{ - "key": "feature.notifications.legacy.fallback_enabled", - "value": tc.value, - } - body, _ := json.Marshal(payload) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - var resp map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(t, err) - assert.Contains(t, resp["error"], "Legacy fallback has been removed") - assert.Equal(t, "LEGACY_FALLBACK_REMOVED", resp["code"]) - - // Verify flag was not saved to database - var setting models.Setting - err = db.Where("key = ?", "feature.notifications.legacy.fallback_enabled").First(&setting).Error - assert.Error(t, err) // Should not exist - }) - } -} - -func TestSettingsHandler_UpdateSetting_AllowsLegacyFallbackFlagFalse(t *testing.T) { - gin.SetMode(gin.TestMode) - db := setupSettingsTestDB(t) - - handler := handlers.NewSettingsHandler(db) - router := newAdminRouter() - router.POST("/settings", handler.UpdateSetting) - - payload := map[string]string{ - "key": "feature.notifications.legacy.fallback_enabled", - "value": "false", - } - body, _ := json.Marshal(payload) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - // Verify flag was saved to database with false value - var setting models.Setting - err := db.Where("key = ?", "feature.notifications.legacy.fallback_enabled").First(&setting).Error - assert.NoError(t, err) - assert.Equal(t, "false", setting.Value) -} - func TestSettingsHandler_PatchConfig_InvalidAdminWhitelist(t *testing.T) { gin.SetMode(gin.TestMode) db := setupSettingsTestDB(t) @@ -774,98 +699,6 @@ func TestSettingsHandler_PatchConfig_EnablesCerberusWhenACLEnabled(t *testing.T) assert.True(t, cfg.Enabled) } -func TestSettingsHandler_PatchConfig_BlocksLegacyFallbackFlag(t *testing.T) { - gin.SetMode(gin.TestMode) - db := setupSettingsTestDB(t) - - handler := handlers.NewSettingsHandler(db) - router := newAdminRouter() - router.PATCH("/config", handler.PatchConfig) - - testCases := []struct { - name string - payload map[string]any - }{ - {"nested true", map[string]any{ - "feature": map[string]any{ - "notifications": map[string]any{ - "legacy": map[string]any{ - "fallback_enabled": true, - }, - }, - }, - }}, - {"flat key true", map[string]any{ - "feature.notifications.legacy.fallback_enabled": "true", - }}, - {"nested string true", map[string]any{ - "feature": map[string]any{ - "notifications": map[string]any{ - "legacy": map[string]any{ - "fallback_enabled": "true", - }, - }, - }, - }}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - body, _ := json.Marshal(tc.payload) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("PATCH", "/config", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - var resp map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(t, err) - assert.Contains(t, resp["error"], "Legacy fallback has been removed") - assert.Equal(t, "LEGACY_FALLBACK_REMOVED", resp["code"]) - - // Verify flag was not saved to database - var setting models.Setting - err = db.Where("key = ?", "feature.notifications.legacy.fallback_enabled").First(&setting).Error - assert.Error(t, err) // Should not exist - }) - } -} - -func TestSettingsHandler_PatchConfig_AllowsLegacyFallbackFlagFalse(t *testing.T) { - gin.SetMode(gin.TestMode) - db := setupSettingsTestDB(t) - - handler := handlers.NewSettingsHandler(db) - router := newAdminRouter() - router.PATCH("/config", handler.PatchConfig) - - payload := map[string]any{ - "feature": map[string]any{ - "notifications": map[string]any{ - "legacy": map[string]any{ - "fallback_enabled": false, - }, - }, - }, - } - body, _ := json.Marshal(payload) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("PATCH", "/config", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - // Verify flag was saved to database with false value - var setting models.Setting - err := db.Where("key = ?", "feature.notifications.legacy.fallback_enabled").First(&setting).Error - assert.NoError(t, err) - assert.Equal(t, "false", setting.Value) -} - func TestSettingsHandler_UpdateSetting_DatabaseError(t *testing.T) { gin.SetMode(gin.TestMode) db := setupSettingsTestDB(t) diff --git a/backend/internal/models/notification_config.go b/backend/internal/models/notification_config.go index 044bdcc0..7bfed565 100644 --- a/backend/internal/models/notification_config.go +++ b/backend/internal/models/notification_config.go @@ -18,7 +18,6 @@ type NotificationConfig struct { NotifyACLDenies bool `json:"security_acl_enabled"` NotifyRateLimitHits bool `json:"security_rate_limit_enabled"` NotifyCrowdSecDecisions bool `json:"security_crowdsec_enabled"` - EmailRecipients string `json:"email_recipients"` // Legacy destination fields (compatibility, not stored in DB) DiscordWebhookURL string `gorm:"-" json:"discord_webhook_url,omitempty"` diff --git a/backend/internal/notifications/engine.go b/backend/internal/notifications/engine.go index 6b320d73..b94f6fd8 100644 --- a/backend/internal/notifications/engine.go +++ b/backend/internal/notifications/engine.go @@ -3,7 +3,6 @@ package notifications import "context" const ( - EngineLegacy = "legacy" EngineNotifyV1 = "notify_v1" ) diff --git a/backend/internal/notifications/router.go b/backend/internal/notifications/router.go index 5c19aa02..afa17a97 100644 --- a/backend/internal/notifications/router.go +++ b/backend/internal/notifications/router.go @@ -2,21 +2,18 @@ package notifications import "strings" +// NOTE: used only in tests type Router struct{} func NewRouter() *Router { return &Router{} } -func (r *Router) ShouldUseNotify(providerType, providerEngine string, flags map[string]bool) bool { +func (r *Router) ShouldUseNotify(providerType string, flags map[string]bool) bool { if !flags[FlagNotifyEngineEnabled] { return false } - if strings.EqualFold(providerEngine, EngineLegacy) { - return false - } - switch strings.ToLower(providerType) { case "discord": return flags[FlagDiscordServiceEnabled] @@ -28,10 +25,3 @@ func (r *Router) ShouldUseNotify(providerType, providerEngine string, flags map[ return false } } - -func (r *Router) ShouldUseLegacyFallback(flags map[string]bool) bool { - // Hard-disabled: Legacy fallback has been permanently removed. - // This function exists only for interface compatibility and always returns false. - _ = flags // Explicitly ignore flags to prevent accidental re-introduction - return false -} diff --git a/backend/internal/notifications/router_test.go b/backend/internal/notifications/router_test.go index a8ea1a44..0d4ea894 100644 --- a/backend/internal/notifications/router_test.go +++ b/backend/internal/notifications/router_test.go @@ -10,37 +10,15 @@ func TestRouter_ShouldUseNotify(t *testing.T) { FlagDiscordServiceEnabled: true, } - if !router.ShouldUseNotify("discord", EngineNotifyV1, flags) { + if !router.ShouldUseNotify("discord", flags) { t.Fatalf("expected notify routing for discord when enabled") } - if router.ShouldUseNotify("discord", EngineLegacy, flags) { - t.Fatalf("expected legacy engine to stay on legacy path") - } - - if router.ShouldUseNotify("telegram", EngineNotifyV1, flags) { + if router.ShouldUseNotify("telegram", flags) { t.Fatalf("expected unsupported service to remain legacy") } } -func TestRouter_ShouldUseLegacyFallback(t *testing.T) { - router := NewRouter() - - if router.ShouldUseLegacyFallback(map[string]bool{}) { - t.Fatalf("expected fallback disabled by default") - } - - // Note: FlagLegacyFallbackEnabled constant has been removed as part of hard-disable - // Using string literal for test completeness - if router.ShouldUseLegacyFallback(map[string]bool{"feature.notifications.legacy.fallback_enabled": false}) { - t.Fatalf("expected fallback disabled when flag is false") - } - - if router.ShouldUseLegacyFallback(map[string]bool{"feature.notifications.legacy.fallback_enabled": true}) { - t.Fatalf("expected fallback disabled even when flag is true (hard-disabled)") - } -} - // TestRouter_ShouldUseNotify_EngineDisabled covers lines 13-14 func TestRouter_ShouldUseNotify_EngineDisabled(t *testing.T) { router := NewRouter() @@ -50,7 +28,7 @@ func TestRouter_ShouldUseNotify_EngineDisabled(t *testing.T) { FlagDiscordServiceEnabled: true, } - if router.ShouldUseNotify("discord", EngineNotifyV1, flags) { + if router.ShouldUseNotify("discord", flags) { t.Fatalf("expected notify routing disabled when FlagNotifyEngineEnabled is false") } } @@ -64,7 +42,7 @@ func TestRouter_ShouldUseNotify_DiscordServiceFlag(t *testing.T) { FlagDiscordServiceEnabled: false, } - if router.ShouldUseNotify("discord", EngineNotifyV1, flags) { + if router.ShouldUseNotify("discord", flags) { t.Fatalf("expected notify routing disabled for discord when FlagDiscordServiceEnabled is false") } } @@ -79,14 +57,14 @@ func TestRouter_ShouldUseNotify_GotifyServiceFlag(t *testing.T) { FlagGotifyServiceEnabled: true, } - if !router.ShouldUseNotify("gotify", EngineNotifyV1, flags) { + if !router.ShouldUseNotify("gotify", flags) { t.Fatalf("expected notify routing enabled for gotify when FlagGotifyServiceEnabled is true") } // Test with gotify disabled flags[FlagGotifyServiceEnabled] = false - if router.ShouldUseNotify("gotify", EngineNotifyV1, flags) { + if router.ShouldUseNotify("gotify", flags) { t.Fatalf("expected notify routing disabled for gotify when FlagGotifyServiceEnabled is false") } } @@ -99,12 +77,12 @@ func TestRouter_ShouldUseNotify_WebhookServiceFlag(t *testing.T) { FlagWebhookServiceEnabled: true, } - if !router.ShouldUseNotify("webhook", EngineNotifyV1, flags) { + if !router.ShouldUseNotify("webhook", flags) { t.Fatalf("expected notify routing enabled for webhook when FlagWebhookServiceEnabled is true") } flags[FlagWebhookServiceEnabled] = false - if router.ShouldUseNotify("webhook", EngineNotifyV1, flags) { + if router.ShouldUseNotify("webhook", flags) { t.Fatalf("expected notify routing disabled for webhook when FlagWebhookServiceEnabled is false") } } diff --git a/backend/internal/services/enhanced_security_notification_service_discord_only_test.go b/backend/internal/services/enhanced_security_notification_service_discord_only_test.go index a05230f4..5a969c88 100644 --- a/backend/internal/services/enhanced_security_notification_service_discord_only_test.go +++ b/backend/internal/services/enhanced_security_notification_service_discord_only_test.go @@ -8,7 +8,6 @@ import ( "testing" "github.com/Wikid82/charon/backend/internal/models" - "github.com/Wikid82/charon/backend/internal/notifications" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" @@ -170,41 +169,6 @@ func TestDiscordOnly_SendViaProvidersFiltersNonDiscord(t *testing.T) { _ = originalDispatch // Suppress unused warning } -// TestNoFallbackPath_RouterAlwaysReturnsFalse tests that the router never enables legacy fallback. -func TestNoFallbackPath_RouterAlwaysReturnsFalse(t *testing.T) { - // Import router to test actual routing behavior - router := notifications.NewRouter() - - testCases := []struct { - name string - flags map[string]bool - }{ - {"no_flags", map[string]bool{}}, - {"fallback_false", map[string]bool{"feature.notifications.legacy.fallback_enabled": false}}, - {"fallback_true", map[string]bool{"feature.notifications.legacy.fallback_enabled": true}}, - {"all_enabled", map[string]bool{ - "feature.notifications.legacy.fallback_enabled": true, - "feature.notifications.engine.notify_v1.enabled": true, - "feature.notifications.service.discord.enabled": true, - }}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - // Concrete assertion: Router always returns false regardless of flag state - shouldFallback := router.ShouldUseLegacyFallback(tc.flags) - assert.False(t, shouldFallback, - "Router must return false for all flag combinations - legacy fallback is permanently disabled") - - // Proof: Even when flag is explicitly true, router returns false - if tc.flags["feature.notifications.legacy.fallback_enabled"] { - assert.False(t, shouldFallback, - "Router ignores legacy fallback flag and always returns false") - } - }) - } -} - // TestNoFallbackPath_ServiceHasNoLegacyDispatchHooks tests that the service has no legacy dispatch hooks. func TestNoFallbackPath_ServiceHasNoLegacyDispatchHooks(t *testing.T) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index f6b84544..724f4f9d 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "fmt" "net" "net/http" @@ -56,12 +55,6 @@ func normalizeURL(serviceType, rawURL string) string { return rawURL } -var ErrLegacyFallbackDisabled = errors.New("legacy fallback is retired and disabled") - -func legacyFallbackInvocationError(providerType string) error { - return fmt.Errorf("%w: provider type %q is not supported by notify-only runtime", ErrLegacyFallbackDisabled, providerType) -} - func validateDiscordWebhookURL(rawURL string) error { parsedURL, err := neturl.Parse(rawURL) if err != nil { @@ -232,8 +225,7 @@ func (s *NotificationService) SendExternal(ctx context.Context, eventType, title } go func(p models.NotificationProvider) { if !supportsJSONTemplates(p.Type) { - err := legacyFallbackInvocationError(p.Type) - logger.Log().WithError(err).WithField("provider", util.SanitizeForLog(p.Name)).Error("Notify-only runtime blocked legacy fallback invocation") + logger.Log().WithField("provider", util.SanitizeForLog(p.Name)).WithField("type", p.Type).Warn("Provider type is not supported by notify-only runtime") return } @@ -244,12 +236,6 @@ func (s *NotificationService) SendExternal(ctx context.Context, eventType, title } } -// legacySendFunc is a test hook for outbound sends. -// In notify-only mode this path is retired and always fails closed. -var legacySendFunc = func(_ string, _ string) error { - return ErrLegacyFallbackDisabled -} - // webhookDoRequestFunc is a test hook for outbound JSON webhook requests. // In production it defaults to (*http.Client).Do. var webhookDoRequestFunc = func(client *http.Client, req *http.Request) (*http.Response, error) { @@ -464,7 +450,7 @@ func (s *NotificationService) TestProvider(provider models.NotificationProvider) } if !supportsJSONTemplates(providerType) { - return legacyFallbackInvocationError(providerType) + return fmt.Errorf("provider type %q does not support JSON templates", providerType) } data := map[string]any{ diff --git a/backend/internal/services/notification_service_json_test.go b/backend/internal/services/notification_service_json_test.go index 261895e3..fb68a8d9 100644 --- a/backend/internal/services/notification_service_json_test.go +++ b/backend/internal/services/notification_service_json_test.go @@ -425,35 +425,6 @@ func TestNormalizeURL_DiscordWebhook_ConvertsToDiscordScheme(t *testing.T) { assert.Equal(t, "discord://xyz@456", got2) } -func TestSendExternal_SkipsInvalidHTTPDestination(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) - require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.NotificationProvider{})) - - // Provider with invalid HTTP destination should be skipped before send. - require.NoError(t, db.Create(&models.NotificationProvider{ - Name: "bad", - Type: "telegram", // unsupported by notify-only runtime - URL: "http://example..com/webhook", - Enabled: true, - }).Error) - - var called atomic.Bool - orig := legacySendFunc - defer func() { legacySendFunc = orig }() - legacySendFunc = func(_ string, _ string) error { - called.Store(true) - return nil - } - - svc := NewNotificationService(db) - svc.SendExternal(context.Background(), "test", "t", "m", nil) - - // Give goroutine a moment; the send should be skipped. - time.Sleep(150 * time.Millisecond) - assert.False(t, called.Load()) -} - func TestSendExternal_UsesJSONForSupportedServices(t *testing.T) { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) require.NoError(t, err) diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index 47ecc412..5e0303a2 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -315,33 +315,6 @@ func TestNotificationService_SendExternal_Filtered(t *testing.T) { } } -func TestNotificationService_SendExternal_NotifyOnlyBlocksLegacyFallback(t *testing.T) { - db := setupNotificationTestDB(t) - svc := NewNotificationService(db) - - provider := models.NotificationProvider{ - Name: "Legacy Telegram", - Type: "telegram", - URL: "telegram://token@id", - Enabled: true, - NotifyProxyHosts: true, - } - _ = svc.CreateProvider(&provider) - - var called atomic.Bool - originalFunc := legacySendFunc - legacySendFunc = func(url, msg string) error { - called.Store(true) - return nil - } - defer func() { legacySendFunc = originalFunc }() - - svc.SendExternal(context.Background(), "proxy_host", "Title", "Message", nil) - - time.Sleep(100 * time.Millisecond) - assert.False(t, called.Load(), "legacy fallback path must not execute") -} - func TestNormalizeURL(t *testing.T) { tests := []struct { name string @@ -1697,114 +1670,6 @@ func TestIsValidRedirectURL(t *testing.T) { } } -func TestSendExternal_UnsupportedProviderFailsClosed(t *testing.T) { - db := setupNotificationTestDB(t) - svc := NewNotificationService(db) - - var called atomic.Bool - originalFunc := legacySendFunc - legacySendFunc = func(url, msg string) error { - called.Store(true) - return nil - } - defer func() { legacySendFunc = originalFunc }() - - provider := models.NotificationProvider{ - Name: "legacy-test", - Type: "telegram", - URL: "telegram://token@telegram?chats=123", - Enabled: true, - NotifyProxyHosts: true, - Template: "", - } - require.NoError(t, db.Create(&provider).Error) - - svc.SendExternal(context.Background(), "proxy_host", "Test Title", "Test Message", nil) - time.Sleep(100 * time.Millisecond) - - assert.False(t, called.Load(), "legacy fallback must remain blocked") -} - -func TestSendExternal_UnsupportedProviderSkipsFallbackEvenWhenHTTPURL(t *testing.T) { - db := setupNotificationTestDB(t) - svc := NewNotificationService(db) - - var called atomic.Bool - originalFunc := legacySendFunc - legacySendFunc = func(url, msg string) error { - called.Store(true) - return nil - } - defer func() { legacySendFunc = originalFunc }() - - provider := models.NotificationProvider{ - Name: "http-legacy", - Type: "pushover", - URL: "http://127.0.0.1:8080/webhook", - Enabled: true, - NotifyProxyHosts: true, - Template: "", - } - require.NoError(t, db.Create(&provider).Error) - - svc.SendExternal(context.Background(), "proxy_host", "Test", "Message", nil) - time.Sleep(100 * time.Millisecond) - - assert.False(t, called.Load(), "legacy fallback must remain blocked for HTTP URL") -} - -func TestSendExternal_UnsupportedProviderPrivateIPStillNoFallback(t *testing.T) { - db := setupNotificationTestDB(t) - svc := NewNotificationService(db) - - var called atomic.Bool - originalFunc := legacySendFunc - legacySendFunc = func(url, msg string) error { - called.Store(true) - return nil - } - defer func() { legacySendFunc = originalFunc }() - - provider := models.NotificationProvider{ - Name: "private-ip", - Type: "pushover", - URL: "http://10.0.0.1:8080/webhook", - Enabled: true, - NotifyProxyHosts: true, - Template: "", - } - require.NoError(t, db.Create(&provider).Error) - - svc.SendExternal(context.Background(), "proxy_host", "Test", "Message", nil) - time.Sleep(100 * time.Millisecond) - - assert.False(t, called.Load(), "legacy fallback must remain blocked for private IP") -} - -func TestLegacyFallbackInvocationError(t *testing.T) { - db := setupNotificationTestDB(t) - svc := NewNotificationService(db) - - // Test non-supported providers are rejected - err := svc.TestProvider(models.NotificationProvider{ - Type: "telegram", - URL: "telegram://token@telegram?chats=1", - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "unsupported provider type") -} - -func TestLegacyFallbackInvocationError_DirectHelperAndHook(t *testing.T) { - err := legacyFallbackInvocationError("telegram") - require.Error(t, err) - assert.Contains(t, err.Error(), "legacy fallback is retired and disabled") - assert.Contains(t, err.Error(), "provider type \"telegram\"") - - hookErr := legacySendFunc("ignored", "ignored") - require.Error(t, hookErr) - assert.ErrorIs(t, hookErr, ErrLegacyFallbackDisabled) -} - func TestNotificationService_SendExternal_SecurityEventRouting(t *testing.T) { eventCases := []struct { name string diff --git a/backend/internal/services/security_notification_service.go b/backend/internal/services/security_notification_service.go index e5fa7734..ea1b2396 100644 --- a/backend/internal/services/security_notification_service.go +++ b/backend/internal/services/security_notification_service.go @@ -38,7 +38,6 @@ func (s *SecurityNotificationService) GetSettings() (*models.NotificationConfig, NotifyWAFBlocks: true, NotifyACLDenies: true, NotifyRateLimitHits: true, - EmailRecipients: "", }, nil } return &config, err diff --git a/categories.txt b/categories.txt deleted file mode 100644 index cf4ffc46..00000000 --- a/categories.txt +++ /dev/null @@ -1,4 +0,0 @@ -actions -ci -security -testing diff --git a/FIREFOX_E2E_FIXES_SUMMARY.md b/docs/implementation/FIREFOX_E2E_FIXES_SUMMARY.md similarity index 100% rename from FIREFOX_E2E_FIXES_SUMMARY.md rename to docs/implementation/FIREFOX_E2E_FIXES_SUMMARY.md diff --git a/docs/plans/archive/user-management-consolidation-spec.md b/docs/plans/archive/user-management-consolidation-spec.md new file mode 100644 index 00000000..490a4c5f --- /dev/null +++ b/docs/plans/archive/user-management-consolidation-spec.md @@ -0,0 +1,981 @@ +# User Management Consolidation & Privilege Tier System + +Date: 2026-06-25 +Owner: Planning Agent +Status: Proposed +Severity: Medium (UX consolidation + feature enhancement) + +--- + +## 1. Introduction + +### 1.1 Overview + +Charon currently manages users through **two separate interfaces**: + +1. **Admin Account** page at `/settings/account` — self-service profile management (name, email, password, API key, certificate email) +2. **Account Management** page at `/settings/account-management` — admin-only user CRUD (list, invite, delete, update roles/permissions) + +This split is confusing and redundant. A logged-in admin must navigate to two different places to manage their own account versus managing other users. The system also lacks a formal privilege tier model — the `Role` field on the `User` model is a raw string defaulting to `"user"`, with only `"admin"` and `"user"` exposed in the frontend selector (the model comment mentions `"viewer"` but it is entirely unused). + +### 1.2 Objectives + +1. **Consolidate** user management into a single **"Users"** page that lists ALL users, including the admin. +2. **Introduce a formal privilege tier system** with three tiers: **Admin**, **User**, and **Pass-through**. +3. **Self-service profile editing** occurs inline or via a detail drawer/modal on the consolidated page — no separate "Admin Account" page. +4. **Remove** the `/settings/account` route and its sidebar/tab navigation entry entirely. +5. **Update navigation** to a single "Users" entry under Settings (or as a top-level item). +6. **Write E2E Playwright tests** for the consolidated page before any implementation changes. + +--- + +## 2. Research Findings + +### 2.1 Current Architecture: Backend + +#### 2.1.1 User Model — `backend/internal/models/user.go` + +```go +type User struct { + ID uint `json:"-" gorm:"primaryKey"` + UUID string `json:"uuid" gorm:"uniqueIndex"` + Email string `json:"email" gorm:"uniqueIndex"` + APIKey string `json:"-" gorm:"uniqueIndex"` + PasswordHash string `json:"-"` + Name string `json:"name"` + Role string `json:"role" gorm:"default:'user'"` // "admin", "user", "viewer" + Enabled bool `json:"enabled" gorm:"default:true"` + FailedLoginAttempts int `json:"-" gorm:"default:0"` + LockedUntil *time.Time `json:"-"` + LastLogin *time.Time `json:"last_login,omitempty"` + SessionVersion uint `json:"-" gorm:"default:0"` + // Invite system fields ... + PermissionMode PermissionMode `json:"permission_mode" gorm:"default:'allow_all'"` + PermittedHosts []ProxyHost `json:"permitted_hosts,omitempty" gorm:"many2many:user_permitted_hosts;"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} +``` + +**Key observations:** + +| Aspect | Current State | Issue | +|--------|--------------|-------| +| `Role` field | Raw string, default `"user"` | No Go-level enum/const; `"viewer"` mentioned in comment but unused anywhere | +| Permission logic | `CanAccessHost()` — admins bypass all checks | Sound; needs extension for pass-through tier | +| JSON serialization | `ID` is `json:"-"`, `APIKey` is `json:"-"` | Correctly hidden from API responses | +| Password hashing | bcrypt via `SetPassword()` / `CheckPassword()` | Solid | + +#### 2.1.2 Auth Service — `backend/internal/services/auth_service.go` + +- `Register()`: First user becomes admin (`role = "admin"`) +- `Login()`: Account lockout after 5 failed attempts; sets `LastLogin` +- `GenerateToken()`: JWT with claims `{UserID, Role, SessionVersion}`, 24h expiry +- `AuthenticateToken()`: Validates JWT + checks `Enabled` + matches `SessionVersion` +- `InvalidateSessions()`: Increments `SessionVersion` to revoke all existing tokens + +**Impact of role changes:** The `Role` string is embedded in the JWT. When a user's role is changed, their existing JWT still carries the old role until it expires or `SessionVersion` is bumped. The `InvalidateSessions()` method exists for this purpose, **but it is NOT currently called in `UpdateUser()`**. This is a security gap — a user demoted from admin to passthrough retains their admin JWT for up to 24 hours. Task 2.6 addresses this. + +#### 2.1.3 Auth Middleware — `backend/internal/api/middleware/auth.go` + +```go +func AuthMiddleware(authService *services.AuthService) gin.HandlerFunc +func RequireRole(role string) gin.HandlerFunc +``` + +- `AuthMiddleware`: Extracts token from header/cookie/query, sets `userID` and `role` in Gin context. +- `RequireRole`: Checks `role` against a **single** required role string, always allows `"admin"` as fallback. Currently used only for SMTP settings. + +**Gap:** Most admin-only endpoints in `user_handler.go` do inline `role != "admin"` checks via `requireAdmin(c)` in `permission_helpers.go` rather than using middleware. This works but is inconsistent. + +#### 2.1.4 User Handler — `backend/internal/api/handlers/user_handler.go` + +Two disjoint groups of endpoints: + +| Group | Endpoints | Purpose | Auth | +|-------|-----------|---------|------| +| **Self-service** | `GET /user/profile`, `POST /user/profile`, `POST /user/api-key` | Current user edits own profile/API key | Any authenticated user | +| **Admin CRUD** | `GET /users`, `POST /users`, `POST /users/invite`, `GET /users/:id`, `PUT /users/:id`, `DELETE /users/:id`, `PUT /users/:id/permissions`, `POST /users/:id/resend-invite`, `POST /users/preview-invite-url` | Admin manages all users | Admin only (inline check) | + +**Key handler behaviors:** + +- `ListUsers()`: Returns all users with safe fields (excludes password hash, API key). Admin only. +- `UpdateUser()`: Can change `name`, `email`, `password`, `role`, `enabled`. **Does NOT call `InvalidateSessions()` on role change** — a demoted user retains their old-role JWT for up to 24 hours. This is a security gap that must be fixed in this spec (see Task 2.6). +- `DeleteUser()`: Prevents self-deletion. +- `GetProfile()`: Returns `{id, email, name, role, has_api_key, api_key_masked}` for the calling user. +- `UpdateProfile()`: Requires `current_password` verification when email changes. +- `RegenerateAPIKey()`: Generates new UUID-based API key for the calling user. + +#### 2.1.5 Route Registration — `backend/internal/api/routes/routes.go` + +``` +Public: + POST /api/v1/auth/login + POST /api/v1/auth/register + GET /api/v1/auth/verify (forward auth for Caddy) + GET /api/v1/auth/status + GET /api/v1/setup/status + POST /api/v1/setup + GET /api/v1/invite/validate + POST /api/v1/invite/accept + +Protected (authMiddleware): + POST /api/v1/auth/logout + POST /api/v1/auth/refresh + GET /api/v1/auth/me + POST /api/v1/auth/change-password + GET /api/v1/user/profile ← Self-service group + POST /api/v1/user/profile ← + POST /api/v1/user/api-key ← + GET /api/v1/users ← Admin CRUD group + POST /api/v1/users ← + POST /api/v1/users/invite ← + POST /api/v1/users/preview-invite-url ← + GET /api/v1/users/:id ← + PUT /api/v1/users/:id ← + DELETE /api/v1/users/:id ← + PUT /api/v1/users/:id/permissions ← + POST /api/v1/users/:id/resend-invite ← +``` + +#### 2.1.6 Forward Auth — `backend/internal/api/handlers/auth_handler.go` + +The `Verify()` handler at `GET /api/v1/auth/verify` is the critical integration point for the pass-through tier: + +1. Extracts token from cookie/header/query +2. Authenticates user via `AuthenticateToken()` +3. Reads `X-Forwarded-Host` from Caddy +4. Checks `user.CanAccessHost(hostID)` against the permission model +5. Returns `200 OK` with `X-Auth-User` headers or `401`/`403` + +**Pass-through users will use this exact path** — they log in to Charon only to establish a session, then Caddy's `forward_auth` directive validates their access to proxied services. They never need to see the Charon management UI. + +### 2.2 Current Architecture: Frontend + +#### 2.2.1 Router — `frontend/src/App.tsx` + +```tsx +// Three routes serve user management: +} /> // top-level +}> + } /> // self-service + } /> // admin CRUD + +``` + +The `UsersPage` component is mounted at **two** routes: `/users` and `/settings/account-management`. The `Account` component is only at `/settings/account`. + +#### 2.2.2 Settings Page — `frontend/src/pages/Settings.tsx` + +Tab bar with 4 items: `System`, `Notifications`, `SMTP`, `Account`. Note that **Account Management is NOT in the tab bar** — it is only reachable via the sidebar. The `Account` tab links to `/settings/account` (the self-service profile page). + +#### 2.2.3 Sidebar Navigation — `frontend/src/components/Layout.tsx` + +Under the Settings section, two entries: + +```tsx +{ name: t('navigation.adminAccount'), path: '/settings/account', icon: '🛡️' }, +{ name: t('navigation.accountManagement'), path: '/settings/account-management', icon: '👥' }, +``` + +#### 2.2.4 Account Page — `frontend/src/pages/Account.tsx` + +Four cards: +1. **Profile** — name, email (with password verification for email changes) +2. **Certificate Email** — toggle between account email and custom email for ACME +3. **Password Change** — old/new/confirm password form +4. **API Key** — masked display + regenerate button + +Uses `api/user.ts` (singular) — endpoints: `GET /user/profile`, `POST /user/profile`, `POST /user/api-key`. + +#### 2.2.5 Users Page — `frontend/src/pages/UsersPage.tsx` + +~900 lines. Contains: +- `InviteModal` — email, role select (`user`/`admin`), permission mode, host selection, URL preview +- `PermissionsModal` — permission mode + host checkboxes per user +- `UsersPage` (default export) — table listing all users with columns: User (name/email), Role (badge), Status (active/pending/expired), Permissions (whitelist/blacklist), Enabled (toggle), Actions (resend/permissions/delete) + +**Current limitations:** +- Admin users cannot be disabled (switch is `disabled` when `role === 'admin'`) +- Admin users cannot be deleted (delete button is `disabled` when `role === 'admin'`) +- No permission editing for admins (gear icon hidden when `role !== 'admin'`) +- Role selector in `InviteModal` only has `user` and `admin` options +- No inline profile editing — admins can't edit their own name/email/password from this page +- The admin's own row appears in the list but has limited interactivity + +#### 2.2.6 API Client Files + +**`frontend/src/api/user.ts`** (singular — self-service): + +```tsx +interface UserProfile { id, email, name, role, has_api_key, api_key_masked } +getProfile() → GET /user/profile +updateProfile(data) → POST /user/profile +regenerateApiKey() → POST /user/api-key +``` + +**`frontend/src/api/users.ts`** (plural — admin CRUD): + +```tsx +interface User { id, uuid, email, name, role, enabled, last_login, invite_status, ... } +type PermissionMode = 'allow_all' | 'deny_all' +listUsers() → GET /users +getUser(id) → GET /users/:id +createUser(data) → POST /users +inviteUser(data) → POST /users/invite +updateUser(id, data) → PUT /users/:id +deleteUser(id) → DELETE /users/:id +updateUserPermissions(id, d) → PUT /users/:id/permissions +validateInvite(token) → GET /invite/validate +acceptInvite(data) → POST /invite/accept +previewInviteURL(email) → POST /users/preview-invite-url +resendInvite(id) → POST /users/:id/resend-invite +``` + +#### 2.2.7 Auth Context — `frontend/src/context/AuthContextValue.ts` + +```tsx +interface User { user_id: number; role: string; name?: string; email?: string; } +interface AuthContextType { user, login, logout, changePassword, isAuthenticated, isLoading } +``` + +The `AuthProvider` in `AuthContext.tsx` fetches `/api/v1/auth/me` on mount and stores the user. Auto-logout after 15 minutes of inactivity. + +#### 2.2.8 Route Protection — `frontend/src/components/RequireAuth.tsx` + +Checks `isAuthenticated`, localStorage token, and `user !== null`. Redirects to `/login` if any check fails. **No role-based route protection exists** — all authenticated users see the same navigation and routes. + +#### 2.2.9 Translation Keys — `frontend/src/locales/en/translation.json` + +Relevant keys: +```json +"navigation.adminAccount": "Admin Account", +"navigation.accountManagement": "Account Management", +"users.roleUser": "User", +"users.roleAdmin": "Admin" +``` + +No keys exist for `"roleViewer"` or `"rolePassthrough"`. + +### 2.3 Current Architecture: E2E Tests + +**No Playwright E2E tests exist** for user management or account pages. The `tests/` directory contains specs for DNS providers, modal dropdowns, and debug utilities only. + +### 2.4 Configuration Files Review + +| File | Relevance | Action Needed | +|------|-----------|---------------| +| `.gitignore` | May need entries for new test artifacts | Review during implementation | +| `codecov.yml` | Coverage thresholds may need adjustment | Review if new files change coverage ratios | +| `.dockerignore` | No impact expected | No changes | +| `Dockerfile` | No impact expected | No changes | + +--- + +## 3. Technical Specifications + +### 3.1 Privilege Tier System + +#### 3.1.1 Tier Definitions + +| Tier | Value | Charon UI Access | Forward Auth (Proxy) Access | Can Manage Others | +|------|-------|------------------|---------------------------|-------------------| +| **Admin** | `"admin"` | Full access to all pages and settings | All hosts (bypasses permission checks) | Yes — full CRUD on all users | +| **User** | `"user"` | Access to Charon UI except admin-only pages (user management, system settings) | Based on `permission_mode` + `permitted_hosts` | No | +| **Pass-through** | `"passthrough"` | Login page only — redirected away from all management pages after login | Based on `permission_mode` + `permitted_hosts` | No | + +#### 3.1.2 Behavioral Details + +**Admin:** +- Unchanged from current behavior. +- Can edit any user's role, permissions, name, email, enabled state. +- Can edit their own profile inline on the consolidated Users page. +- Cannot delete themselves. Cannot disable themselves. + +**User:** +- Can log in to the Charon management UI. +- Can view proxy hosts, certificates, DNS, uptime, and other read-oriented pages. +- Cannot access: User management, System settings, SMTP settings, Encryption, Plugins. +- Can edit their own profile (name, email, password, API key) via self-service section on the Users page or a dedicated "My Account" modal. +- Forward auth access is governed by `permission_mode` + `permitted_hosts`. + +**Pass-through:** +- Can log in to Charon (to obtain a session cookie/JWT). +- Immediately after login, is redirected to `/passthrough` landing page — **no access to the management UI**. +- The session cookie is used by Caddy's `forward_auth` to grant access to proxied services based on `permission_mode` + `permitted_hosts`. +- Can access **only**: `/auth/logout`, `/auth/refresh`, `/auth/me`, `/auth/change-password`. +- **Cannot access**: `/user/profile`, `/user/api-key`, or any management API endpoints. +- Cannot access any Charon management UI pages. + +#### 3.1.3 Backend Role Constants + +Add typed constants to `backend/internal/models/user.go`: + +```go +type UserRole string + +const ( + RoleAdmin UserRole = "admin" + RoleUser UserRole = "user" + RolePassthrough UserRole = "passthrough" +) + +func (r UserRole) IsValid() bool { + switch r { + case RoleAdmin, RoleUser, RolePassthrough: + return true + } + return false +} +``` + +Change `User.Role` from `string` to `UserRole` (which is still a `string` alias, so GORM and JSON work identically — zero migration friction). + +### 3.2 Database Schema Changes + +#### 3.2.1 User Table + +**No schema migration needed.** The `Role` column is already a `TEXT` field storing string values. Changing the Go type from `string` to `UserRole` (a `string` alias) requires no ALTER TABLE. New role values (`"passthrough"`) are immediately storable. + +#### 3.2.2 Data Migration + +A one-time migration function should: +1. Scan for any users with `role = "viewer"` and update them to `role = "passthrough"` (in case any were manually created via the unused viewer path). +2. Validate all existing roles are in the allowed set. + +This runs in the `AutoMigrate` block in `routes.go`. + +The migration must be in a clearly named function (e.g., `migrateViewerToPassthrough()`) and must log when it runs and how many records it affected: + +```go +func migrateViewerToPassthrough(db *gorm.DB) { + result := db.Model(&User{}).Where("role = ?", "viewer").Update("role", string(RolePassthrough)) + if result.RowsAffected > 0 { + log.Printf("[migration] Updated %d users from 'viewer' to 'passthrough' role", result.RowsAffected) + } +} +``` + +### 3.3 API Changes + +#### 3.3.1 Consolidated Self-Service Endpoints + +Merge the current `/user/profile` and `/user/api-key` endpoints into the existing `/users/:id` group by allowing users to edit their own record: + +| Current Endpoint | Action | Proposed | +|-----------------|--------|----------| +| `GET /user/profile` | Get own profile | **Keep** (convenience alias) | +| `POST /user/profile` | Update own profile | **Keep** (convenience alias) | +| `POST /user/api-key` | Regenerate API key | **Keep** (convenience alias) | +| `PUT /users/:id` | Admin updates any user | **Extend**: allow authenticated user to update their own record (name, email with password verification) | + +The self-service endpoints (`/user/*`) remain as convenient aliases that internally resolve to the caller's own user ID and delegate to the same service logic. No breaking API changes. + +#### 3.3.2 Role Validation + +The `UpdateUser()` and `CreateUser()` handlers must validate the `role` field against `UserRole.IsValid()`. The invite endpoint `InviteUser()` must accept `"passthrough"` as a valid role. + +#### 3.3.3 Pass-through Route Protection + +Add middleware to block pass-through users from accessing management endpoints: + +```go +func RequireManagementAccess() gin.HandlerFunc { + return func(c *gin.Context) { + role := c.GetString("role") + if role == string(models.RolePassthrough) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "pass-through users cannot access management features", + }) + return + } + c.Next() + } +} +``` + +Apply this middleware by restructuring the protected routes into two sub-groups: + +**Exempt routes** (authMiddleware only — no management check): +``` +POST /auth/logout +POST /auth/refresh +GET /auth/me +POST /auth/change-password +GET /auth/accessible-hosts +GET /auth/check-host/:hostId +GET /user/profile +POST /user/profile +POST /user/api-key +``` + +**Management routes** (authMiddleware + `RequireManagementAccess`): +``` +GET /users +POST /users +POST /users/invite +POST /users/preview-invite-url +GET /users/:id +PUT /users/:id +DELETE /users/:id +PUT /users/:id/permissions +POST /users/:id/resend-invite +All /backup/* routes +All /logs/* routes +All /settings/* routes +All /system/* routes +All /security/* routes +All /features/* routes +All /proxy/* routes +All /certificates/* routes +All /dns/* routes +All /uptime/* routes +All /plugins/* routes +``` + +The route split in `routes.go` should look like: + +```go +// Self-service + auth routes — accessible to all authenticated users (including passthrough) +exempt := protected.Group("") +{ + exempt.POST("/auth/logout", ...) + exempt.POST("/auth/refresh", ...) + exempt.GET("/auth/me", ...) + exempt.POST("/auth/change-password", ...) + exempt.GET("/auth/accessible-hosts", ...) + exempt.GET("/auth/check-host/:hostId", ...) + exempt.GET("/user/profile", ...) + exempt.POST("/user/profile", ...) + exempt.POST("/user/api-key", ...) +} + +// Management routes — blocked for passthrough users +management := protected.Group("", middleware.RequireManagementAccess()) +{ + // All other routes registered here +} +``` + +#### 3.3.4 New Endpoint: Pass-through Landing + +No new backend endpoint needed. The forward auth flow (`GET /auth/verify`) already works for pass-through users. The minimal landing page is purely a frontend concern. + +### 3.4 Frontend Changes + +#### 3.4.1 Consolidated Users Page + +Transform `UsersPage.tsx` into the single source of truth for all user management: + +**New capabilities:** +1. **Admin's own row is fully interactive** — edit name/email/password, regenerate API key, view cert email settings. +2. **"My Profile" section** at the top (or accessible via a button) for the currently logged-in user to manage their own account, replacing the `Account.tsx` page entirely. +3. **Role selector** includes three options: `Admin`, `User`, `Pass-through`. +4. **Permission controls** are shown for `User` and `Pass-through` roles (hidden for `Admin`). +5. **Inline editing** or a detail drawer for editing individual users. + +**Components to modify:** + +| Component | File | Change | +|-----------|------|--------| +| `InviteModal` | `UsersPage.tsx` | Add `"passthrough"` to role ``. Show permission controls for both `user` and `passthrough`. | -| `PermissionsModal` | `UsersPage.tsx` | No functional changes — already works for any non-admin role. | -| `UsersPage` (table) | `UsersPage.tsx` | Add role badge color for `passthrough`. Remove `disabled` from admin row's enable/delete where appropriate. Add "Edit" action for all users. Add "My Profile" card or section. | -| New: `UserDetailModal` | `UsersPage.tsx` or separate file | Modal/drawer for editing a user's full details (including password reset, API key regen for self). Replaces the Account page's four cards. | - -#### 3.4.2 Remove Account Page - -| Action | File | Detail | -|--------|------|--------| -| Delete | `frontend/src/pages/Account.tsx` | Remove entirely | -| Delete | `frontend/src/api/user.ts` | Remove (merge any unique types into `users.ts`) | -| Remove route | `frontend/src/App.tsx` | Remove `} />` | -| Remove nav entry | `frontend/src/components/Layout.tsx` | Remove `{ name: t('navigation.adminAccount'), path: '/settings/account', icon: '🛡️' }` | -| Remove tab | `frontend/src/pages/Settings.tsx` | Remove `{ path: '/settings/account', label: t('settings.account'), icon: User }` | -| Remove translation keys | `frontend/src/locales/en/translation.json` | Remove `navigation.adminAccount`, update `navigation.accountManagement` → `navigation.users` | - -#### 3.4.3 Navigation Update - -**Sidebar** (Layout.tsx): Under Settings, consolidate to a single entry: - -```tsx -{ name: t('navigation.users'), path: '/settings/users', icon: '👥' } -``` - -**Settings tab bar** (Settings.tsx): Add "Users" as a new tab, remove "Account": - -```tsx -{ path: '/settings/users', label: t('navigation.users'), icon: Users } -``` - -**Router** (App.tsx): Update routes: - -```tsx -{/* Pass-through landing — accessible to all authenticated users */} -} /> - -}> - } /> - } /> - } /> - } /> - } /> - -``` - -Add redirects for old paths: -```tsx -} /> -} /> -} /> -``` - -#### 3.4.4 Role-Based Route Protection - -Create a new `RequireRole` wrapper component: - -```tsx -// frontend/src/components/RequireRole.tsx -const RequireRole: React.FC<{ allowed: string[]; children: React.ReactNode }> = ({ allowed, children }) => { - const { user } = useAuth(); - if (!user) { - return ; - } - if (!allowed.includes(user.role)) { - // Role-aware redirect: pass-through users go to their landing page, - // other unauthorized roles go to dashboard - const redirectTarget = user.role === 'passthrough' ? '/passthrough' : '/'; - return ; - } - return children; -}; -``` - -Wrap admin-only routes (user management, system settings) with ``. - -For pass-through users, redirect all management routes to a minimal landing page: - -```tsx -// frontend/src/pages/PassthroughLanding.tsx -// Minimal page: "You are logged in. Your session grants access to authorized services." -// Shows: user name, logout button, change password link, optionally a list of accessible hosts. -// Route: /passthrough (must be registered in App.tsx router) -// -// Accessibility requirements (WCAG 2.2 AA): -// - Use semantic HTML:
,

,