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 `