diff --git a/backend/internal/api/handlers/auth_handler.go b/backend/internal/api/handlers/auth_handler.go index 9f9f4c0e..19727cda 100644 --- a/backend/internal/api/handlers/auth_handler.go +++ b/backend/internal/api/handlers/auth_handler.go @@ -2,19 +2,64 @@ package handlers import ( "net/http" + "os" + "strconv" + "strings" + "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) type AuthHandler struct { authService *services.AuthService + db *gorm.DB } func NewAuthHandler(authService *services.AuthService) *AuthHandler { return &AuthHandler{authService: authService} } +// NewAuthHandlerWithDB creates an AuthHandler with database access for forward auth. +func NewAuthHandlerWithDB(authService *services.AuthService, db *gorm.DB) *AuthHandler { + return &AuthHandler{authService: authService, db: db} +} + +// isProduction checks if we're running in production mode +func isProduction() bool { + env := os.Getenv("CHARON_ENV") + return env == "production" || env == "prod" +} + +// setSecureCookie sets an auth cookie with security best practices +// - HttpOnly: prevents JavaScript access (XSS protection) +// - Secure: only sent over HTTPS (in production) +// - SameSite=Strict: prevents CSRF attacks +func setSecureCookie(c *gin.Context, name, value string, maxAge int) { + secure := isProduction() + sameSite := http.SameSiteStrictMode + + // Use the host without port for domain + domain := "" + + c.SetSameSite(sameSite) + c.SetCookie( + name, // name + value, // value + maxAge, // maxAge in seconds + "/", // path + domain, // domain (empty = current host) + secure, // secure (HTTPS only in production) + true, // httpOnly (no JS access) + ) +} + +// clearSecureCookie removes a cookie with the same security settings +func clearSecureCookie(c *gin.Context, name string) { + setSecureCookie(c, name, "", -1) +} + type LoginRequest struct { Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required"` @@ -33,8 +78,8 @@ func (h *AuthHandler) Login(c *gin.Context) { return } - // Set cookie - c.SetCookie("auth_token", token, 3600*24, "/", "", false, true) // Secure should be true in prod + // Set secure cookie (HttpOnly, Secure in prod, SameSite=Strict) + setSecureCookie(c, "auth_token", token, 3600*24) c.JSON(http.StatusOK, gin.H{"token": token}) } @@ -62,7 +107,7 @@ func (h *AuthHandler) Register(c *gin.Context) { } func (h *AuthHandler) Logout(c *gin.Context) { - c.SetCookie("auth_token", "", -1, "/", "", false, true) + clearSecureCookie(c, "auth_token") c.JSON(http.StatusOK, gin.H{"message": "Logged out"}) } @@ -109,3 +154,225 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"}) } + +// Verify is the forward auth endpoint for Caddy. +// It validates the user's session and checks access permissions for the requested host. +// Used by Caddy's forward_auth directive. +// +// Expected headers from Caddy: +// - X-Forwarded-Host: The original host being accessed +// - X-Forwarded-Uri: The original URI being accessed +// +// Response headers on success (200): +// - X-Forwarded-User: The user's email +// - X-Forwarded-Groups: The user's role (for future RBAC) +// +// Response on failure: +// - 401: Not authenticated (redirect to login) +// - 403: Authenticated but not authorized for this host +func (h *AuthHandler) Verify(c *gin.Context) { + // Extract token from cookie or Authorization header + var tokenString string + + // Try cookie first (most common for browser requests) + if cookie, err := c.Cookie("auth_token"); err == nil && cookie != "" { + tokenString = cookie + } + + // Fall back to Authorization header + if tokenString == "" { + authHeader := c.GetHeader("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + tokenString = strings.TrimPrefix(authHeader, "Bearer ") + } + } + + // No token found - not authenticated + if tokenString == "" { + c.Header("X-Auth-Redirect", "/login") + c.AbortWithStatus(http.StatusUnauthorized) + return + } + + // Validate token + claims, err := h.authService.ValidateToken(tokenString) + if err != nil { + c.Header("X-Auth-Redirect", "/login") + c.AbortWithStatus(http.StatusUnauthorized) + return + } + + // Get user details + user, err := h.authService.GetUserByID(claims.UserID) + if err != nil || !user.Enabled { + c.Header("X-Auth-Redirect", "/login") + c.AbortWithStatus(http.StatusUnauthorized) + return + } + + // Get the forwarded host from Caddy + forwardedHost := c.GetHeader("X-Forwarded-Host") + if forwardedHost == "" { + forwardedHost = c.GetHeader("X-Original-Host") + } + + // If we have a database reference and a forwarded host, check permissions + if h.db != nil && forwardedHost != "" { + // Find the proxy host for this domain + var proxyHost models.ProxyHost + err := h.db.Where("domain_names LIKE ?", "%"+forwardedHost+"%").First(&proxyHost).Error + + if err == nil && proxyHost.ForwardAuthEnabled { + // Load user's permitted hosts for permission check + var userWithHosts models.User + if err := h.db.Preload("PermittedHosts").First(&userWithHosts, user.ID).Error; err == nil { + // Check if user can access this host + if !userWithHosts.CanAccessHost(proxyHost.ID) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "Access denied to this application", + }) + return + } + } + } + } + + // Set headers for downstream services + c.Header("X-Forwarded-User", user.Email) + c.Header("X-Forwarded-Groups", user.Role) + c.Header("X-Forwarded-Name", user.Name) + + // Return 200 OK - access granted + c.Status(http.StatusOK) +} + +// VerifyStatus returns the current auth status without triggering a redirect. +// Useful for frontend to check if user is logged in. +func (h *AuthHandler) VerifyStatus(c *gin.Context) { + // Extract token + var tokenString string + + if cookie, err := c.Cookie("auth_token"); err == nil && cookie != "" { + tokenString = cookie + } + + if tokenString == "" { + authHeader := c.GetHeader("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + tokenString = strings.TrimPrefix(authHeader, "Bearer ") + } + } + + if tokenString == "" { + c.JSON(http.StatusOK, gin.H{ + "authenticated": false, + }) + return + } + + claims, err := h.authService.ValidateToken(tokenString) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "authenticated": false, + }) + return + } + + user, err := h.authService.GetUserByID(claims.UserID) + if err != nil || !user.Enabled { + c.JSON(http.StatusOK, gin.H{ + "authenticated": false, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "authenticated": true, + "user": gin.H{ + "id": user.ID, + "email": user.Email, + "name": user.Name, + "role": user.Role, + }, + }) +} + +// GetAccessibleHosts returns the list of proxy hosts the authenticated user can access. +func (h *AuthHandler) GetAccessibleHosts(c *gin.Context) { + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + if h.db == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database not available"}) + return + } + + // Load user with permitted hosts + var user models.User + if err := h.db.Preload("PermittedHosts").First(&user, userID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + // Get all enabled proxy hosts + var allHosts []models.ProxyHost + if err := h.db.Where("enabled = ?", true).Find(&allHosts).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch hosts"}) + return + } + + // Filter to accessible hosts + accessibleHosts := make([]gin.H, 0) + for _, host := range allHosts { + if user.CanAccessHost(host.ID) { + accessibleHosts = append(accessibleHosts, gin.H{ + "id": host.ID, + "name": host.Name, + "domain_names": host.DomainNames, + }) + } + } + + c.JSON(http.StatusOK, gin.H{ + "hosts": accessibleHosts, + "permission_mode": user.PermissionMode, + }) +} + +// CheckHostAccess checks if the current user can access a specific host. +func (h *AuthHandler) CheckHostAccess(c *gin.Context) { + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + hostIDStr := c.Param("hostId") + hostID, err := strconv.ParseUint(hostIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid host ID"}) + return + } + + if h.db == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database not available"}) + return + } + + // Load user with permitted hosts + var user models.User + if err := h.db.Preload("PermittedHosts").First(&user, userID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + canAccess := user.CanAccessHost(uint(hostID)) + + c.JSON(http.StatusOK, gin.H{ + "host_id": hostID, + "can_access": canAccess, + }) +} diff --git a/backend/internal/api/handlers/auth_handler_test.go b/backend/internal/api/handlers/auth_handler_test.go index 32100162..878821ba 100644 --- a/backend/internal/api/handlers/auth_handler_test.go +++ b/backend/internal/api/handlers/auth_handler_test.go @@ -293,3 +293,515 @@ func TestAuthHandler_ChangePassword_Errors(t *testing.T) { r.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code) } + +// setupAuthHandlerWithDB creates an AuthHandler with DB access for forward auth tests +func setupAuthHandlerWithDB(t *testing.T) (*AuthHandler, *gorm.DB) { + dbName := "file:" + t.Name() + "?mode=memory&cache=shared" + db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{}) + require.NoError(t, err) + db.AutoMigrate(&models.User{}, &models.Setting{}, &models.ProxyHost{}) + + cfg := config.Config{JWTSecret: "test-secret"} + authService := services.NewAuthService(db, cfg) + return NewAuthHandlerWithDB(authService, db), db +} + +func TestNewAuthHandlerWithDB(t *testing.T) { + handler, db := setupAuthHandlerWithDB(t) + assert.NotNil(t, handler) + assert.NotNil(t, handler.db) + assert.NotNil(t, db) +} + +func TestAuthHandler_Verify_NoCookie(t *testing.T) { + handler, _ := setupAuthHandlerWithDB(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/verify", handler.Verify) + + req := httptest.NewRequest("GET", "/verify", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Equal(t, "/login", w.Header().Get("X-Auth-Redirect")) +} + +func TestAuthHandler_Verify_InvalidToken(t *testing.T) { + handler, _ := setupAuthHandlerWithDB(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/verify", handler.Verify) + + req := httptest.NewRequest("GET", "/verify", nil) + req.AddCookie(&http.Cookie{Name: "auth_token", Value: "invalid-token"}) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestAuthHandler_Verify_ValidToken(t *testing.T) { + handler, db := setupAuthHandlerWithDB(t) + + // Create user + user := &models.User{ + UUID: uuid.NewString(), + Email: "test@example.com", + Name: "Test User", + Role: "user", + Enabled: true, + } + user.SetPassword("password123") + db.Create(user) + + // Generate token + token, _ := handler.authService.GenerateToken(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/verify", handler.Verify) + + req := httptest.NewRequest("GET", "/verify", nil) + req.AddCookie(&http.Cookie{Name: "auth_token", Value: token}) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "test@example.com", w.Header().Get("X-Forwarded-User")) + assert.Equal(t, "user", w.Header().Get("X-Forwarded-Groups")) +} + +func TestAuthHandler_Verify_BearerToken(t *testing.T) { + handler, db := setupAuthHandlerWithDB(t) + + user := &models.User{ + UUID: uuid.NewString(), + Email: "bearer@example.com", + Name: "Bearer User", + Role: "admin", + Enabled: true, + } + user.SetPassword("password123") + db.Create(user) + + token, _ := handler.authService.GenerateToken(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/verify", handler.Verify) + + req := httptest.NewRequest("GET", "/verify", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "bearer@example.com", w.Header().Get("X-Forwarded-User")) +} + +func TestAuthHandler_Verify_DisabledUser(t *testing.T) { + handler, db := setupAuthHandlerWithDB(t) + + user := &models.User{ + UUID: uuid.NewString(), + Email: "disabled@example.com", + Name: "Disabled User", + Role: "user", + } + user.SetPassword("password123") + db.Create(user) + // Explicitly disable after creation to bypass GORM's default:true behavior + db.Model(user).Update("enabled", false) + + token, _ := handler.authService.GenerateToken(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/verify", handler.Verify) + + req := httptest.NewRequest("GET", "/verify", nil) + req.AddCookie(&http.Cookie{Name: "auth_token", Value: token}) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestAuthHandler_Verify_ForwardAuthDenied(t *testing.T) { + handler, db := setupAuthHandlerWithDB(t) + + // Create proxy host with forward auth enabled + proxyHost := &models.ProxyHost{ + UUID: uuid.NewString(), + Name: "Protected App", + DomainNames: "app.example.com", + ForwardAuthEnabled: true, + Enabled: true, + } + db.Create(proxyHost) + + // Create user with deny_all permission + user := &models.User{ + UUID: uuid.NewString(), + Email: "denied@example.com", + Name: "Denied User", + Role: "user", + Enabled: true, + PermissionMode: models.PermissionModeDenyAll, + } + user.SetPassword("password123") + db.Create(user) + + token, _ := handler.authService.GenerateToken(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/verify", handler.Verify) + + req := httptest.NewRequest("GET", "/verify", nil) + req.AddCookie(&http.Cookie{Name: "auth_token", Value: token}) + req.Header.Set("X-Forwarded-Host", "app.example.com") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestAuthHandler_VerifyStatus_NotAuthenticated(t *testing.T) { + handler, _ := setupAuthHandlerWithDB(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/status", handler.VerifyStatus) + + req := httptest.NewRequest("GET", "/status", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(t, false, resp["authenticated"]) +} + +func TestAuthHandler_VerifyStatus_InvalidToken(t *testing.T) { + handler, _ := setupAuthHandlerWithDB(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/status", handler.VerifyStatus) + + req := httptest.NewRequest("GET", "/status", nil) + req.AddCookie(&http.Cookie{Name: "auth_token", Value: "invalid"}) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(t, false, resp["authenticated"]) +} + +func TestAuthHandler_VerifyStatus_Authenticated(t *testing.T) { + handler, db := setupAuthHandlerWithDB(t) + + user := &models.User{ + UUID: uuid.NewString(), + Email: "status@example.com", + Name: "Status User", + Role: "user", + Enabled: true, + } + user.SetPassword("password123") + db.Create(user) + + token, _ := handler.authService.GenerateToken(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/status", handler.VerifyStatus) + + req := httptest.NewRequest("GET", "/status", nil) + req.AddCookie(&http.Cookie{Name: "auth_token", Value: token}) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(t, true, resp["authenticated"]) + userObj := resp["user"].(map[string]interface{}) + assert.Equal(t, "status@example.com", userObj["email"]) +} + +func TestAuthHandler_VerifyStatus_DisabledUser(t *testing.T) { + handler, db := setupAuthHandlerWithDB(t) + + user := &models.User{ + UUID: uuid.NewString(), + Email: "disabled2@example.com", + Name: "Disabled User 2", + Role: "user", + } + user.SetPassword("password123") + db.Create(user) + // Explicitly disable after creation to bypass GORM's default:true behavior + db.Model(user).Update("enabled", false) + + token, _ := handler.authService.GenerateToken(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/status", handler.VerifyStatus) + + req := httptest.NewRequest("GET", "/status", nil) + req.AddCookie(&http.Cookie{Name: "auth_token", Value: token}) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(t, false, resp["authenticated"]) +} + +func TestAuthHandler_GetAccessibleHosts_Unauthorized(t *testing.T) { + handler, _ := setupAuthHandlerWithDB(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/hosts", handler.GetAccessibleHosts) + + req := httptest.NewRequest("GET", "/hosts", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestAuthHandler_GetAccessibleHosts_AllowAll(t *testing.T) { + handler, db := setupAuthHandlerWithDB(t) + + // Create proxy hosts + host1 := &models.ProxyHost{UUID: uuid.NewString(), Name: "Host 1", DomainNames: "host1.example.com", Enabled: true} + host2 := &models.ProxyHost{UUID: uuid.NewString(), Name: "Host 2", DomainNames: "host2.example.com", Enabled: true} + db.Create(host1) + db.Create(host2) + + user := &models.User{ + UUID: uuid.NewString(), + Email: "allowall@example.com", + Name: "Allow All User", + Role: "user", + Enabled: true, + PermissionMode: models.PermissionModeAllowAll, + } + db.Create(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("userID", user.ID) + c.Next() + }) + r.GET("/hosts", handler.GetAccessibleHosts) + + req := httptest.NewRequest("GET", "/hosts", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + hosts := resp["hosts"].([]interface{}) + assert.Len(t, hosts, 2) +} + +func TestAuthHandler_GetAccessibleHosts_DenyAll(t *testing.T) { + handler, db := setupAuthHandlerWithDB(t) + + // Create proxy hosts + host1 := &models.ProxyHost{UUID: uuid.NewString(), Name: "Host 1", DomainNames: "host1.example.com", Enabled: true} + db.Create(host1) + + user := &models.User{ + UUID: uuid.NewString(), + Email: "denyall@example.com", + Name: "Deny All User", + Role: "user", + Enabled: true, + PermissionMode: models.PermissionModeDenyAll, + } + db.Create(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("userID", user.ID) + c.Next() + }) + r.GET("/hosts", handler.GetAccessibleHosts) + + req := httptest.NewRequest("GET", "/hosts", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + hosts := resp["hosts"].([]interface{}) + assert.Len(t, hosts, 0) +} + +func TestAuthHandler_GetAccessibleHosts_PermittedHosts(t *testing.T) { + handler, db := setupAuthHandlerWithDB(t) + + // Create proxy hosts + host1 := &models.ProxyHost{UUID: uuid.NewString(), Name: "Host 1", DomainNames: "host1.example.com", Enabled: true} + host2 := &models.ProxyHost{UUID: uuid.NewString(), Name: "Host 2", DomainNames: "host2.example.com", Enabled: true} + db.Create(host1) + db.Create(host2) + + user := &models.User{ + UUID: uuid.NewString(), + Email: "permitted@example.com", + Name: "Permitted User", + Role: "user", + Enabled: true, + PermissionMode: models.PermissionModeDenyAll, + PermittedHosts: []models.ProxyHost{*host1}, // Only host1 + } + db.Create(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("userID", user.ID) + c.Next() + }) + r.GET("/hosts", handler.GetAccessibleHosts) + + req := httptest.NewRequest("GET", "/hosts", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + hosts := resp["hosts"].([]interface{}) + assert.Len(t, hosts, 1) +} + +func TestAuthHandler_GetAccessibleHosts_UserNotFound(t *testing.T) { + handler, _ := setupAuthHandlerWithDB(t) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("userID", uint(99999)) + c.Next() + }) + r.GET("/hosts", handler.GetAccessibleHosts) + + req := httptest.NewRequest("GET", "/hosts", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestAuthHandler_CheckHostAccess_Unauthorized(t *testing.T) { + handler, _ := setupAuthHandlerWithDB(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/hosts/:hostId/access", handler.CheckHostAccess) + + req := httptest.NewRequest("GET", "/hosts/1/access", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestAuthHandler_CheckHostAccess_InvalidHostID(t *testing.T) { + handler, db := setupAuthHandlerWithDB(t) + + user := &models.User{UUID: uuid.NewString(), Email: "check@example.com", Enabled: true} + db.Create(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("userID", user.ID) + c.Next() + }) + r.GET("/hosts/:hostId/access", handler.CheckHostAccess) + + req := httptest.NewRequest("GET", "/hosts/invalid/access", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestAuthHandler_CheckHostAccess_Allowed(t *testing.T) { + handler, db := setupAuthHandlerWithDB(t) + + host := &models.ProxyHost{UUID: uuid.NewString(), Name: "Test Host", DomainNames: "test.example.com", Enabled: true} + db.Create(host) + + user := &models.User{ + UUID: uuid.NewString(), + Email: "checkallowed@example.com", + Enabled: true, + PermissionMode: models.PermissionModeAllowAll, + } + db.Create(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("userID", user.ID) + c.Next() + }) + r.GET("/hosts/:hostId/access", handler.CheckHostAccess) + + req := httptest.NewRequest("GET", "/hosts/1/access", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(t, true, resp["can_access"]) +} + +func TestAuthHandler_CheckHostAccess_Denied(t *testing.T) { + handler, db := setupAuthHandlerWithDB(t) + + host := &models.ProxyHost{UUID: uuid.NewString(), Name: "Protected Host", DomainNames: "protected.example.com", Enabled: true} + db.Create(host) + + user := &models.User{ + UUID: uuid.NewString(), + Email: "checkdenied@example.com", + Enabled: true, + PermissionMode: models.PermissionModeDenyAll, + } + db.Create(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("userID", user.ID) + c.Next() + }) + r.GET("/hosts/:hostId/access", handler.CheckHostAccess) + + req := httptest.NewRequest("GET", "/hosts/1/access", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(t, false, resp["can_access"]) +} diff --git a/backend/internal/api/handlers/benchmark_test.go b/backend/internal/api/handlers/benchmark_test.go index 762a81aa..9b96c0ed 100644 --- a/backend/internal/api/handlers/benchmark_test.go +++ b/backend/internal/api/handlers/benchmark_test.go @@ -274,10 +274,10 @@ func BenchmarkSecurityHandler_UpdateConfig(b *testing.B) { router.PUT("/api/v1/security/config", h.UpdateConfig) payload := map[string]interface{}{ - "name": "default", - "enabled": true, - "rate_limit_enable": true, - "rate_limit_burst": 10, + "name": "default", + "enabled": true, + "rate_limit_enable": true, + "rate_limit_burst": 10, "rate_limit_requests": 100, } body, _ := json.Marshal(payload) diff --git a/backend/internal/api/handlers/perf_assert_test.go b/backend/internal/api/handlers/perf_assert_test.go index c252a1bb..49d5cd9a 100644 --- a/backend/internal/api/handlers/perf_assert_test.go +++ b/backend/internal/api/handlers/perf_assert_test.go @@ -1,13 +1,13 @@ package handlers import ( + "fmt" "net/http" "net/http/httptest" "os" "sort" "testing" "time" - "fmt" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" @@ -74,9 +74,13 @@ func computePercentiles(samples []float64) (avg, p50, p95, p99, max float64) { } avg = sum / float64(len(samples)) p := func(pct float64) float64 { - idx := int(float64(len(samples))*pct) - if idx < 0 { idx = 0 } - if idx >= len(samples) { idx = len(samples)-1 } + idx := int(float64(len(samples)) * pct) + if idx < 0 { + idx = 0 + } + if idx >= len(samples) { + idx = len(samples) - 1 + } return samples[idx] } p50 = p(0.50) @@ -112,7 +116,9 @@ func TestPerf_GetStatus_AssertThreshold(t *testing.T) { // default thresholds ms thresholdP95 := 2.0 // 2ms per request if env := os.Getenv("PERF_MAX_MS_GETSTATUS_P95"); env != "" { - if parsed, err := time.ParseDuration(env); err == nil { thresholdP95 = ms(parsed) } + if parsed, err := time.ParseDuration(env); err == nil { + thresholdP95 = ms(parsed) + } } // fail if p95 exceeds threshold t.Logf("GetStatus avg=%.3fms p95=%.3fms max=%.3fms", avg, p95, max) @@ -144,13 +150,19 @@ func TestPerf_GetStatus_Parallel_AssertThreshold(t *testing.T) { } // run 4 concurrent workers - for k := 0; k < 4; k++ { go worker() } + for k := 0; k < 4; k++ { + go worker() + } collected := make([]float64, 0, n*4) - for i := 0; i < n*4; i++ { collected = append(collected, <-samples) } + for i := 0; i < n*4; i++ { + collected = append(collected, <-samples) + } avg, _, p95, _, max := computePercentiles(collected) thresholdP95 := 5.0 // 5ms default if env := os.Getenv("PERF_MAX_MS_GETSTATUS_P95_PARALLEL"); env != "" { - if parsed, err := time.ParseDuration(env); err == nil { thresholdP95 = ms(parsed) } + if parsed, err := time.ParseDuration(env); err == nil { + thresholdP95 = ms(parsed) + } } t.Logf("GetStatus Parallel avg=%.3fms p95=%.3fms max=%.3fms", avg, p95, max) if p95 > thresholdP95 { @@ -177,7 +189,9 @@ func TestPerf_ListDecisions_AssertThreshold(t *testing.T) { avg, _, p95, _, max := computePercentiles(samples) thresholdP95 := 30.0 // 30ms default if env := os.Getenv("PERF_MAX_MS_LISTDECISIONS_P95"); env != "" { - if parsed, err := time.ParseDuration(env); err == nil { thresholdP95 = ms(parsed) } + if parsed, err := time.ParseDuration(env); err == nil { + thresholdP95 = ms(parsed) + } } t.Logf("ListDecisions avg=%.3fms p95=%.3fms max=%.3fms", avg, p95, max) if p95 > thresholdP95 { diff --git a/backend/internal/api/handlers/settings_handler.go b/backend/internal/api/handlers/settings_handler.go index e03e379b..9d8e6556 100644 --- a/backend/internal/api/handlers/settings_handler.go +++ b/backend/internal/api/handlers/settings_handler.go @@ -7,14 +7,19 @@ import ( "gorm.io/gorm" "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/services" ) type SettingsHandler struct { - DB *gorm.DB + DB *gorm.DB + MailService *services.MailService } func NewSettingsHandler(db *gorm.DB) *SettingsHandler { - return &SettingsHandler{DB: db} + return &SettingsHandler{ + DB: db, + MailService: services.NewMailService(db), + } } // GetSettings returns all settings. @@ -69,3 +74,153 @@ func (h *SettingsHandler) UpdateSetting(c *gin.Context) { c.JSON(http.StatusOK, setting) } + +// SMTPConfigRequest represents the request body for SMTP configuration. +type SMTPConfigRequest struct { + Host string `json:"host" binding:"required"` + Port int `json:"port" binding:"required,min=1,max=65535"` + Username string `json:"username"` + Password string `json:"password"` + FromAddress string `json:"from_address" binding:"required,email"` + Encryption string `json:"encryption" binding:"required,oneof=none ssl starttls"` +} + +// GetSMTPConfig returns the current SMTP configuration. +func (h *SettingsHandler) GetSMTPConfig(c *gin.Context) { + config, err := h.MailService.GetSMTPConfig() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch SMTP configuration"}) + return + } + + // Don't expose the password + c.JSON(http.StatusOK, gin.H{ + "host": config.Host, + "port": config.Port, + "username": config.Username, + "password": MaskPassword(config.Password), + "from_address": config.FromAddress, + "encryption": config.Encryption, + "configured": config.Host != "" && config.FromAddress != "", + }) +} + +// MaskPassword masks the password for display. +func MaskPassword(password string) string { + if password == "" { + return "" + } + return "********" +} + +// MaskPasswordForTest is an alias for testing. +func MaskPasswordForTest(password string) string { + return MaskPassword(password) +} + +// UpdateSMTPConfig updates the SMTP configuration. +func (h *SettingsHandler) UpdateSMTPConfig(c *gin.Context) { + role, _ := c.Get("role") + if role != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) + return + } + + var req SMTPConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // If password is masked (i.e., unchanged), keep the existing password + existingConfig, _ := h.MailService.GetSMTPConfig() + if req.Password == "********" || req.Password == "" { + req.Password = existingConfig.Password + } + + config := &services.SMTPConfig{ + Host: req.Host, + Port: req.Port, + Username: req.Username, + Password: req.Password, + FromAddress: req.FromAddress, + Encryption: req.Encryption, + } + + if err := h.MailService.SaveSMTPConfig(config); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save SMTP configuration: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "SMTP configuration saved successfully"}) +} + +// TestSMTPConfig tests the SMTP connection. +func (h *SettingsHandler) TestSMTPConfig(c *gin.Context) { + role, _ := c.Get("role") + if role != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) + return + } + + if err := h.MailService.TestConnection(); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "SMTP connection successful", + }) +} + +// SendTestEmail sends a test email to verify the SMTP configuration. +func (h *SettingsHandler) SendTestEmail(c *gin.Context) { + role, _ := c.Get("role") + if role != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) + return + } + + type TestEmailRequest struct { + To string `json:"to" binding:"required,email"` + } + + var req TestEmailRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + htmlBody := ` + + + + Test Email + + +
+

Test Email from Charon

+

If you received this email, your SMTP configuration is working correctly!

+

This is an automated test email.

+
+ + +` + + if err := h.MailService.SendEmail(req.To, "Charon - Test Email", htmlBody); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Test email sent successfully", + }) +} diff --git a/backend/internal/api/handlers/settings_handler_test.go b/backend/internal/api/handlers/settings_handler_test.go index c6aa6be6..ba55faf7 100644 --- a/backend/internal/api/handlers/settings_handler_test.go +++ b/backend/internal/api/handlers/settings_handler_test.go @@ -119,3 +119,286 @@ func TestSettingsHandler_Errors(t *testing.T) { router.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) } + +// ============= SMTP Settings Tests ============= + +func setupSettingsHandlerWithMail(t *testing.T) (*handlers.SettingsHandler, *gorm.DB) { + dsn := "file:" + t.Name() + "?mode=memory&cache=shared" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + panic("failed to connect to test database") + } + db.AutoMigrate(&models.Setting{}) + return handlers.NewSettingsHandler(db), db +} + +func TestSettingsHandler_GetSMTPConfig(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, db := setupSettingsHandlerWithMail(t) + + // Seed SMTP config + db.Create(&models.Setting{Key: "smtp_host", Value: "smtp.example.com", Category: "smtp", Type: "string"}) + db.Create(&models.Setting{Key: "smtp_port", Value: "587", Category: "smtp", Type: "number"}) + db.Create(&models.Setting{Key: "smtp_username", Value: "user@example.com", Category: "smtp", Type: "string"}) + db.Create(&models.Setting{Key: "smtp_password", Value: "secret123", Category: "smtp", Type: "string"}) + db.Create(&models.Setting{Key: "smtp_from_address", Value: "noreply@example.com", Category: "smtp", Type: "string"}) + db.Create(&models.Setting{Key: "smtp_encryption", Value: "starttls", Category: "smtp", Type: "string"}) + + router := gin.New() + router.GET("/settings/smtp", handler.GetSMTPConfig) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/settings/smtp", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(t, "smtp.example.com", resp["host"]) + assert.Equal(t, float64(587), resp["port"]) + assert.Equal(t, "********", resp["password"]) // Password should be masked + assert.Equal(t, true, resp["configured"]) +} + +func TestSettingsHandler_GetSMTPConfig_Empty(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, _ := setupSettingsHandlerWithMail(t) + + router := gin.New() + router.GET("/settings/smtp", handler.GetSMTPConfig) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/settings/smtp", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(t, false, resp["configured"]) +} + +func TestSettingsHandler_UpdateSMTPConfig_NonAdmin(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, _ := setupSettingsHandlerWithMail(t) + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "user") + c.Next() + }) + router.PUT("/settings/smtp", handler.UpdateSMTPConfig) + + body := map[string]interface{}{ + "host": "smtp.example.com", + "port": 587, + "from_address": "test@example.com", + "encryption": "starttls", + } + jsonBody, _ := json.Marshal(body) + req, _ := http.NewRequest("PUT", "/settings/smtp", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestSettingsHandler_UpdateSMTPConfig_InvalidJSON(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, _ := setupSettingsHandlerWithMail(t) + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + router.PUT("/settings/smtp", handler.UpdateSMTPConfig) + + req, _ := http.NewRequest("PUT", "/settings/smtp", bytes.NewBufferString("invalid")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestSettingsHandler_UpdateSMTPConfig_Success(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, _ := setupSettingsHandlerWithMail(t) + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + router.PUT("/settings/smtp", handler.UpdateSMTPConfig) + + body := map[string]interface{}{ + "host": "smtp.example.com", + "port": 587, + "username": "user@example.com", + "password": "password123", + "from_address": "noreply@example.com", + "encryption": "starttls", + } + jsonBody, _ := json.Marshal(body) + req, _ := http.NewRequest("PUT", "/settings/smtp", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestSettingsHandler_UpdateSMTPConfig_KeepExistingPassword(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, db := setupSettingsHandlerWithMail(t) + + // Seed existing password + db.Create(&models.Setting{Key: "smtp_password", Value: "existingpassword", Category: "smtp", Type: "string"}) + db.Create(&models.Setting{Key: "smtp_host", Value: "old.example.com", Category: "smtp", Type: "string"}) + db.Create(&models.Setting{Key: "smtp_port", Value: "25", Category: "smtp", Type: "number"}) + db.Create(&models.Setting{Key: "smtp_from_address", Value: "old@example.com", Category: "smtp", Type: "string"}) + db.Create(&models.Setting{Key: "smtp_encryption", Value: "none", Category: "smtp", Type: "string"}) + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + router.PUT("/settings/smtp", handler.UpdateSMTPConfig) + + // Send masked password (simulating frontend sending back masked value) + body := map[string]interface{}{ + "host": "smtp.example.com", + "port": 587, + "password": "********", // Masked + "from_address": "noreply@example.com", + "encryption": "starttls", + } + jsonBody, _ := json.Marshal(body) + req, _ := http.NewRequest("PUT", "/settings/smtp", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + // Verify password was preserved + var setting models.Setting + db.Where("key = ?", "smtp_password").First(&setting) + assert.Equal(t, "existingpassword", setting.Value) +} + +func TestSettingsHandler_TestSMTPConfig_NonAdmin(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, _ := setupSettingsHandlerWithMail(t) + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "user") + c.Next() + }) + router.POST("/settings/smtp/test", handler.TestSMTPConfig) + + req, _ := http.NewRequest("POST", "/settings/smtp/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestSettingsHandler_TestSMTPConfig_NotConfigured(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, _ := setupSettingsHandlerWithMail(t) + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + router.POST("/settings/smtp/test", handler.TestSMTPConfig) + + req, _ := http.NewRequest("POST", "/settings/smtp/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(t, false, resp["success"]) +} + +func TestSettingsHandler_SendTestEmail_NonAdmin(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, _ := setupSettingsHandlerWithMail(t) + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "user") + c.Next() + }) + router.POST("/settings/smtp/send-test", handler.SendTestEmail) + + body := map[string]string{"to": "test@example.com"} + jsonBody, _ := json.Marshal(body) + req, _ := http.NewRequest("POST", "/settings/smtp/send-test", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestSettingsHandler_SendTestEmail_InvalidJSON(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, _ := setupSettingsHandlerWithMail(t) + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + router.POST("/settings/smtp/send-test", handler.SendTestEmail) + + req, _ := http.NewRequest("POST", "/settings/smtp/send-test", bytes.NewBufferString("invalid")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestSettingsHandler_SendTestEmail_NotConfigured(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, _ := setupSettingsHandlerWithMail(t) + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + router.POST("/settings/smtp/send-test", handler.SendTestEmail) + + body := map[string]string{"to": "test@example.com"} + jsonBody, _ := json.Marshal(body) + req, _ := http.NewRequest("POST", "/settings/smtp/send-test", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(t, false, resp["success"]) +} + +func TestMaskPassword(t *testing.T) { + // Empty password + assert.Equal(t, "", handlers.MaskPasswordForTest("")) + + // Non-empty password + assert.Equal(t, "********", handlers.MaskPasswordForTest("secret")) +} diff --git a/backend/internal/api/handlers/user_handler.go b/backend/internal/api/handlers/user_handler.go index 4498b3fe..6aae2e38 100644 --- a/backend/internal/api/handlers/user_handler.go +++ b/backend/internal/api/handlers/user_handler.go @@ -1,22 +1,31 @@ package handlers import ( + "crypto/rand" + "encoding/hex" "net/http" + "strconv" "strings" + "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "gorm.io/gorm" "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/services" ) type UserHandler struct { - DB *gorm.DB + DB *gorm.DB + MailService *services.MailService } func NewUserHandler(db *gorm.DB) *UserHandler { - return &UserHandler{DB: db} + return &UserHandler{ + DB: db, + MailService: services.NewMailService(db), + } } func (h *UserHandler) RegisterRoutes(r *gin.RouterGroup) { @@ -25,6 +34,19 @@ func (h *UserHandler) RegisterRoutes(r *gin.RouterGroup) { r.GET("/profile", h.GetProfile) r.POST("/regenerate-api-key", h.RegenerateAPIKey) r.PUT("/profile", h.UpdateProfile) + + // User management (admin only) + r.GET("/users", h.ListUsers) + r.POST("/users", h.CreateUser) + r.POST("/users/invite", h.InviteUser) + r.GET("/users/:id", h.GetUser) + r.PUT("/users/:id", h.UpdateUser) + r.DELETE("/users/:id", h.DeleteUser) + r.PUT("/users/:id/permissions", h.UpdateUserPermissions) + + // Invite acceptance (public) + r.GET("/invite/validate", h.ValidateInvite) + r.POST("/invite/accept", h.AcceptInvite) } // GetSetupStatus checks if the application needs initial setup (i.e., no users exist). @@ -220,3 +242,591 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Profile updated successfully"}) } + +// ListUsers returns all users (admin only). +func (h *UserHandler) ListUsers(c *gin.Context) { + role, _ := c.Get("role") + if role != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) + return + } + + var users []models.User + if err := h.DB.Preload("PermittedHosts").Find(&users).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch users"}) + return + } + + // Return users with safe fields only + result := make([]gin.H, len(users)) + for i, u := range users { + result[i] = gin.H{ + "id": u.ID, + "uuid": u.UUID, + "email": u.Email, + "name": u.Name, + "role": u.Role, + "enabled": u.Enabled, + "last_login": u.LastLogin, + "invite_status": u.InviteStatus, + "invited_at": u.InvitedAt, + "permission_mode": u.PermissionMode, + "created_at": u.CreatedAt, + "updated_at": u.UpdatedAt, + } + } + + c.JSON(http.StatusOK, result) +} + +// CreateUserRequest represents the request body for creating a user. +type CreateUserRequest struct { + Email string `json:"email" binding:"required,email"` + Name string `json:"name" binding:"required"` + Password string `json:"password" binding:"required,min=8"` + Role string `json:"role"` + PermissionMode string `json:"permission_mode"` + PermittedHosts []uint `json:"permitted_hosts"` +} + +// CreateUser creates a new user with a password (admin only). +func (h *UserHandler) CreateUser(c *gin.Context) { + role, _ := c.Get("role") + if role != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) + return + } + + var req CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Default role to "user" + if req.Role == "" { + req.Role = "user" + } + + // Default permission mode to "allow_all" + if req.PermissionMode == "" { + req.PermissionMode = "allow_all" + } + + // Check if email already exists + var count int64 + if err := h.DB.Model(&models.User{}).Where("email = ?", strings.ToLower(req.Email)).Count(&count).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check email"}) + return + } + if count > 0 { + c.JSON(http.StatusConflict, gin.H{"error": "Email already in use"}) + return + } + + user := models.User{ + UUID: uuid.New().String(), + Email: strings.ToLower(req.Email), + Name: req.Name, + Role: req.Role, + Enabled: true, + APIKey: uuid.New().String(), + PermissionMode: models.PermissionMode(req.PermissionMode), + } + + if err := user.SetPassword(req.Password); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) + return + } + + err := h.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&user).Error; err != nil { + return err + } + + // Add permitted hosts if specified + if len(req.PermittedHosts) > 0 { + var hosts []models.ProxyHost + if err := tx.Where("id IN ?", req.PermittedHosts).Find(&hosts).Error; err != nil { + return err + } + if err := tx.Model(&user).Association("PermittedHosts").Replace(hosts); err != nil { + return err + } + } + + return nil + }) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "id": user.ID, + "uuid": user.UUID, + "email": user.Email, + "name": user.Name, + "role": user.Role, + }) +} + +// InviteUserRequest represents the request body for inviting a user. +type InviteUserRequest struct { + Email string `json:"email" binding:"required,email"` + Role string `json:"role"` + PermissionMode string `json:"permission_mode"` + PermittedHosts []uint `json:"permitted_hosts"` +} + +// generateSecureToken creates a cryptographically secure random token. +func generateSecureToken(length int) (string, error) { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +// InviteUser creates a new user with an invite token and sends an email (admin only). +func (h *UserHandler) InviteUser(c *gin.Context) { + role, _ := c.Get("role") + if role != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) + return + } + + inviterID, _ := c.Get("userID") + + var req InviteUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Default role to "user" + if req.Role == "" { + req.Role = "user" + } + + // Default permission mode to "allow_all" + if req.PermissionMode == "" { + req.PermissionMode = "allow_all" + } + + // Check if email already exists + var existingUser models.User + if err := h.DB.Where("email = ?", strings.ToLower(req.Email)).First(&existingUser).Error; err == nil { + c.JSON(http.StatusConflict, gin.H{"error": "Email already in use"}) + return + } + + // Generate invite token + inviteToken, err := generateSecureToken(32) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate invite token"}) + return + } + + // Set invite expiration (48 hours) + inviteExpires := time.Now().Add(48 * time.Hour) + invitedAt := time.Now() + inviterIDUint := inviterID.(uint) + + user := models.User{ + UUID: uuid.New().String(), + Email: strings.ToLower(req.Email), + Role: req.Role, + Enabled: false, // Disabled until invite is accepted + APIKey: uuid.New().String(), + PermissionMode: models.PermissionMode(req.PermissionMode), + InviteToken: inviteToken, + InviteExpires: &inviteExpires, + InvitedAt: &invitedAt, + InvitedBy: &inviterIDUint, + InviteStatus: "pending", + } + + err = h.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&user).Error; err != nil { + return err + } + + // Explicitly disable user (bypass GORM's default:true) + if err := tx.Model(&user).Update("enabled", false).Error; err != nil { + return err + } + + // Add permitted hosts if specified + if len(req.PermittedHosts) > 0 { + var hosts []models.ProxyHost + if err := tx.Where("id IN ?", req.PermittedHosts).Find(&hosts).Error; err != nil { + return err + } + if err := tx.Model(&user).Association("PermittedHosts").Replace(hosts); err != nil { + return err + } + } + + return nil + }) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()}) + return + } + + // Try to send invite email + emailSent := false + if h.MailService.IsConfigured() { + baseURL := getBaseURL(c) + appName := getAppName(h.DB) + if err := h.MailService.SendInvite(user.Email, inviteToken, appName, baseURL); err == nil { + emailSent = true + } + } + + c.JSON(http.StatusCreated, gin.H{ + "id": user.ID, + "uuid": user.UUID, + "email": user.Email, + "role": user.Role, + "invite_token": inviteToken, // Return token in case email fails + "email_sent": emailSent, + "expires_at": inviteExpires, + }) +} + +// getBaseURL extracts the base URL from the request. +func getBaseURL(c *gin.Context) string { + scheme := "https" + if c.Request.TLS == nil { + // Check for X-Forwarded-Proto header + if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" { + scheme = proto + } else { + scheme = "http" + } + } + return scheme + "://" + c.Request.Host +} + +// getAppName retrieves the application name from settings or returns a default. +func getAppName(db *gorm.DB) string { + var setting models.Setting + if err := db.Where("key = ?", "app_name").First(&setting).Error; err == nil && setting.Value != "" { + return setting.Value + } + return "Charon" +} + +// GetUser returns a single user by ID (admin only). +func (h *UserHandler) GetUser(c *gin.Context) { + role, _ := c.Get("role") + if role != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) + return + } + + idParam := c.Param("id") + id, err := strconv.ParseUint(idParam, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"}) + return + } + + var user models.User + if err := h.DB.Preload("PermittedHosts").First(&user, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + // Build permitted host IDs list + permittedHostIDs := make([]uint, len(user.PermittedHosts)) + for i, host := range user.PermittedHosts { + permittedHostIDs[i] = host.ID + } + + c.JSON(http.StatusOK, gin.H{ + "id": user.ID, + "uuid": user.UUID, + "email": user.Email, + "name": user.Name, + "role": user.Role, + "enabled": user.Enabled, + "last_login": user.LastLogin, + "invite_status": user.InviteStatus, + "invited_at": user.InvitedAt, + "permission_mode": user.PermissionMode, + "permitted_hosts": permittedHostIDs, + "created_at": user.CreatedAt, + "updated_at": user.UpdatedAt, + }) +} + +// UpdateUserRequest represents the request body for updating a user. +type UpdateUserRequest struct { + Name string `json:"name"` + Email string `json:"email"` + Role string `json:"role"` + Enabled *bool `json:"enabled"` +} + +// UpdateUser updates an existing user (admin only). +func (h *UserHandler) UpdateUser(c *gin.Context) { + role, _ := c.Get("role") + if role != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) + return + } + + idParam := c.Param("id") + id, err := strconv.ParseUint(idParam, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"}) + return + } + + var user models.User + if err := h.DB.First(&user, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + var req UpdateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + updates := make(map[string]interface{}) + + if req.Name != "" { + updates["name"] = req.Name + } + + if req.Email != "" { + email := strings.ToLower(req.Email) + // Check if email is taken by another user + var count int64 + if err := h.DB.Model(&models.User{}).Where("email = ? AND id != ?", email, id).Count(&count).Error; err == nil && count > 0 { + c.JSON(http.StatusConflict, gin.H{"error": "Email already in use"}) + return + } + updates["email"] = email + } + + if req.Role != "" { + updates["role"] = req.Role + } + + if req.Enabled != nil { + updates["enabled"] = *req.Enabled + } + + if len(updates) > 0 { + if err := h.DB.Model(&user).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"}) + return + } + } + + c.JSON(http.StatusOK, gin.H{"message": "User updated successfully"}) +} + +// DeleteUser deletes a user (admin only). +func (h *UserHandler) DeleteUser(c *gin.Context) { + role, _ := c.Get("role") + if role != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) + return + } + + currentUserID, _ := c.Get("userID") + + idParam := c.Param("id") + id, err := strconv.ParseUint(idParam, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"}) + return + } + + // Prevent self-deletion + if uint(id) == currentUserID.(uint) { + c.JSON(http.StatusForbidden, gin.H{"error": "Cannot delete your own account"}) + return + } + + var user models.User + if err := h.DB.First(&user, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + // Clear associations first + if err := h.DB.Model(&user).Association("PermittedHosts").Clear(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear user associations"}) + return + } + + if err := h.DB.Delete(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "User deleted successfully"}) +} + +// UpdateUserPermissionsRequest represents the request body for updating user permissions. +type UpdateUserPermissionsRequest struct { + PermissionMode string `json:"permission_mode" binding:"required,oneof=allow_all deny_all"` + PermittedHosts []uint `json:"permitted_hosts"` +} + +// UpdateUserPermissions updates a user's permission mode and host exceptions (admin only). +func (h *UserHandler) UpdateUserPermissions(c *gin.Context) { + role, _ := c.Get("role") + if role != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) + return + } + + idParam := c.Param("id") + id, err := strconv.ParseUint(idParam, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"}) + return + } + + var user models.User + if err := h.DB.First(&user, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + var req UpdateUserPermissionsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + err = h.DB.Transaction(func(tx *gorm.DB) error { + // Update permission mode + if err := tx.Model(&user).Update("permission_mode", req.PermissionMode).Error; err != nil { + return err + } + + // Update permitted hosts + var hosts []models.ProxyHost + if len(req.PermittedHosts) > 0 { + if err := tx.Where("id IN ?", req.PermittedHosts).Find(&hosts).Error; err != nil { + return err + } + } + + if err := tx.Model(&user).Association("PermittedHosts").Replace(hosts); err != nil { + return err + } + + return nil + }) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update permissions: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Permissions updated successfully"}) +} + +// ValidateInvite validates an invite token (public endpoint). +func (h *UserHandler) ValidateInvite(c *gin.Context) { + token := c.Query("token") + if token == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Token required"}) + return + } + + var user models.User + if err := h.DB.Where("invite_token = ?", token).First(&user).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Invalid or expired invite token"}) + return + } + + // Check if token is expired + if user.InviteExpires != nil && user.InviteExpires.Before(time.Now()) { + c.JSON(http.StatusGone, gin.H{"error": "Invite token has expired"}) + return + } + + // Check if already accepted + if user.InviteStatus != "pending" { + c.JSON(http.StatusConflict, gin.H{"error": "Invite has already been accepted"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "valid": true, + "email": user.Email, + }) +} + +// AcceptInviteRequest represents the request body for accepting an invite. +type AcceptInviteRequest struct { + Token string `json:"token" binding:"required"` + Name string `json:"name" binding:"required"` + Password string `json:"password" binding:"required,min=8"` +} + +// AcceptInvite accepts an invitation and sets the user's password (public endpoint). +func (h *UserHandler) AcceptInvite(c *gin.Context) { + var req AcceptInviteRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var user models.User + if err := h.DB.Where("invite_token = ?", req.Token).First(&user).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Invalid or expired invite token"}) + return + } + + // Check if token is expired + if user.InviteExpires != nil && user.InviteExpires.Before(time.Now()) { + // Mark as expired + h.DB.Model(&user).Update("invite_status", "expired") + c.JSON(http.StatusGone, gin.H{"error": "Invite token has expired"}) + return + } + + // Check if already accepted + if user.InviteStatus != "pending" { + c.JSON(http.StatusConflict, gin.H{"error": "Invite has already been accepted"}) + return + } + + // Set password and activate user + if err := user.SetPassword(req.Password); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to set password"}) + return + } + + if err := h.DB.Model(&user).Updates(map[string]interface{}{ + "name": req.Name, + "password_hash": user.PasswordHash, + "enabled": true, + "invite_token": "", // Clear token + "invite_expires": nil, // Clear expiration + "invite_status": "accepted", + }).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to accept invite"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Invite accepted successfully", + "email": user.Email, + }) +} diff --git a/backend/internal/api/handlers/user_handler_test.go b/backend/internal/api/handlers/user_handler_test.go index 864d79c3..52e2a404 100644 --- a/backend/internal/api/handlers/user_handler_test.go +++ b/backend/internal/api/handlers/user_handler_test.go @@ -5,7 +5,9 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strconv" "testing" + "time" "github.com/Wikid82/charon/backend/internal/models" "github.com/gin-gonic/gin" @@ -386,3 +388,1036 @@ func TestUserHandler_UpdateProfile_Errors(t *testing.T) { r.ServeHTTP(w, req) assert.Equal(t, http.StatusNotFound, w.Code) } + +// ============= User Management Tests (Admin functions) ============= + +func setupUserHandlerWithProxyHosts(t *testing.T) (*UserHandler, *gorm.DB) { + dbName := "file:" + t.Name() + "?mode=memory&cache=shared" + db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{}) + require.NoError(t, err) + db.AutoMigrate(&models.User{}, &models.Setting{}, &models.ProxyHost{}) + return NewUserHandler(db), db +} + +func TestUserHandler_ListUsers_NonAdmin(t *testing.T) { + handler, _ := setupUserHandlerWithProxyHosts(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "user") + c.Next() + }) + r.GET("/users", handler.ListUsers) + + req := httptest.NewRequest("GET", "/users", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestUserHandler_ListUsers_Admin(t *testing.T) { + handler, db := setupUserHandlerWithProxyHosts(t) + + // Create users with unique API keys + user1 := &models.User{UUID: uuid.NewString(), Email: "user1@example.com", Name: "User 1", APIKey: uuid.NewString()} + user2 := &models.User{UUID: uuid.NewString(), Email: "user2@example.com", Name: "User 2", APIKey: uuid.NewString()} + db.Create(user1) + db.Create(user2) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + r.GET("/users", handler.ListUsers) + + req := httptest.NewRequest("GET", "/users", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var users []map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &users) + assert.Len(t, users, 2) +} + +func TestUserHandler_CreateUser_NonAdmin(t *testing.T) { + handler, _ := setupUserHandlerWithProxyHosts(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "user") + c.Next() + }) + r.POST("/users", handler.CreateUser) + + body := map[string]interface{}{ + "email": "new@example.com", + "name": "New User", + "password": "password123", + } + jsonBody, _ := json.Marshal(body) + req := httptest.NewRequest("POST", "/users", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestUserHandler_CreateUser_Admin(t *testing.T) { + handler, _ := setupUserHandlerWithProxyHosts(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + r.POST("/users", handler.CreateUser) + + body := map[string]interface{}{ + "email": "newuser@example.com", + "name": "New User", + "password": "password123", + } + jsonBody, _ := json.Marshal(body) + req := httptest.NewRequest("POST", "/users", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) +} + +func TestUserHandler_CreateUser_InvalidJSON(t *testing.T) { + handler, _ := setupUserHandlerWithProxyHosts(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + r.POST("/users", handler.CreateUser) + + req := httptest.NewRequest("POST", "/users", bytes.NewBufferString("invalid")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestUserHandler_CreateUser_DuplicateEmail(t *testing.T) { + handler, db := setupUserHandlerWithProxyHosts(t) + + existing := &models.User{UUID: uuid.NewString(), Email: "existing@example.com", Name: "Existing"} + db.Create(existing) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + r.POST("/users", handler.CreateUser) + + body := map[string]interface{}{ + "email": "existing@example.com", + "name": "New User", + "password": "password123", + } + jsonBody, _ := json.Marshal(body) + req := httptest.NewRequest("POST", "/users", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusConflict, w.Code) +} + +func TestUserHandler_CreateUser_WithPermittedHosts(t *testing.T) { + handler, db := setupUserHandlerWithProxyHosts(t) + + host := &models.ProxyHost{Name: "Host 1", DomainNames: "host1.example.com", Enabled: true} + db.Create(host) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + r.POST("/users", handler.CreateUser) + + body := map[string]interface{}{ + "email": "withhosts@example.com", + "name": "User With Hosts", + "password": "password123", + "permission_mode": "deny_all", + "permitted_hosts": []uint{host.ID}, + } + jsonBody, _ := json.Marshal(body) + req := httptest.NewRequest("POST", "/users", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) +} + +func TestUserHandler_GetUser_NonAdmin(t *testing.T) { + handler, _ := setupUserHandlerWithProxyHosts(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "user") + c.Next() + }) + r.GET("/users/:id", handler.GetUser) + + req := httptest.NewRequest("GET", "/users/1", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestUserHandler_GetUser_InvalidID(t *testing.T) { + handler, _ := setupUserHandlerWithProxyHosts(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + r.GET("/users/:id", handler.GetUser) + + req := httptest.NewRequest("GET", "/users/invalid", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestUserHandler_GetUser_NotFound(t *testing.T) { + handler, _ := setupUserHandlerWithProxyHosts(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + r.GET("/users/:id", handler.GetUser) + + req := httptest.NewRequest("GET", "/users/999", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestUserHandler_GetUser_Success(t *testing.T) { + handler, db := setupUserHandlerWithProxyHosts(t) + + user := &models.User{UUID: uuid.NewString(), Email: "getuser@example.com", Name: "Get User"} + db.Create(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + r.GET("/users/:id", handler.GetUser) + + req := httptest.NewRequest("GET", "/users/1", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestUserHandler_UpdateUser_NonAdmin(t *testing.T) { + handler, _ := setupUserHandlerWithProxyHosts(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "user") + c.Next() + }) + r.PUT("/users/:id", handler.UpdateUser) + + body := map[string]interface{}{"name": "Updated"} + jsonBody, _ := json.Marshal(body) + req := httptest.NewRequest("PUT", "/users/1", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestUserHandler_UpdateUser_InvalidID(t *testing.T) { + handler, _ := setupUserHandlerWithProxyHosts(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + r.PUT("/users/:id", handler.UpdateUser) + + body := map[string]interface{}{"name": "Updated"} + jsonBody, _ := json.Marshal(body) + req := httptest.NewRequest("PUT", "/users/invalid", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestUserHandler_UpdateUser_InvalidJSON(t *testing.T) { + handler, db := setupUserHandlerWithProxyHosts(t) + + // Create user first + user := &models.User{UUID: uuid.NewString(), Email: "toupdate@example.com", Name: "To Update", APIKey: uuid.NewString()} + db.Create(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + r.PUT("/users/:id", handler.UpdateUser) + + req := httptest.NewRequest("PUT", "/users/1", bytes.NewBufferString("invalid")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestUserHandler_UpdateUser_NotFound(t *testing.T) { + handler, _ := setupUserHandlerWithProxyHosts(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + r.PUT("/users/:id", handler.UpdateUser) + + body := map[string]interface{}{"name": "Updated"} + jsonBody, _ := json.Marshal(body) + req := httptest.NewRequest("PUT", "/users/999", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestUserHandler_UpdateUser_Success(t *testing.T) { + handler, db := setupUserHandlerWithProxyHosts(t) + + user := &models.User{UUID: uuid.NewString(), Email: "update@example.com", Name: "Original", Role: "user"} + db.Create(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + r.PUT("/users/:id", handler.UpdateUser) + + body := map[string]interface{}{ + "name": "Updated Name", + "enabled": true, + } + jsonBody, _ := json.Marshal(body) + req := httptest.NewRequest("PUT", "/users/1", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestUserHandler_DeleteUser_NonAdmin(t *testing.T) { + handler, _ := setupUserHandlerWithProxyHosts(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "user") + c.Next() + }) + r.DELETE("/users/:id", handler.DeleteUser) + + req := httptest.NewRequest("DELETE", "/users/1", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestUserHandler_DeleteUser_InvalidID(t *testing.T) { + handler, _ := setupUserHandlerWithProxyHosts(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + r.DELETE("/users/:id", handler.DeleteUser) + + req := httptest.NewRequest("DELETE", "/users/invalid", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestUserHandler_DeleteUser_NotFound(t *testing.T) { + handler, _ := setupUserHandlerWithProxyHosts(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", uint(1)) // Current user ID (different from target) + c.Next() + }) + r.DELETE("/users/:id", handler.DeleteUser) + + req := httptest.NewRequest("DELETE", "/users/999", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestUserHandler_DeleteUser_Success(t *testing.T) { + handler, db := setupUserHandlerWithProxyHosts(t) + + user := &models.User{UUID: uuid.NewString(), Email: "delete@example.com", Name: "Delete Me"} + db.Create(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", uint(999)) // Different user + c.Next() + }) + r.DELETE("/users/:id", handler.DeleteUser) + + req := httptest.NewRequest("DELETE", "/users/1", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestUserHandler_DeleteUser_CannotDeleteSelf(t *testing.T) { + handler, db := setupUserHandlerWithProxyHosts(t) + + user := &models.User{UUID: uuid.NewString(), Email: "self@example.com", Name: "Self"} + db.Create(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", user.ID) // Same user + c.Next() + }) + r.DELETE("/users/:id", handler.DeleteUser) + + req := httptest.NewRequest("DELETE", "/users/1", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestUserHandler_UpdateUserPermissions_NonAdmin(t *testing.T) { + handler, _ := setupUserHandlerWithProxyHosts(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "user") + c.Next() + }) + r.PUT("/users/:id/permissions", handler.UpdateUserPermissions) + + body := map[string]interface{}{"permission_mode": "allow_all"} + jsonBody, _ := json.Marshal(body) + req := httptest.NewRequest("PUT", "/users/1/permissions", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestUserHandler_UpdateUserPermissions_InvalidID(t *testing.T) { + handler, _ := setupUserHandlerWithProxyHosts(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + r.PUT("/users/:id/permissions", handler.UpdateUserPermissions) + + body := map[string]interface{}{"permission_mode": "allow_all"} + jsonBody, _ := json.Marshal(body) + req := httptest.NewRequest("PUT", "/users/invalid/permissions", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestUserHandler_UpdateUserPermissions_InvalidJSON(t *testing.T) { + handler, db := setupUserHandlerWithProxyHosts(t) + + // Create a user first + user := &models.User{ + UUID: uuid.NewString(), + APIKey: uuid.NewString(), + Email: "perms-invalid@example.com", + Name: "Perms Invalid Test", + Role: "user", + Enabled: true, + } + db.Create(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + r.PUT("/users/:id/permissions", handler.UpdateUserPermissions) + + req := httptest.NewRequest("PUT", "/users/"+strconv.FormatUint(uint64(user.ID), 10)+"/permissions", bytes.NewBufferString("invalid")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestUserHandler_UpdateUserPermissions_NotFound(t *testing.T) { + handler, _ := setupUserHandlerWithProxyHosts(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + r.PUT("/users/:id/permissions", handler.UpdateUserPermissions) + + body := map[string]interface{}{"permission_mode": "allow_all"} + jsonBody, _ := json.Marshal(body) + req := httptest.NewRequest("PUT", "/users/999/permissions", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestUserHandler_UpdateUserPermissions_Success(t *testing.T) { + handler, db := setupUserHandlerWithProxyHosts(t) + + host := &models.ProxyHost{Name: "Host 1", DomainNames: "host1.example.com", Enabled: true} + db.Create(host) + + user := &models.User{ + UUID: uuid.NewString(), + Email: "perms@example.com", + Name: "Perms User", + PermissionMode: models.PermissionModeAllowAll, + } + db.Create(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + r.PUT("/users/:id/permissions", handler.UpdateUserPermissions) + + body := map[string]interface{}{ + "permission_mode": "deny_all", + "permitted_hosts": []uint{host.ID}, + } + jsonBody, _ := json.Marshal(body) + req := httptest.NewRequest("PUT", "/users/1/permissions", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestUserHandler_ValidateInvite_MissingToken(t *testing.T) { + handler, _ := setupUserHandlerWithProxyHosts(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/invite/validate", handler.ValidateInvite) + + req := httptest.NewRequest("GET", "/invite/validate", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestUserHandler_ValidateInvite_InvalidToken(t *testing.T) { + handler, _ := setupUserHandlerWithProxyHosts(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/invite/validate", handler.ValidateInvite) + + req := httptest.NewRequest("GET", "/invite/validate?token=invalidtoken", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestUserHandler_ValidateInvite_ExpiredToken(t *testing.T) { + handler, db := setupUserHandlerWithProxyHosts(t) + + expiredTime := time.Now().Add(-24 * time.Hour) // Expired yesterday + user := &models.User{ + UUID: uuid.NewString(), + Email: "expired@example.com", + Name: "Expired Invite", + InviteToken: "expiredtoken123", + InviteExpires: &expiredTime, + InviteStatus: "pending", + } + db.Create(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/invite/validate", handler.ValidateInvite) + + req := httptest.NewRequest("GET", "/invite/validate?token=expiredtoken123", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusGone, w.Code) +} + +func TestUserHandler_ValidateInvite_AlreadyAccepted(t *testing.T) { + handler, db := setupUserHandlerWithProxyHosts(t) + + expiresAt := time.Now().Add(24 * time.Hour) + user := &models.User{ + UUID: uuid.NewString(), + Email: "accepted@example.com", + Name: "Accepted Invite", + InviteToken: "acceptedtoken123", + InviteExpires: &expiresAt, + InviteStatus: "accepted", + } + db.Create(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/invite/validate", handler.ValidateInvite) + + req := httptest.NewRequest("GET", "/invite/validate?token=acceptedtoken123", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusConflict, w.Code) +} + +func TestUserHandler_ValidateInvite_Success(t *testing.T) { + handler, db := setupUserHandlerWithProxyHosts(t) + + expiresAt := time.Now().Add(24 * time.Hour) + user := &models.User{ + UUID: uuid.NewString(), + Email: "valid@example.com", + Name: "Valid Invite", + InviteToken: "validtoken123", + InviteExpires: &expiresAt, + InviteStatus: "pending", + } + db.Create(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/invite/validate", handler.ValidateInvite) + + req := httptest.NewRequest("GET", "/invite/validate?token=validtoken123", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(t, "valid@example.com", resp["email"]) +} + +func TestUserHandler_AcceptInvite_InvalidJSON(t *testing.T) { + handler, _ := setupUserHandlerWithProxyHosts(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.POST("/invite/accept", handler.AcceptInvite) + + req := httptest.NewRequest("POST", "/invite/accept", bytes.NewBufferString("invalid")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestUserHandler_AcceptInvite_InvalidToken(t *testing.T) { + handler, _ := setupUserHandlerWithProxyHosts(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.POST("/invite/accept", handler.AcceptInvite) + + body := map[string]string{ + "token": "invalidtoken", + "name": "Test User", + "password": "password123", + } + jsonBody, _ := json.Marshal(body) + req := httptest.NewRequest("POST", "/invite/accept", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestUserHandler_AcceptInvite_Success(t *testing.T) { + handler, db := setupUserHandlerWithProxyHosts(t) + + expiresAt := time.Now().Add(24 * time.Hour) + user := &models.User{ + UUID: uuid.NewString(), + Email: "accept@example.com", + Name: "Accept User", + InviteToken: "accepttoken123", + InviteExpires: &expiresAt, + InviteStatus: "pending", + } + db.Create(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.POST("/invite/accept", handler.AcceptInvite) + + body := map[string]string{ + "token": "accepttoken123", + "password": "newpassword123", + "name": "Accepted User", + } + jsonBody, _ := json.Marshal(body) + req := httptest.NewRequest("POST", "/invite/accept", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + // Verify user was updated + var updated models.User + db.First(&updated, user.ID) + assert.Equal(t, "accepted", updated.InviteStatus) + assert.True(t, updated.Enabled) +} + +func TestGenerateSecureToken(t *testing.T) { + token, err := generateSecureToken(32) + assert.NoError(t, err) + assert.Len(t, token, 64) // 32 bytes = 64 hex chars + assert.Regexp(t, "^[a-f0-9]+$", token) + + // Ensure uniqueness + token2, err := generateSecureToken(32) + assert.NoError(t, err) + assert.NotEqual(t, token, token2) +} + +func TestUserHandler_InviteUser_NonAdmin(t *testing.T) { + handler, _ := setupUserHandlerWithProxyHosts(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "user") + c.Set("userID", uint(1)) + c.Next() + }) + r.POST("/users/invite", handler.InviteUser) + + body := map[string]string{"email": "invitee@example.com"} + jsonBody, _ := json.Marshal(body) + req := httptest.NewRequest("POST", "/users/invite", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestUserHandler_InviteUser_InvalidJSON(t *testing.T) { + handler, _ := setupUserHandlerWithProxyHosts(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", uint(1)) + c.Next() + }) + r.POST("/users/invite", handler.InviteUser) + + req := httptest.NewRequest("POST", "/users/invite", bytes.NewBufferString("invalid")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestUserHandler_InviteUser_DuplicateEmail(t *testing.T) { + handler, db := setupUserHandlerWithProxyHosts(t) + + // Create existing user + existingUser := &models.User{ + UUID: uuid.NewString(), + APIKey: uuid.NewString(), + Email: "existing@example.com", + } + db.Create(existingUser) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", uint(1)) + c.Next() + }) + r.POST("/users/invite", handler.InviteUser) + + body := map[string]string{"email": "existing@example.com"} + jsonBody, _ := json.Marshal(body) + req := httptest.NewRequest("POST", "/users/invite", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusConflict, w.Code) +} + +func TestUserHandler_InviteUser_Success(t *testing.T) { + handler, db := setupUserHandlerWithProxyHosts(t) + + // Create admin user + admin := &models.User{ + UUID: uuid.NewString(), + APIKey: uuid.NewString(), + Email: "admin@example.com", + Role: "admin", + } + db.Create(admin) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", admin.ID) + c.Next() + }) + r.POST("/users/invite", handler.InviteUser) + + body := map[string]interface{}{ + "email": "newinvite@example.com", + "role": "user", + } + jsonBody, _ := json.Marshal(body) + req := httptest.NewRequest("POST", "/users/invite", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + assert.NotEmpty(t, resp["invite_token"]) + // email_sent is false because no SMTP is configured + assert.Equal(t, false, resp["email_sent"].(bool)) + + // Verify user was created + var user models.User + db.Where("email = ?", "newinvite@example.com").First(&user) + assert.Equal(t, "pending", user.InviteStatus) + assert.False(t, user.Enabled) +} + +func TestUserHandler_InviteUser_WithPermittedHosts(t *testing.T) { + handler, db := setupUserHandlerWithProxyHosts(t) + + // Create admin user + admin := &models.User{ + UUID: uuid.NewString(), + APIKey: uuid.NewString(), + Email: "admin-perm@example.com", + Role: "admin", + } + db.Create(admin) + + // Create proxy host + host := &models.ProxyHost{ + UUID: uuid.NewString(), + Name: "Test Host", + DomainNames: "test.example.com", + } + db.Create(host) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", admin.ID) + c.Next() + }) + r.POST("/users/invite", handler.InviteUser) + + body := map[string]interface{}{ + "email": "invitee-perms@example.com", + "permission_mode": "deny_all", + "permitted_hosts": []uint{host.ID}, + } + jsonBody, _ := json.Marshal(body) + req := httptest.NewRequest("POST", "/users/invite", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + + // Verify user has permitted hosts + var user models.User + db.Preload("PermittedHosts").Where("email = ?", "invitee-perms@example.com").First(&user) + assert.Len(t, user.PermittedHosts, 1) + assert.Equal(t, models.PermissionModeDenyAll, user.PermissionMode) +} + +func TestGetBaseURL(t *testing.T) { + gin.SetMode(gin.TestMode) + + // Test with X-Forwarded-Proto header + r := gin.New() + r.GET("/test", func(c *gin.Context) { + url := getBaseURL(c) + c.String(200, url) + }) + + req := httptest.NewRequest("GET", "/test", nil) + req.Host = "example.com" + req.Header.Set("X-Forwarded-Proto", "https") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, "https://example.com", w.Body.String()) +} + +func TestGetAppName(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:appname?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + db.AutoMigrate(&models.Setting{}) + + // Test default + name := getAppName(db) + assert.Equal(t, "Charon", name) + + // Test with custom setting + db.Create(&models.Setting{Key: "app_name", Value: "CustomApp"}) + name = getAppName(db) + assert.Equal(t, "CustomApp", name) +} + +func TestUserHandler_AcceptInvite_ExpiredToken(t *testing.T) { + handler, db := setupUserHandlerWithProxyHosts(t) + + // Create user with expired invite + expired := time.Now().Add(-24 * time.Hour) + user := &models.User{ + UUID: uuid.NewString(), + APIKey: uuid.NewString(), + Email: "expired-invite@example.com", + InviteToken: "expiredtoken123", + InviteExpires: &expired, + InviteStatus: "pending", + } + db.Create(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.POST("/invite/accept", handler.AcceptInvite) + + body := map[string]string{ + "token": "expiredtoken123", + "name": "Expired User", + "password": "password123", + } + jsonBody, _ := json.Marshal(body) + req := httptest.NewRequest("POST", "/invite/accept", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusGone, w.Code) +} + +func TestUserHandler_AcceptInvite_AlreadyAccepted(t *testing.T) { + handler, db := setupUserHandlerWithProxyHosts(t) + + expires := time.Now().Add(24 * time.Hour) + user := &models.User{ + UUID: uuid.NewString(), + APIKey: uuid.NewString(), + Email: "accepted-already@example.com", + InviteToken: "acceptedtoken123", + InviteExpires: &expires, + InviteStatus: "accepted", + } + db.Create(user) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.POST("/invite/accept", handler.AcceptInvite) + + body := map[string]string{ + "token": "acceptedtoken123", + "name": "Already Accepted", + "password": "password123", + } + jsonBody, _ := json.Marshal(body) + req := httptest.NewRequest("POST", "/invite/accept", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusConflict, w.Code) +} diff --git a/backend/internal/api/middleware/security.go b/backend/internal/api/middleware/security.go new file mode 100644 index 00000000..6488f803 --- /dev/null +++ b/backend/internal/api/middleware/security.go @@ -0,0 +1,126 @@ +package middleware + +import ( + "fmt" + "strings" + + "github.com/gin-gonic/gin" +) + +// SecurityHeadersConfig holds configuration for the security headers middleware. +type SecurityHeadersConfig struct { + // IsDevelopment enables less strict settings for local development + IsDevelopment bool + // CustomCSPDirectives allows adding extra CSP directives + CustomCSPDirectives map[string]string +} + +// DefaultSecurityHeadersConfig returns a secure default configuration. +func DefaultSecurityHeadersConfig() SecurityHeadersConfig { + return SecurityHeadersConfig{ + IsDevelopment: false, + CustomCSPDirectives: nil, + } +} + +// SecurityHeaders returns middleware that sets security-related HTTP headers. +// This implements Phase 1 of the security hardening plan. +func SecurityHeaders(cfg SecurityHeadersConfig) gin.HandlerFunc { + return func(c *gin.Context) { + // Build Content-Security-Policy + csp := buildCSP(cfg) + c.Header("Content-Security-Policy", csp) + + // Strict-Transport-Security (HSTS) + // max-age=31536000 = 1 year + // includeSubDomains ensures all subdomains also use HTTPS + // preload allows browser preload lists (requires submission to hstspreload.org) + if !cfg.IsDevelopment { + c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload") + } + + // X-Frame-Options: Prevent clickjacking + // DENY prevents any framing; SAMEORIGIN would allow same-origin framing + c.Header("X-Frame-Options", "DENY") + + // X-Content-Type-Options: Prevent MIME sniffing + c.Header("X-Content-Type-Options", "nosniff") + + // X-XSS-Protection: Enable browser XSS filtering (legacy but still useful) + // mode=block tells browser to block the response if XSS is detected + c.Header("X-XSS-Protection", "1; mode=block") + + // Referrer-Policy: Control referrer information sent with requests + // strict-origin-when-cross-origin sends full URL for same-origin, origin only for cross-origin + c.Header("Referrer-Policy", "strict-origin-when-cross-origin") + + // Permissions-Policy: Restrict browser features + // Disable features that aren't needed for security + c.Header("Permissions-Policy", buildPermissionsPolicy()) + + // Cross-Origin-Opener-Policy: Isolate browsing context + c.Header("Cross-Origin-Opener-Policy", "same-origin") + + // Cross-Origin-Resource-Policy: Prevent cross-origin reads + c.Header("Cross-Origin-Resource-Policy", "same-origin") + + // Cross-Origin-Embedder-Policy: Require CORP for cross-origin resources + // Note: This can break some external resources, use with caution + // c.Header("Cross-Origin-Embedder-Policy", "require-corp") + + c.Next() + } +} + +// buildCSP constructs the Content-Security-Policy header value. +func buildCSP(cfg SecurityHeadersConfig) string { + // Base CSP directives for a secure single-page application + directives := map[string]string{ + "default-src": "'self'", + "script-src": "'self'", + "style-src": "'self' 'unsafe-inline'", // unsafe-inline needed for many CSS-in-JS solutions + "img-src": "'self' data: https:", // Allow HTTPS images and data URIs + "font-src": "'self' data:", // Allow self-hosted fonts and data URIs + "connect-src": "'self'", // API connections + "frame-src": "'none'", // No iframes + "object-src": "'none'", // No plugins (Flash, etc.) + "base-uri": "'self'", // Restrict base tag + "form-action": "'self'", // Restrict form submissions + } + + // In development, allow more sources for hot reloading, etc. + if cfg.IsDevelopment { + directives["script-src"] = "'self' 'unsafe-inline' 'unsafe-eval'" + directives["connect-src"] = "'self' ws: wss:" // WebSocket for HMR + } + + // Apply custom directives + for key, value := range cfg.CustomCSPDirectives { + directives[key] = value + } + + // Build the CSP string + var parts []string + for directive, value := range directives { + parts = append(parts, fmt.Sprintf("%s %s", directive, value)) + } + + return strings.Join(parts, "; ") +} + +// buildPermissionsPolicy constructs the Permissions-Policy header value. +func buildPermissionsPolicy() string { + // Disable features we don't need + policies := []string{ + "accelerometer=()", + "camera=()", + "geolocation=()", + "gyroscope=()", + "magnetometer=()", + "microphone=()", + "payment=()", + "usb=()", + } + + return strings.Join(policies, ", ") +} diff --git a/backend/internal/api/middleware/security_test.go b/backend/internal/api/middleware/security_test.go new file mode 100644 index 00000000..d83cf7bf --- /dev/null +++ b/backend/internal/api/middleware/security_test.go @@ -0,0 +1,182 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestSecurityHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + isDevelopment bool + checkHeaders func(t *testing.T, resp *httptest.ResponseRecorder) + }{ + { + name: "production mode sets HSTS", + isDevelopment: false, + checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) { + hsts := resp.Header().Get("Strict-Transport-Security") + assert.Contains(t, hsts, "max-age=31536000") + assert.Contains(t, hsts, "includeSubDomains") + assert.Contains(t, hsts, "preload") + }, + }, + { + name: "development mode skips HSTS", + isDevelopment: true, + checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) { + hsts := resp.Header().Get("Strict-Transport-Security") + assert.Empty(t, hsts) + }, + }, + { + name: "sets X-Frame-Options", + isDevelopment: false, + checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, "DENY", resp.Header().Get("X-Frame-Options")) + }, + }, + { + name: "sets X-Content-Type-Options", + isDevelopment: false, + checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, "nosniff", resp.Header().Get("X-Content-Type-Options")) + }, + }, + { + name: "sets X-XSS-Protection", + isDevelopment: false, + checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, "1; mode=block", resp.Header().Get("X-XSS-Protection")) + }, + }, + { + name: "sets Referrer-Policy", + isDevelopment: false, + checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, "strict-origin-when-cross-origin", resp.Header().Get("Referrer-Policy")) + }, + }, + { + name: "sets Content-Security-Policy", + isDevelopment: false, + checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) { + csp := resp.Header().Get("Content-Security-Policy") + assert.NotEmpty(t, csp) + assert.Contains(t, csp, "default-src") + }, + }, + { + name: "development mode CSP allows unsafe-eval", + isDevelopment: true, + checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) { + csp := resp.Header().Get("Content-Security-Policy") + assert.Contains(t, csp, "unsafe-eval") + }, + }, + { + name: "sets Permissions-Policy", + isDevelopment: false, + checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) { + pp := resp.Header().Get("Permissions-Policy") + assert.NotEmpty(t, pp) + assert.Contains(t, pp, "camera=()") + assert.Contains(t, pp, "microphone=()") + }, + }, + { + name: "sets Cross-Origin-Opener-Policy", + isDevelopment: false, + checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, "same-origin", resp.Header().Get("Cross-Origin-Opener-Policy")) + }, + }, + { + name: "sets Cross-Origin-Resource-Policy", + isDevelopment: false, + checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) { + assert.Equal(t, "same-origin", resp.Header().Get("Cross-Origin-Resource-Policy")) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := gin.New() + router.Use(SecurityHeaders(SecurityHeadersConfig{ + IsDevelopment: tt.isDevelopment, + })) + router.GET("/test", func(c *gin.Context) { + c.String(http.StatusOK, "OK") + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusOK, resp.Code) + tt.checkHeaders(t, resp) + }) + } +} + +func TestSecurityHeadersCustomCSP(t *testing.T) { + gin.SetMode(gin.TestMode) + + router := gin.New() + router.Use(SecurityHeaders(SecurityHeadersConfig{ + IsDevelopment: false, + CustomCSPDirectives: map[string]string{ + "frame-src": "'self' https://trusted.com", + }, + })) + router.GET("/test", func(c *gin.Context) { + c.String(http.StatusOK, "OK") + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + csp := resp.Header().Get("Content-Security-Policy") + assert.Contains(t, csp, "frame-src 'self' https://trusted.com") +} + +func TestDefaultSecurityHeadersConfig(t *testing.T) { + cfg := DefaultSecurityHeadersConfig() + assert.False(t, cfg.IsDevelopment) + assert.Nil(t, cfg.CustomCSPDirectives) +} + +func TestBuildCSP(t *testing.T) { + t.Run("production CSP", func(t *testing.T) { + csp := buildCSP(SecurityHeadersConfig{IsDevelopment: false}) + assert.Contains(t, csp, "default-src 'self'") + assert.Contains(t, csp, "script-src 'self'") + assert.NotContains(t, csp, "unsafe-eval") + }) + + t.Run("development CSP", func(t *testing.T) { + csp := buildCSP(SecurityHeadersConfig{IsDevelopment: true}) + assert.Contains(t, csp, "unsafe-eval") + assert.Contains(t, csp, "ws:") + }) +} + +func TestBuildPermissionsPolicy(t *testing.T) { + pp := buildPermissionsPolicy() + + // Check that dangerous features are disabled + disabledFeatures := []string{"camera", "microphone", "geolocation", "payment"} + for _, feature := range disabledFeatures { + assert.True(t, strings.Contains(pp, feature+"=()"), + "Expected %s to be disabled in permissions policy", feature) + } +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 331c5301..4b556c10 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -23,6 +23,13 @@ import ( // Register wires up API routes and performs automatic migrations. func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { + // Apply security headers middleware globally + // This sets CSP, HSTS, X-Frame-Options, etc. + securityHeadersCfg := middleware.SecurityHeadersConfig{ + IsDevelopment: cfg.Environment == "development", + } + router.Use(middleware.SecurityHeaders(securityHeadersCfg)) + // AutoMigrate all models for Issue #5 persistence layer if err := db.AutoMigrate( &models.ProxyHost{}, @@ -46,6 +53,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { &models.SecurityDecision{}, &models.SecurityAudit{}, &models.SecurityRuleSet{}, + &models.UserPermittedHost{}, // Join table for user permissions ); err != nil { return fmt.Errorf("auto migrate: %w", err) } @@ -85,7 +93,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { // Auth routes authService := services.NewAuthService(db, cfg) - authHandler := handlers.NewAuthHandler(authService) + authHandler := handlers.NewAuthHandlerWithDB(authService, db) authMiddleware := middleware.AuthMiddleware(authService) // Backup routes @@ -105,6 +113,15 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { api.POST("/auth/login", authHandler.Login) api.POST("/auth/register", authHandler.Register) + // Forward auth endpoint for Caddy (public, validates session internally) + api.GET("/auth/verify", authHandler.Verify) + api.GET("/auth/status", authHandler.VerifyStatus) + + // User invite acceptance (public endpoints) + userHandler := handlers.NewUserHandler(db) + api.GET("/invite/validate", userHandler.ValidateInvite) + api.POST("/invite/accept", userHandler.AcceptInvite) + // Uptime Service - define early so it can be used during route registration uptimeService := services.NewUptimeService(db, notificationService) @@ -132,17 +149,35 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { protected.GET("/settings", settingsHandler.GetSettings) protected.POST("/settings", settingsHandler.UpdateSetting) + // SMTP Configuration + protected.GET("/settings/smtp", settingsHandler.GetSMTPConfig) + protected.POST("/settings/smtp", settingsHandler.UpdateSMTPConfig) + protected.POST("/settings/smtp/test", settingsHandler.TestSMTPConfig) + protected.POST("/settings/smtp/test-email", settingsHandler.SendTestEmail) + + // Auth related protected routes + protected.GET("/auth/accessible-hosts", authHandler.GetAccessibleHosts) + protected.GET("/auth/check-host/:hostId", authHandler.CheckHostAccess) + // Feature flags (DB-backed with env fallback) featureFlagsHandler := handlers.NewFeatureFlagsHandler(db) protected.GET("/feature-flags", featureFlagsHandler.GetFlags) protected.PUT("/feature-flags", featureFlagsHandler.UpdateFlags) // User Profile & API Key - userHandler := handlers.NewUserHandler(db) protected.GET("/user/profile", userHandler.GetProfile) protected.POST("/user/profile", userHandler.UpdateProfile) protected.POST("/user/api-key", userHandler.RegenerateAPIKey) + // User Management (admin only routes are in RegisterRoutes) + protected.GET("/users", userHandler.ListUsers) + protected.POST("/users", userHandler.CreateUser) + protected.POST("/users/invite", userHandler.InviteUser) + protected.GET("/users/:id", userHandler.GetUser) + protected.PUT("/users/:id", userHandler.UpdateUser) + protected.DELETE("/users/:id", userHandler.DeleteUser) + protected.PUT("/users/:id/permissions", userHandler.UpdateUserPermissions) + // Updates updateService := services.NewUpdateService() updateHandler := handlers.NewUpdateHandler(updateService) @@ -267,9 +302,6 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { protected.DELETE("/access-lists/:id", accessListHandler.Delete) protected.POST("/access-lists/:id/test", accessListHandler.TestIP) - userHandler := handlers.NewUserHandler(db) - userHandler.RegisterRoutes(api) - // Certificate routes // Use cfg.CaddyConfigDir + "/data" for cert service so we scan the actual Caddy storage // where ACME and certificates are stored (e.g. /data). diff --git a/backend/internal/models/proxy_host.go b/backend/internal/models/proxy_host.go index 0d6503b8..5eceed0c 100644 --- a/backend/internal/models/proxy_host.go +++ b/backend/internal/models/proxy_host.go @@ -28,6 +28,11 @@ type ProxyHost struct { Locations []Location `json:"locations" gorm:"foreignKey:ProxyHostID;constraint:OnDelete:CASCADE"` AdvancedConfig string `json:"advanced_config" gorm:"type:text"` AdvancedConfigBackup string `json:"advanced_config_backup" gorm:"type:text"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + + // Forward Auth / User Gateway settings + // When enabled, Caddy will use forward_auth to verify user access via Charon + ForwardAuthEnabled bool `json:"forward_auth_enabled" gorm:"default:false"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index 49640a95..c7cce43e 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -6,8 +6,18 @@ import ( "golang.org/x/crypto/bcrypt" ) +// PermissionMode determines how user access to proxy hosts is evaluated. +type PermissionMode string + +const ( + // PermissionModeAllowAll grants access to all hosts except those in the exception list. + PermissionModeAllowAll PermissionMode = "allow_all" + // PermissionModeDenyAll denies access to all hosts except those in the exception list. + PermissionModeDenyAll PermissionMode = "deny_all" +) + // User represents authenticated users with role-based access control. -// Supports local auth, SSO integration planned for later phases. +// Supports local auth, SSO integration, and invite-based onboarding. type User struct { ID uint `json:"id" gorm:"primaryKey"` UUID string `json:"uuid" gorm:"uniqueIndex"` @@ -20,8 +30,20 @@ type User struct { FailedLoginAttempts int `json:"-" gorm:"default:0"` LockedUntil *time.Time `json:"-"` LastLogin *time.Time `json:"last_login,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + + // Invite system fields + InviteToken string `json:"-" gorm:"index"` // Token sent via email for account setup + InviteExpires *time.Time `json:"-"` // When the invite token expires + InvitedAt *time.Time `json:"invited_at,omitempty"` // When the invite was sent + InvitedBy *uint `json:"invited_by,omitempty"` // ID of user who sent the invite + InviteStatus string `json:"invite_status,omitempty"` // "pending", "accepted", "expired" + + // Permission system for forward auth / user gateway + PermissionMode PermissionMode `json:"permission_mode" gorm:"default:'allow_all'"` // "allow_all" or "deny_all" + PermittedHosts []ProxyHost `json:"permitted_hosts,omitempty" gorm:"many2many:user_permitted_hosts;"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // SetPassword hashes and sets the user's password. @@ -39,3 +61,49 @@ func (u *User) CheckPassword(password string) bool { err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) return err == nil } + +// HasPendingInvite returns true if the user has a pending invite that hasn't expired. +func (u *User) HasPendingInvite() bool { + if u.InviteToken == "" || u.InviteExpires == nil { + return false + } + return u.InviteExpires.After(time.Now()) && u.InviteStatus == "pending" +} + +// CanAccessHost determines if the user can access a given proxy host based on their permission mode. +// - allow_all mode: User can access everything EXCEPT hosts in PermittedHosts (blacklist) +// - deny_all mode: User can ONLY access hosts in PermittedHosts (whitelist) +func (u *User) CanAccessHost(hostID uint) bool { + // Admins always have access + if u.Role == "admin" { + return true + } + + // Check if host is in the permitted hosts list + hostInList := false + for _, h := range u.PermittedHosts { + if h.ID == hostID { + hostInList = true + break + } + } + + switch u.PermissionMode { + case PermissionModeAllowAll: + // Allow all except those in the list (blacklist) + return !hostInList + case PermissionModeDenyAll: + // Deny all except those in the list (whitelist) + return hostInList + default: + // Default to allow_all behavior + return !hostInList + } +} + +// UserPermittedHost is the join table for the many-to-many relationship. +// This is auto-created by GORM but defined here for clarity. +type UserPermittedHost struct { + UserID uint `gorm:"primaryKey"` + ProxyHostID uint `gorm:"primaryKey"` +} diff --git a/backend/internal/models/user_test.go b/backend/internal/models/user_test.go index eb3ef30c..281949b9 100644 --- a/backend/internal/models/user_test.go +++ b/backend/internal/models/user_test.go @@ -2,6 +2,7 @@ package models import ( "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -21,3 +22,162 @@ func TestUser_CheckPassword(t *testing.T) { assert.True(t, u.CheckPassword("password123")) assert.False(t, u.CheckPassword("wrongpassword")) } + +func TestUser_HasPendingInvite(t *testing.T) { + tests := []struct { + name string + user User + expected bool + }{ + { + name: "no invite token", + user: User{InviteToken: "", InviteStatus: ""}, + expected: false, + }, + { + name: "expired invite", + user: User{ + InviteToken: "token123", + InviteExpires: timePtr(time.Now().Add(-1 * time.Hour)), + InviteStatus: "pending", + }, + expected: false, + }, + { + name: "valid pending invite", + user: User{ + InviteToken: "token123", + InviteExpires: timePtr(time.Now().Add(24 * time.Hour)), + InviteStatus: "pending", + }, + expected: true, + }, + { + name: "already accepted invite", + user: User{ + InviteToken: "token123", + InviteExpires: timePtr(time.Now().Add(24 * time.Hour)), + InviteStatus: "accepted", + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.user.HasPendingInvite() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestUser_CanAccessHost_AllowAll(t *testing.T) { + // User with allow_all mode (blacklist) - can access everything except listed hosts + user := User{ + Role: "user", + PermissionMode: PermissionModeAllowAll, + PermittedHosts: []ProxyHost{ + {ID: 1}, // Blocked host + {ID: 2}, // Blocked host + }, + } + + // Should NOT be able to access hosts in the blacklist + assert.False(t, user.CanAccessHost(1)) + assert.False(t, user.CanAccessHost(2)) + + // Should be able to access other hosts + assert.True(t, user.CanAccessHost(3)) + assert.True(t, user.CanAccessHost(100)) +} + +func TestUser_CanAccessHost_DenyAll(t *testing.T) { + // User with deny_all mode (whitelist) - can only access listed hosts + user := User{ + Role: "user", + PermissionMode: PermissionModeDenyAll, + PermittedHosts: []ProxyHost{ + {ID: 5}, // Allowed host + {ID: 6}, // Allowed host + }, + } + + // Should be able to access hosts in the whitelist + assert.True(t, user.CanAccessHost(5)) + assert.True(t, user.CanAccessHost(6)) + + // Should NOT be able to access other hosts + assert.False(t, user.CanAccessHost(1)) + assert.False(t, user.CanAccessHost(100)) +} + +func TestUser_CanAccessHost_AdminBypass(t *testing.T) { + // Admin users should always have access regardless of permission mode + adminUser := User{ + Role: "admin", + PermissionMode: PermissionModeDenyAll, + PermittedHosts: []ProxyHost{}, // No hosts in whitelist + } + + // Admin should still be able to access any host + assert.True(t, adminUser.CanAccessHost(1)) + assert.True(t, adminUser.CanAccessHost(999)) +} + +func TestUser_CanAccessHost_DefaultBehavior(t *testing.T) { + // User with empty/default permission mode should behave like allow_all + user := User{ + Role: "user", + PermissionMode: "", // Empty = default + PermittedHosts: []ProxyHost{ + {ID: 1}, // Should be blocked + }, + } + + assert.False(t, user.CanAccessHost(1)) + assert.True(t, user.CanAccessHost(2)) +} + +func TestUser_CanAccessHost_EmptyPermittedHosts(t *testing.T) { + tests := []struct { + name string + permissionMode PermissionMode + hostID uint + expected bool + }{ + { + name: "allow_all with no exceptions allows all", + permissionMode: PermissionModeAllowAll, + hostID: 1, + expected: true, + }, + { + name: "deny_all with no exceptions denies all", + permissionMode: PermissionModeDenyAll, + hostID: 1, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + user := User{ + Role: "user", + PermissionMode: tt.permissionMode, + PermittedHosts: []ProxyHost{}, + } + result := user.CanAccessHost(tt.hostID) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestPermissionMode_Constants(t *testing.T) { + assert.Equal(t, PermissionMode("allow_all"), PermissionModeAllowAll) + assert.Equal(t, PermissionMode("deny_all"), PermissionModeDenyAll) +} + +// Helper function to create time pointers +func timePtr(t time.Time) *time.Time { + return &t +} diff --git a/backend/internal/services/mail_service.go b/backend/internal/services/mail_service.go new file mode 100644 index 00000000..d0238076 --- /dev/null +++ b/backend/internal/services/mail_service.go @@ -0,0 +1,368 @@ +package services + +import ( + "bytes" + "crypto/tls" + "errors" + "fmt" + "html/template" + "net/smtp" + "strings" + + "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/models" + "gorm.io/gorm" +) + +// SMTPConfig holds the SMTP server configuration. +type SMTPConfig struct { + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + FromAddress string `json:"from_address"` + Encryption string `json:"encryption"` // "none", "ssl", "starttls" +} + +// MailService handles sending emails via SMTP. +type MailService struct { + db *gorm.DB +} + +// NewMailService creates a new mail service instance. +func NewMailService(db *gorm.DB) *MailService { + return &MailService{db: db} +} + +// GetSMTPConfig retrieves SMTP settings from the database. +func (s *MailService) GetSMTPConfig() (*SMTPConfig, error) { + var settings []models.Setting + if err := s.db.Where("category = ?", "smtp").Find(&settings).Error; err != nil { + return nil, fmt.Errorf("failed to load SMTP settings: %w", err) + } + + config := &SMTPConfig{ + Port: 587, // Default port + Encryption: "starttls", + } + + for _, setting := range settings { + switch setting.Key { + case "smtp_host": + config.Host = setting.Value + case "smtp_port": + if _, err := fmt.Sscanf(setting.Value, "%d", &config.Port); err != nil { + config.Port = 587 + } + case "smtp_username": + config.Username = setting.Value + case "smtp_password": + config.Password = setting.Value + case "smtp_from_address": + config.FromAddress = setting.Value + case "smtp_encryption": + config.Encryption = setting.Value + } + } + + return config, nil +} + +// SaveSMTPConfig saves SMTP settings to the database. +func (s *MailService) SaveSMTPConfig(config *SMTPConfig) error { + settings := map[string]string{ + "smtp_host": config.Host, + "smtp_port": fmt.Sprintf("%d", config.Port), + "smtp_username": config.Username, + "smtp_password": config.Password, + "smtp_from_address": config.FromAddress, + "smtp_encryption": config.Encryption, + } + + for key, value := range settings { + setting := models.Setting{ + Key: key, + Value: value, + Type: "string", + Category: "smtp", + } + + // Upsert: update if exists, create if not + result := s.db.Where("key = ?", key).First(&models.Setting{}) + if result.Error == gorm.ErrRecordNotFound { + if err := s.db.Create(&setting).Error; err != nil { + return fmt.Errorf("failed to create setting %s: %w", key, err) + } + } else { + if err := s.db.Model(&models.Setting{}).Where("key = ?", key).Updates(map[string]interface{}{ + "value": value, + "category": "smtp", + }).Error; err != nil { + return fmt.Errorf("failed to update setting %s: %w", key, err) + } + } + } + + return nil +} + +// IsConfigured returns true if SMTP is properly configured. +func (s *MailService) IsConfigured() bool { + config, err := s.GetSMTPConfig() + if err != nil { + return false + } + return config.Host != "" && config.FromAddress != "" +} + +// TestConnection tests the SMTP connection without sending an email. +func (s *MailService) TestConnection() error { + config, err := s.GetSMTPConfig() + if err != nil { + return err + } + + if config.Host == "" { + return errors.New("SMTP host not configured") + } + + addr := fmt.Sprintf("%s:%d", config.Host, config.Port) + + // Try to connect based on encryption type + switch config.Encryption { + case "ssl": + tlsConfig := &tls.Config{ + ServerName: config.Host, + MinVersion: tls.VersionTLS12, + } + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { + return fmt.Errorf("SSL connection failed: %w", err) + } + defer conn.Close() + + case "starttls", "none", "": + client, err := smtp.Dial(addr) + if err != nil { + return fmt.Errorf("SMTP connection failed: %w", err) + } + defer client.Close() + + if config.Encryption == "starttls" { + tlsConfig := &tls.Config{ + ServerName: config.Host, + MinVersion: tls.VersionTLS12, + } + if err := client.StartTLS(tlsConfig); err != nil { + return fmt.Errorf("STARTTLS failed: %w", err) + } + } + + // Try authentication if credentials are provided + if config.Username != "" && config.Password != "" { + auth := smtp.PlainAuth("", config.Username, config.Password, config.Host) + if err := client.Auth(auth); err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + } + } + + return nil +} + +// SendEmail sends an email using the configured SMTP settings. +func (s *MailService) SendEmail(to, subject, htmlBody string) error { + config, err := s.GetSMTPConfig() + if err != nil { + return err + } + + if config.Host == "" { + return errors.New("SMTP not configured") + } + + // Build the email message + msg := s.buildEmail(config.FromAddress, to, subject, htmlBody) + + addr := fmt.Sprintf("%s:%d", config.Host, config.Port) + var auth smtp.Auth + if config.Username != "" && config.Password != "" { + auth = smtp.PlainAuth("", config.Username, config.Password, config.Host) + } + + switch config.Encryption { + case "ssl": + return s.sendSSL(addr, config, auth, to, msg) + case "starttls": + return s.sendSTARTTLS(addr, config, auth, to, msg) + default: + return smtp.SendMail(addr, auth, config.FromAddress, []string{to}, msg) + } +} + +// buildEmail constructs a properly formatted email message. +func (s *MailService) buildEmail(from, to, subject, htmlBody string) []byte { + headers := make(map[string]string) + headers["From"] = from + headers["To"] = to + headers["Subject"] = subject + headers["MIME-Version"] = "1.0" + headers["Content-Type"] = "text/html; charset=UTF-8" + + var msg bytes.Buffer + for key, value := range headers { + msg.WriteString(fmt.Sprintf("%s: %s\r\n", key, value)) + } + msg.WriteString("\r\n") + msg.WriteString(htmlBody) + + return msg.Bytes() +} + +// sendSSL sends email using direct SSL/TLS connection. +func (s *MailService) sendSSL(addr string, config *SMTPConfig, auth smtp.Auth, to string, msg []byte) error { + tlsConfig := &tls.Config{ + ServerName: config.Host, + MinVersion: tls.VersionTLS12, + } + + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { + return fmt.Errorf("SSL connection failed: %w", err) + } + defer conn.Close() + + client, err := smtp.NewClient(conn, config.Host) + if err != nil { + return fmt.Errorf("failed to create SMTP client: %w", err) + } + defer client.Close() + + if auth != nil { + if err := client.Auth(auth); err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + } + + if err := client.Mail(config.FromAddress); err != nil { + return fmt.Errorf("MAIL FROM failed: %w", err) + } + + if err := client.Rcpt(to); err != nil { + return fmt.Errorf("RCPT TO failed: %w", err) + } + + w, err := client.Data() + if err != nil { + return fmt.Errorf("DATA failed: %w", err) + } + + if _, err := w.Write(msg); err != nil { + return fmt.Errorf("failed to write message: %w", err) + } + + if err := w.Close(); err != nil { + return fmt.Errorf("failed to close data writer: %w", err) + } + + return client.Quit() +} + +// sendSTARTTLS sends email using STARTTLS. +func (s *MailService) sendSTARTTLS(addr string, config *SMTPConfig, auth smtp.Auth, to string, msg []byte) error { + client, err := smtp.Dial(addr) + if err != nil { + return fmt.Errorf("SMTP connection failed: %w", err) + } + defer client.Close() + + tlsConfig := &tls.Config{ + ServerName: config.Host, + MinVersion: tls.VersionTLS12, + } + + if err := client.StartTLS(tlsConfig); err != nil { + return fmt.Errorf("STARTTLS failed: %w", err) + } + + if auth != nil { + if err := client.Auth(auth); err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + } + + if err := client.Mail(config.FromAddress); err != nil { + return fmt.Errorf("MAIL FROM failed: %w", err) + } + + if err := client.Rcpt(to); err != nil { + return fmt.Errorf("RCPT TO failed: %w", err) + } + + w, err := client.Data() + if err != nil { + return fmt.Errorf("DATA failed: %w", err) + } + + if _, err := w.Write(msg); err != nil { + return fmt.Errorf("failed to write message: %w", err) + } + + if err := w.Close(); err != nil { + return fmt.Errorf("failed to close data writer: %w", err) + } + + return client.Quit() +} + +// SendInvite sends an invitation email to a new user. +func (s *MailService) SendInvite(email, inviteToken, appName, baseURL string) error { + inviteURL := fmt.Sprintf("%s/accept-invite?token=%s", strings.TrimSuffix(baseURL, "/"), inviteToken) + + tmpl := ` + + + + + You've been invited to {{.AppName}} + + +
+

{{.AppName}}

+
+
+

You've Been Invited!

+

You've been invited to join {{.AppName}}. Click the button below to set up your account:

+
+ Accept Invitation +
+

This invitation link will expire in 48 hours.

+

If you didn't expect this invitation, you can safely ignore this email.

+
+

If the button doesn't work, copy and paste this link into your browser:
+ {{.InviteURL}}

+
+ + +` + + t, err := template.New("invite").Parse(tmpl) + if err != nil { + return fmt.Errorf("failed to parse email template: %w", err) + } + + var body bytes.Buffer + data := map[string]string{ + "AppName": appName, + "InviteURL": inviteURL, + } + + if err := t.Execute(&body, data); err != nil { + return fmt.Errorf("failed to execute email template: %w", err) + } + + subject := fmt.Sprintf("You've been invited to %s", appName) + + logger.Log().WithField("email", email).Info("Sending invite email") + return s.SendEmail(email, subject, body.String()) +} diff --git a/backend/internal/services/mail_service_test.go b/backend/internal/services/mail_service_test.go new file mode 100644 index 00000000..56c140ae --- /dev/null +++ b/backend/internal/services/mail_service_test.go @@ -0,0 +1,298 @@ +package services + +import ( + "testing" + + "github.com/Wikid82/charon/backend/internal/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func setupMailTestDB(t *testing.T) *gorm.DB { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + require.NoError(t, err) + + err = db.AutoMigrate(&models.Setting{}) + require.NoError(t, err) + + return db +} + +func TestMailService_SaveAndGetSMTPConfig(t *testing.T) { + db := setupMailTestDB(t) + svc := NewMailService(db) + + config := &SMTPConfig{ + Host: "smtp.example.com", + Port: 587, + Username: "user@example.com", + Password: "secret123", + FromAddress: "noreply@example.com", + Encryption: "starttls", + } + + // Save config + err := svc.SaveSMTPConfig(config) + require.NoError(t, err) + + // Retrieve config + retrieved, err := svc.GetSMTPConfig() + require.NoError(t, err) + + assert.Equal(t, config.Host, retrieved.Host) + assert.Equal(t, config.Port, retrieved.Port) + assert.Equal(t, config.Username, retrieved.Username) + assert.Equal(t, config.Password, retrieved.Password) + assert.Equal(t, config.FromAddress, retrieved.FromAddress) + assert.Equal(t, config.Encryption, retrieved.Encryption) +} + +func TestMailService_UpdateSMTPConfig(t *testing.T) { + db := setupMailTestDB(t) + svc := NewMailService(db) + + // Save initial config + config := &SMTPConfig{ + Host: "smtp.example.com", + Port: 587, + Username: "user@example.com", + Password: "secret123", + FromAddress: "noreply@example.com", + Encryption: "starttls", + } + err := svc.SaveSMTPConfig(config) + require.NoError(t, err) + + // Update config + config.Host = "smtp.newhost.com" + config.Port = 465 + config.Encryption = "ssl" + err = svc.SaveSMTPConfig(config) + require.NoError(t, err) + + // Verify update + retrieved, err := svc.GetSMTPConfig() + require.NoError(t, err) + + assert.Equal(t, "smtp.newhost.com", retrieved.Host) + assert.Equal(t, 465, retrieved.Port) + assert.Equal(t, "ssl", retrieved.Encryption) +} + +func TestMailService_IsConfigured(t *testing.T) { + tests := []struct { + name string + config *SMTPConfig + expected bool + }{ + { + name: "configured with all fields", + config: &SMTPConfig{ + Host: "smtp.example.com", + Port: 587, + FromAddress: "noreply@example.com", + Encryption: "starttls", + }, + expected: true, + }, + { + name: "not configured - missing host", + config: &SMTPConfig{ + Port: 587, + FromAddress: "noreply@example.com", + }, + expected: false, + }, + { + name: "not configured - missing from address", + config: &SMTPConfig{ + Host: "smtp.example.com", + Port: 587, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db := setupMailTestDB(t) + svc := NewMailService(db) + + err := svc.SaveSMTPConfig(tt.config) + require.NoError(t, err) + + result := svc.IsConfigured() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestMailService_GetSMTPConfig_Defaults(t *testing.T) { + db := setupMailTestDB(t) + svc := NewMailService(db) + + // Get config without saving anything + config, err := svc.GetSMTPConfig() + require.NoError(t, err) + + // Should have defaults + assert.Equal(t, 587, config.Port) + assert.Equal(t, "starttls", config.Encryption) + assert.Empty(t, config.Host) +} + +func TestMailService_BuildEmail(t *testing.T) { + db := setupMailTestDB(t) + svc := NewMailService(db) + + msg := svc.buildEmail( + "sender@example.com", + "recipient@example.com", + "Test Subject", + "Test Body", + ) + + msgStr := string(msg) + assert.Contains(t, msgStr, "From: sender@example.com") + assert.Contains(t, msgStr, "To: recipient@example.com") + assert.Contains(t, msgStr, "Subject: Test Subject") + assert.Contains(t, msgStr, "Content-Type: text/html") + assert.Contains(t, msgStr, "Test Body") +} + +func TestMailService_TestConnection_NotConfigured(t *testing.T) { + db := setupMailTestDB(t) + svc := NewMailService(db) + + err := svc.TestConnection() + assert.Error(t, err) + assert.Contains(t, err.Error(), "not configured") +} + +func TestMailService_SendEmail_NotConfigured(t *testing.T) { + db := setupMailTestDB(t) + svc := NewMailService(db) + + err := svc.SendEmail("test@example.com", "Subject", "

Body

") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not configured") +} + +// TestSMTPConfigSerialization ensures config fields are properly stored +func TestSMTPConfigSerialization(t *testing.T) { + db := setupMailTestDB(t) + svc := NewMailService(db) + + // Test with special characters in password + config := &SMTPConfig{ + Host: "smtp.example.com", + Port: 587, + Username: "user@example.com", + Password: "p@$$w0rd!#$%", + FromAddress: "Charon ", + Encryption: "starttls", + } + + err := svc.SaveSMTPConfig(config) + require.NoError(t, err) + + retrieved, err := svc.GetSMTPConfig() + require.NoError(t, err) + + assert.Equal(t, config.Password, retrieved.Password) + assert.Equal(t, config.FromAddress, retrieved.FromAddress) +} + +// TestMailService_SendInvite tests the invite email template +func TestMailService_SendInvite_Template(t *testing.T) { + db := setupMailTestDB(t) + svc := NewMailService(db) + + // We can't actually send email, but we can verify the method doesn't panic + // and returns appropriate error when SMTP is not configured + err := svc.SendInvite("test@example.com", "abc123token", "TestApp", "https://example.com") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not configured") +} + +// Benchmark tests +func BenchmarkMailService_IsConfigured(b *testing.B) { + db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + db.AutoMigrate(&models.Setting{}) + svc := NewMailService(db) + + config := &SMTPConfig{ + Host: "smtp.example.com", + Port: 587, + FromAddress: "noreply@example.com", + } + svc.SaveSMTPConfig(config) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + svc.IsConfigured() + } +} + +func BenchmarkMailService_BuildEmail(b *testing.B) { + db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + svc := NewMailService(db) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + svc.buildEmail( + "sender@example.com", + "recipient@example.com", + "Test Subject", + "Test Body", + ) + } +} + +// Integration test placeholder - this would use a real SMTP server +func TestMailService_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // This test would connect to a real SMTP server (like MailHog) for integration testing + t.Skip("Integration test requires SMTP server") +} + +// Test for expired invite token handling in SendInvite +func TestMailService_SendInvite_TokenFormat(t *testing.T) { + db := setupMailTestDB(t) + svc := NewMailService(db) + + // Save SMTP config so we can test template generation + config := &SMTPConfig{ + Host: "smtp.example.com", + Port: 587, + FromAddress: "noreply@example.com", + } + svc.SaveSMTPConfig(config) + + // The SendInvite will fail at SMTP connection, but we're testing that + // the function correctly constructs the invite URL + err := svc.SendInvite("test@example.com", "token123", "Charon", "https://charon.local/") + assert.Error(t, err) // Will error on SMTP connection + + // Test with trailing slash handling + err = svc.SendInvite("test@example.com", "token123", "Charon", "https://charon.local") + assert.Error(t, err) // Will error on SMTP connection +} + +// Add timeout handling test +// Note: Skipped as in-memory SQLite doesn't support concurrent writes well +func TestMailService_SaveSMTPConfig_Concurrent(t *testing.T) { + t.Skip("In-memory SQLite doesn't support concurrent writes - test real DB in integration") +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 51ebea03..6659402e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ const ImportCaddy = lazy(() => import('./pages/ImportCaddy')) const ImportCrowdSec = lazy(() => import('./pages/ImportCrowdSec')) const Certificates = lazy(() => import('./pages/Certificates')) const SystemSettings = lazy(() => import('./pages/SystemSettings')) +const SMTPSettings = lazy(() => import('./pages/SMTPSettings')) const CrowdSecConfig = lazy(() => import('./pages/CrowdSecConfig')) const Account = lazy(() => import('./pages/Account')) const Settings = lazy(() => import('./pages/Settings')) @@ -29,8 +30,10 @@ const WafConfig = lazy(() => import('./pages/WafConfig')) const RateLimiting = lazy(() => import('./pages/RateLimiting')) const Uptime = lazy(() => import('./pages/Uptime')) const Notifications = lazy(() => import('./pages/Notifications')) +const UsersPage = lazy(() => import('./pages/UsersPage')) const Login = lazy(() => import('./pages/Login')) const Setup = lazy(() => import('./pages/Setup')) +const AcceptInvite = lazy(() => import('./pages/AcceptInvite')) export default function App() { return ( @@ -40,6 +43,7 @@ export default function App() { } /> } /> + } /> @@ -62,12 +66,14 @@ export default function App() { } /> } /> } /> + } /> } /> {/* Settings Routes */} }> } /> } /> + } /> } /> } /> diff --git a/frontend/src/api/smtp.ts b/frontend/src/api/smtp.ts new file mode 100644 index 00000000..04488967 --- /dev/null +++ b/frontend/src/api/smtp.ts @@ -0,0 +1,50 @@ +import client from './client' + +export interface SMTPConfig { + host: string + port: number + username: string + password: string + from_address: string + encryption: 'none' | 'ssl' | 'starttls' + configured: boolean +} + +export interface SMTPConfigRequest { + host: string + port: number + username: string + password: string + from_address: string + encryption: 'none' | 'ssl' | 'starttls' +} + +export interface TestEmailRequest { + to: string +} + +export interface SMTPTestResult { + success: boolean + message?: string + error?: string +} + +export const getSMTPConfig = async (): Promise => { + const response = await client.get('/settings/smtp') + return response.data +} + +export const updateSMTPConfig = async (config: SMTPConfigRequest): Promise<{ message: string }> => { + const response = await client.post<{ message: string }>('/settings/smtp', config) + return response.data +} + +export const testSMTPConnection = async (): Promise => { + const response = await client.post('/settings/smtp/test') + return response.data +} + +export const sendTestEmail = async (request: TestEmailRequest): Promise => { + const response = await client.post('/settings/smtp/test-email', request) + return response.data +} diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts new file mode 100644 index 00000000..29c3fc98 --- /dev/null +++ b/frontend/src/api/users.ts @@ -0,0 +1,119 @@ +import client from './client' + +export type PermissionMode = 'allow_all' | 'deny_all' + +export interface User { + id: number + uuid: string + email: string + name: string + role: 'admin' | 'user' | 'viewer' + enabled: boolean + last_login?: string + invite_status?: 'pending' | 'accepted' | 'expired' + invited_at?: string + permission_mode: PermissionMode + permitted_hosts?: number[] + created_at: string + updated_at: string +} + +export interface CreateUserRequest { + email: string + name: string + password: string + role?: string + permission_mode?: PermissionMode + permitted_hosts?: number[] +} + +export interface InviteUserRequest { + email: string + role?: string + permission_mode?: PermissionMode + permitted_hosts?: number[] +} + +export interface InviteUserResponse { + id: number + uuid: string + email: string + role: string + invite_token: string + email_sent: boolean + expires_at: string +} + +export interface UpdateUserRequest { + name?: string + email?: string + role?: string + enabled?: boolean +} + +export interface UpdateUserPermissionsRequest { + permission_mode: PermissionMode + permitted_hosts: number[] +} + +export interface ValidateInviteResponse { + valid: boolean + email: string +} + +export interface AcceptInviteRequest { + token: string + name: string + password: string +} + +export const listUsers = async (): Promise => { + const response = await client.get('/users') + return response.data +} + +export const getUser = async (id: number): Promise => { + const response = await client.get(`/users/${id}`) + return response.data +} + +export const createUser = async (data: CreateUserRequest): Promise => { + const response = await client.post('/users', data) + return response.data +} + +export const inviteUser = async (data: InviteUserRequest): Promise => { + const response = await client.post('/users/invite', data) + return response.data +} + +export const updateUser = async (id: number, data: UpdateUserRequest): Promise<{ message: string }> => { + const response = await client.put<{ message: string }>(`/users/${id}`, data) + return response.data +} + +export const deleteUser = async (id: number): Promise<{ message: string }> => { + const response = await client.delete<{ message: string }>(`/users/${id}`) + return response.data +} + +export const updateUserPermissions = async ( + id: number, + data: UpdateUserPermissionsRequest +): Promise<{ message: string }> => { + const response = await client.put<{ message: string }>(`/users/${id}/permissions`, data) + return response.data +} + +// Public endpoints (no auth required) +export const validateInvite = async (token: string): Promise => { + const response = await client.get('/invite/validate', { + params: { token } + }) + return response.data +} + +export const acceptInvite = async (data: AcceptInviteRequest): Promise<{ message: string; email: string }> => { + const response = await client.post<{ message: string; email: string }>('/invite/accept', data) + return response.data +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index ad6fc3a6..097b5f60 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -63,6 +63,7 @@ export default function Layout({ children }: LayoutProps) { { name: 'WAF (Coraza)', path: '/security/waf', icon: '🛡️' }, ]}, { name: 'Notifications', path: '/notifications', icon: '🔔' }, + { name: 'Users', path: '/users', icon: '👥' }, // Import group moved under Tasks { name: 'Settings', @@ -70,6 +71,7 @@ export default function Layout({ children }: LayoutProps) { icon: '⚙️', children: [ { name: 'System', path: '/settings/system', icon: '⚙️' }, + { name: 'Email (SMTP)', path: '/settings/smtp', icon: '📧' }, { name: 'Account', path: '/settings/account', icon: '🛡️' }, ] }, diff --git a/frontend/src/pages/AcceptInvite.tsx b/frontend/src/pages/AcceptInvite.tsx new file mode 100644 index 00000000..01d01654 --- /dev/null +++ b/frontend/src/pages/AcceptInvite.tsx @@ -0,0 +1,204 @@ +import { useState, useEffect } from 'react' +import { useSearchParams, useNavigate } from 'react-router-dom' +import { useMutation, useQuery } from '@tanstack/react-query' +import { Card } from '../components/ui/Card' +import { Button } from '../components/ui/Button' +import { Input } from '../components/ui/Input' +import { PasswordStrengthMeter } from '../components/PasswordStrengthMeter' +import { toast } from '../utils/toast' +import { validateInvite, acceptInvite } from '../api/users' +import { Loader2, CheckCircle2, XCircle, UserCheck } from 'lucide-react' + +export default function AcceptInvite() { + const [searchParams] = useSearchParams() + const navigate = useNavigate() + const token = searchParams.get('token') || '' + + const [name, setName] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [accepted, setAccepted] = useState(false) + + const { + data: validation, + isLoading: isValidating, + error: validationError, + } = useQuery({ + queryKey: ['validate-invite', token], + queryFn: () => validateInvite(token), + enabled: !!token, + retry: false, + }) + + const acceptMutation = useMutation({ + mutationFn: async () => { + return acceptInvite({ token, name, password }) + }, + onSuccess: (data) => { + setAccepted(true) + toast.success(`Welcome, ${data.email}! You can now log in.`) + }, + onError: (error: unknown) => { + const err = error as { response?: { data?: { error?: string } } } + toast.error(err.response?.data?.error || 'Failed to accept invitation') + }, + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (password !== confirmPassword) { + toast.error('Passwords do not match') + return + } + if (password.length < 8) { + toast.error('Password must be at least 8 characters') + return + } + acceptMutation.mutate() + } + + // Redirect to login after successful acceptance + useEffect(() => { + if (accepted) { + const timer = setTimeout(() => { + navigate('/login') + }, 3000) + return () => clearTimeout(timer) + } + }, [accepted, navigate]) + + if (!token) { + return ( +
+ +
+ +

Invalid Link

+

+ This invitation link is invalid or incomplete. +

+ +
+
+
+ ) + } + + if (isValidating) { + return ( +
+ +
+ +

Validating invitation...

+
+
+
+ ) + } + + if (validationError || !validation?.valid) { + const errorData = validationError as { response?: { data?: { error?: string } } } | undefined + const errorMessage = errorData?.response?.data?.error || 'This invitation has expired or is invalid.' + + return ( +
+ +
+ +

Invitation Invalid

+

{errorMessage}

+ +
+
+
+ ) + } + + if (accepted) { + return ( +
+ +
+ +

Account Created!

+

+ Your account has been set up successfully. Redirecting to login... +

+ +
+
+
+ ) + } + + return ( +
+
+
+ Charon +
+ + +
+
+
+ + You've been invited! +
+

+ Complete your account setup for {validation.email} +

+
+ +
+ setName(e.target.value)} + placeholder="John Doe" + required + /> + +
+ setPassword(e.target.value)} + placeholder="••••••••" + required + /> + +
+ + setConfirmPassword(e.target.value)} + placeholder="••••••••" + required + error={ + confirmPassword && password !== confirmPassword + ? 'Passwords do not match' + : undefined + } + /> + + +
+
+
+
+
+ ) +} diff --git a/frontend/src/pages/SMTPSettings.tsx b/frontend/src/pages/SMTPSettings.tsx new file mode 100644 index 00000000..c4289223 --- /dev/null +++ b/frontend/src/pages/SMTPSettings.tsx @@ -0,0 +1,233 @@ +import { useState, useEffect } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { Card } from '../components/ui/Card' +import { Button } from '../components/ui/Button' +import { Input } from '../components/ui/Input' +import { toast } from '../utils/toast' +import { getSMTPConfig, updateSMTPConfig, testSMTPConnection, sendTestEmail } from '../api/smtp' +import type { SMTPConfigRequest } from '../api/smtp' +import { Mail, Send, CheckCircle2, XCircle, Loader2 } from 'lucide-react' + +export default function SMTPSettings() { + const queryClient = useQueryClient() + const [host, setHost] = useState('') + const [port, setPort] = useState(587) + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [fromAddress, setFromAddress] = useState('') + const [encryption, setEncryption] = useState<'none' | 'ssl' | 'starttls'>('starttls') + const [testEmail, setTestEmail] = useState('') + + const { data: smtpConfig, isLoading } = useQuery({ + queryKey: ['smtp-config'], + queryFn: getSMTPConfig, + }) + + useEffect(() => { + if (smtpConfig) { + setHost(smtpConfig.host || '') + setPort(smtpConfig.port || 587) + setUsername(smtpConfig.username || '') + setPassword(smtpConfig.password || '') + setFromAddress(smtpConfig.from_address || '') + setEncryption(smtpConfig.encryption || 'starttls') + } + }, [smtpConfig]) + + const saveMutation = useMutation({ + mutationFn: async () => { + const config: SMTPConfigRequest = { + host, + port, + username, + password, + from_address: fromAddress, + encryption, + } + return updateSMTPConfig(config) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['smtp-config'] }) + toast.success('SMTP settings saved successfully') + }, + onError: (error: unknown) => { + const err = error as { response?: { data?: { error?: string } } } + toast.error(err.response?.data?.error || 'Failed to save SMTP settings') + }, + }) + + const testConnectionMutation = useMutation({ + mutationFn: testSMTPConnection, + onSuccess: (data) => { + if (data.success) { + toast.success(data.message || 'SMTP connection successful') + } else { + toast.error(data.error || 'SMTP connection failed') + } + }, + onError: (error: unknown) => { + const err = error as { response?: { data?: { error?: string } } } + toast.error(err.response?.data?.error || 'Failed to test SMTP connection') + }, + }) + + const sendTestEmailMutation = useMutation({ + mutationFn: async () => sendTestEmail({ to: testEmail }), + onSuccess: (data) => { + if (data.success) { + toast.success(data.message || 'Test email sent successfully') + setTestEmail('') + } else { + toast.error(data.error || 'Failed to send test email') + } + }, + onError: (error: unknown) => { + const err = error as { response?: { data?: { error?: string } } } + toast.error(err.response?.data?.error || 'Failed to send test email') + }, + }) + + if (isLoading) { + return ( +
+ +
+ ) + } + + return ( +
+
+ +

Email (SMTP) Settings

+
+ +

+ Configure SMTP settings to enable email notifications and user invitations. +

+ + +
+
+ setHost(e.target.value)} + placeholder="smtp.gmail.com" + /> + setPort(parseInt(e.target.value) || 587)} + placeholder="587" + /> +
+ +
+ setUsername(e.target.value)} + placeholder="your@email.com" + /> + setPassword(e.target.value)} + placeholder="••••••••" + helperText="Use app-specific password for Gmail" + /> +
+ + setFromAddress(e.target.value)} + placeholder="Charon " + /> + +
+ + +
+ +
+ + +
+
+
+ + {/* Status Indicator */} + +
+ {smtpConfig?.configured ? ( + <> + + SMTP Configured + + ) : ( + <> + + SMTP Not Configured + + )} +
+
+ + {/* Test Email */} + {smtpConfig?.configured && ( + +

+ Send Test Email +

+
+
+ setTestEmail(e.target.value)} + placeholder="recipient@example.com" + /> +
+ +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index c1d6af5e..1d98a004 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -24,6 +24,17 @@ export default function Settings() { System + + Email (SMTP) + + void + proxyHosts: ProxyHost[] +} + +function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) { + const queryClient = useQueryClient() + const [email, setEmail] = useState('') + const [role, setRole] = useState<'user' | 'admin'>('user') + const [permissionMode, setPermissionMode] = useState('allow_all') + const [selectedHosts, setSelectedHosts] = useState([]) + const [inviteResult, setInviteResult] = useState<{ + token: string + emailSent: boolean + expiresAt: string + } | null>(null) + + const inviteMutation = useMutation({ + mutationFn: async () => { + const request: InviteUserRequest = { + email, + role, + permission_mode: permissionMode, + permitted_hosts: selectedHosts, + } + return inviteUser(request) + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['users'] }) + setInviteResult({ + token: data.invite_token, + emailSent: data.email_sent, + expiresAt: data.expires_at, + }) + if (data.email_sent) { + toast.success('Invitation email sent') + } else { + toast.success('User invited - copy the invite link below') + } + }, + onError: (error: unknown) => { + const err = error as { response?: { data?: { error?: string } } } + toast.error(err.response?.data?.error || 'Failed to invite user') + }, + }) + + const copyInviteLink = () => { + if (inviteResult?.token) { + const link = `${window.location.origin}/accept-invite?token=${inviteResult.token}` + navigator.clipboard.writeText(link) + toast.success('Invite link copied to clipboard') + } + } + + const handleClose = () => { + setEmail('') + setRole('user') + setPermissionMode('allow_all') + setSelectedHosts([]) + setInviteResult(null) + onClose() + } + + const toggleHost = (hostId: number) => { + setSelectedHosts((prev) => + prev.includes(hostId) ? prev.filter((id) => id !== hostId) : [...prev, hostId] + ) + } + + if (!isOpen) return null + + return ( +
+
+
+

+ + Invite User +

+ +
+ +
+ {inviteResult ? ( +
+
+
+ + User Invited Successfully +
+ {inviteResult.emailSent ? ( +

+ An invitation email has been sent to the user. +

+ ) : ( +

+ Email was not sent. Share the invite link manually. +

+ )} +
+ + {!inviteResult.emailSent && ( +
+ +
+ + +
+

+ Expires: {new Date(inviteResult.expiresAt).toLocaleString()} +

+
+ )} + + +
+ ) : ( + <> + setEmail(e.target.value)} + placeholder="user@example.com" + /> + +
+ + +
+ + {role === 'user' && ( + <> +
+ + +

+ {permissionMode === 'allow_all' + ? 'User can access all hosts EXCEPT those selected below' + : 'User can ONLY access hosts selected below'} +

+
+ +
+ +
+ {proxyHosts.length === 0 ? ( +

No proxy hosts configured

+ ) : ( + proxyHosts.map((host) => ( + + )) + )} +
+
+ + )} + +
+ + +
+ + )} +
+
+
+ ) +} + +interface PermissionsModalProps { + isOpen: boolean + onClose: () => void + user: User | null + proxyHosts: ProxyHost[] +} + +function PermissionsModal({ isOpen, onClose, user, proxyHosts }: PermissionsModalProps) { + const queryClient = useQueryClient() + const [permissionMode, setPermissionMode] = useState('allow_all') + const [selectedHosts, setSelectedHosts] = useState([]) + + // Update state when user changes + useState(() => { + if (user) { + setPermissionMode(user.permission_mode || 'allow_all') + setSelectedHosts(user.permitted_hosts || []) + } + }) + + const updatePermissionsMutation = useMutation({ + mutationFn: async () => { + if (!user) return + const request: UpdateUserPermissionsRequest = { + permission_mode: permissionMode, + permitted_hosts: selectedHosts, + } + return updateUserPermissions(user.id, request) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }) + toast.success('Permissions updated') + onClose() + }, + onError: (error: unknown) => { + const err = error as { response?: { data?: { error?: string } } } + toast.error(err.response?.data?.error || 'Failed to update permissions') + }, + }) + + const toggleHost = (hostId: number) => { + setSelectedHosts((prev) => + prev.includes(hostId) ? prev.filter((id) => id !== hostId) : [...prev, hostId] + ) + } + + if (!isOpen || !user) return null + + return ( +
+
+
+

+ + Edit Permissions - {user.name || user.email} +

+ +
+ +
+
+ + +

+ {permissionMode === 'allow_all' + ? 'User can access all hosts EXCEPT those selected below' + : 'User can ONLY access hosts selected below'} +

+
+ +
+ +
+ {proxyHosts.length === 0 ? ( +

No proxy hosts configured

+ ) : ( + proxyHosts.map((host) => ( + + )) + )} +
+
+ +
+ + +
+
+
+
+ ) +} + +export default function UsersPage() { + const queryClient = useQueryClient() + const [inviteModalOpen, setInviteModalOpen] = useState(false) + const [permissionsModalOpen, setPermissionsModalOpen] = useState(false) + const [selectedUser, setSelectedUser] = useState(null) + + const { data: users, isLoading } = useQuery({ + queryKey: ['users'], + queryFn: listUsers, + }) + + const { data: proxyHosts = [] } = useQuery({ + queryKey: ['proxyHosts'], + queryFn: getProxyHosts, + }) + + const toggleEnabledMutation = useMutation({ + mutationFn: async ({ id, enabled }: { id: number; enabled: boolean }) => { + return updateUser(id, { enabled }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }) + toast.success('User updated') + }, + onError: (error: unknown) => { + const err = error as { response?: { data?: { error?: string } } } + toast.error(err.response?.data?.error || 'Failed to update user') + }, + }) + + const deleteMutation = useMutation({ + mutationFn: deleteUser, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }) + toast.success('User deleted') + }, + onError: (error: unknown) => { + const err = error as { response?: { data?: { error?: string } } } + toast.error(err.response?.data?.error || 'Failed to delete user') + }, + }) + + const openPermissions = (user: User) => { + setSelectedUser(user) + setPermissionsModalOpen(true) + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + return ( +
+
+
+ +

User Management

+
+ +
+ + +
+ + + + + + + + + + + + + {users?.map((user) => ( + + + + + + + + + ))} + +
UserRoleStatusPermissionsEnabledActions
+
+

{user.name || '(No name)'}

+

{user.email}

+
+
+ + {user.role} + + + {user.invite_status === 'pending' ? ( + + + Pending Invite + + ) : user.invite_status === 'expired' ? ( + + + Invite Expired + + ) : ( + + + Active + + )} + + + {user.permission_mode === 'deny_all' ? 'Whitelist' : 'Blacklist'} + + + + toggleEnabledMutation.mutate({ + id: user.id, + enabled: !user.enabled, + }) + } + disabled={user.role === 'admin'} + /> + +
+ {user.role !== 'admin' && ( + + )} + +
+
+
+
+ + setInviteModalOpen(false)} + proxyHosts={proxyHosts} + /> + + { + setPermissionsModalOpen(false) + setSelectedUser(null) + }} + user={selectedUser} + proxyHosts={proxyHosts} + /> +
+ ) +} diff --git a/frontend/src/pages/__tests__/AcceptInvite.test.tsx b/frontend/src/pages/__tests__/AcceptInvite.test.tsx new file mode 100644 index 00000000..2e8b4f39 --- /dev/null +++ b/frontend/src/pages/__tests__/AcceptInvite.test.tsx @@ -0,0 +1,208 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { vi, describe, it, expect, beforeEach } from 'vitest' +import AcceptInvite from '../AcceptInvite' +import * as usersApi from '../../api/users' + +// Mock APIs +vi.mock('../../api/users', () => ({ + validateInvite: vi.fn(), + acceptInvite: vi.fn(), + listUsers: vi.fn(), + getUser: vi.fn(), + createUser: vi.fn(), + inviteUser: vi.fn(), + updateUser: vi.fn(), + deleteUser: vi.fn(), + updateUserPermissions: vi.fn(), +})) + +// Mock react-router-dom navigate +const mockNavigate = vi.fn() +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom') + return { + ...actual, + useNavigate: () => mockNavigate, + } +}) + +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + +const renderWithProviders = (initialRoute: string = '/accept-invite?token=test-token') => { + const queryClient = createQueryClient() + return render( + + + + } /> + Login Page} /> + + + + ) +} + +describe('AcceptInvite', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows invalid link message when no token provided', async () => { + renderWithProviders('/accept-invite') + + await waitFor(() => { + expect(screen.getByText('Invalid Link')).toBeTruthy() + }) + + expect(screen.getByText(/This invitation link is invalid/)).toBeTruthy() + }) + + it('shows validating state initially', () => { + vi.mocked(usersApi.validateInvite).mockReturnValue(new Promise(() => {})) + + renderWithProviders() + + expect(screen.getByText('Validating invitation...')).toBeTruthy() + }) + + it('shows error for invalid token', async () => { + vi.mocked(usersApi.validateInvite).mockRejectedValue({ + response: { data: { error: 'Token expired' } }, + }) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('Invitation Invalid')).toBeTruthy() + }) + }) + + it('renders accept form for valid token', async () => { + vi.mocked(usersApi.validateInvite).mockResolvedValue({ + valid: true, + email: 'invited@example.com', + }) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText(/been invited/i)).toBeTruthy() + }) + + expect(screen.getByText(/invited@example.com/)).toBeTruthy() + expect(screen.getByPlaceholderText('John Doe')).toBeTruthy() + // Password and confirm password have same placeholder + expect(screen.getAllByPlaceholderText('••••••••').length).toBe(2) + }) + + it('shows password mismatch error', async () => { + vi.mocked(usersApi.validateInvite).mockResolvedValue({ + valid: true, + email: 'invited@example.com', + }) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByPlaceholderText('John Doe')).toBeTruthy() + }) + + const user = userEvent.setup() + const [passwordInput, confirmInput] = screen.getAllByPlaceholderText('••••••••') + await user.type(passwordInput, 'password123') + await user.type(confirmInput, 'differentpassword') + + await waitFor(() => { + expect(screen.getByText('Passwords do not match')).toBeTruthy() + }) + }) + + it('submits form and shows success', async () => { + vi.mocked(usersApi.validateInvite).mockResolvedValue({ + valid: true, + email: 'invited@example.com', + }) + vi.mocked(usersApi.acceptInvite).mockResolvedValue({ + message: 'Success', + email: 'invited@example.com', + }) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByPlaceholderText('John Doe')).toBeTruthy() + }) + + const user = userEvent.setup() + await user.type(screen.getByPlaceholderText('John Doe'), 'John Doe') + const [passwordInput, confirmInput] = screen.getAllByPlaceholderText('••••••••') + await user.type(passwordInput, 'securepassword123') + await user.type(confirmInput, 'securepassword123') + + await user.click(screen.getByRole('button', { name: 'Create Account' })) + + await waitFor(() => { + expect(usersApi.acceptInvite).toHaveBeenCalledWith({ + token: 'test-token', + name: 'John Doe', + password: 'securepassword123', + }) + }) + + await waitFor(() => { + expect(screen.getByText('Account Created!')).toBeTruthy() + }) + }) + + it('shows error on submit failure', async () => { + vi.mocked(usersApi.validateInvite).mockResolvedValue({ + valid: true, + email: 'invited@example.com', + }) + vi.mocked(usersApi.acceptInvite).mockRejectedValue({ + response: { data: { error: 'Token has expired' } }, + }) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByPlaceholderText('John Doe')).toBeTruthy() + }) + + const user = userEvent.setup() + await user.type(screen.getByPlaceholderText('John Doe'), 'John Doe') + const [passwordInput, confirmInput] = screen.getAllByPlaceholderText('••••••••') + await user.type(passwordInput, 'securepassword123') + await user.type(confirmInput, 'securepassword123') + + await user.click(screen.getByRole('button', { name: 'Create Account' })) + + await waitFor(() => { + expect(usersApi.acceptInvite).toHaveBeenCalled() + }) + + // The toast should show error but we don't need to test toast specifically + }) + + it('navigates to login after clicking Go to Login button', async () => { + renderWithProviders('/accept-invite') + + await waitFor(() => { + expect(screen.getByText('Invalid Link')).toBeTruthy() + }) + + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: 'Go to Login' })) + + expect(mockNavigate).toHaveBeenCalledWith('/login') + }) +}) diff --git a/frontend/src/pages/__tests__/SMTPSettings.test.tsx b/frontend/src/pages/__tests__/SMTPSettings.test.tsx new file mode 100644 index 00000000..77109ea7 --- /dev/null +++ b/frontend/src/pages/__tests__/SMTPSettings.test.tsx @@ -0,0 +1,209 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import { vi, describe, it, expect, beforeEach } from 'vitest' +import SMTPSettings from '../SMTPSettings' +import * as smtpApi from '../../api/smtp' + +// Mock API +vi.mock('../../api/smtp', () => ({ + getSMTPConfig: vi.fn(), + updateSMTPConfig: vi.fn(), + testSMTPConnection: vi.fn(), + sendTestEmail: vi.fn(), +})) + +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + +const renderWithProviders = (ui: React.ReactNode) => { + const queryClient = createQueryClient() + return render( + + {ui} + + ) +} + +describe('SMTPSettings', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders loading state initially', () => { + vi.mocked(smtpApi.getSMTPConfig).mockReturnValue(new Promise(() => {})) + + renderWithProviders() + + // Should show loading spinner + expect(document.querySelector('.animate-spin')).toBeTruthy() + }) + + it('renders SMTP form with existing config', async () => { + vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({ + host: 'smtp.example.com', + port: 587, + username: 'user@example.com', + password: '********', + from_address: 'noreply@example.com', + encryption: 'starttls', + configured: true, + }) + + renderWithProviders() + + // Wait for the form to populate with data + await waitFor(() => { + const hostInput = screen.getByPlaceholderText('smtp.gmail.com') as HTMLInputElement + return hostInput.value === 'smtp.example.com' + }) + + const hostInput = screen.getByPlaceholderText('smtp.gmail.com') as HTMLInputElement + expect(hostInput.value).toBe('smtp.example.com') + + const portInput = screen.getByPlaceholderText('587') as HTMLInputElement + expect(portInput.value).toBe('587') + + expect(screen.getByText('SMTP Configured')).toBeTruthy() + }) + + it('shows not configured state when SMTP is not set up', async () => { + vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({ + host: '', + port: 587, + username: '', + password: '', + from_address: '', + encryption: 'starttls', + configured: false, + }) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('SMTP Not Configured')).toBeTruthy() + }) + }) + + it('saves SMTP settings successfully', async () => { + vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({ + host: '', + port: 587, + username: '', + password: '', + from_address: '', + encryption: 'starttls', + configured: false, + }) + vi.mocked(smtpApi.updateSMTPConfig).mockResolvedValue({ + message: 'SMTP configuration saved successfully', + }) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByPlaceholderText('smtp.gmail.com')).toBeTruthy() + }) + + const user = userEvent.setup() + await user.type(screen.getByPlaceholderText('smtp.gmail.com'), 'smtp.gmail.com') + await user.type( + screen.getByPlaceholderText('Charon '), + 'test@example.com' + ) + + await user.click(screen.getByRole('button', { name: 'Save Settings' })) + + await waitFor(() => { + expect(smtpApi.updateSMTPConfig).toHaveBeenCalled() + }) + }) + + it('tests SMTP connection', async () => { + vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({ + host: 'smtp.example.com', + port: 587, + username: 'user@example.com', + password: '********', + from_address: 'noreply@example.com', + encryption: 'starttls', + configured: true, + }) + vi.mocked(smtpApi.testSMTPConnection).mockResolvedValue({ + success: true, + message: 'Connection successful', + }) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('Test Connection')).toBeTruthy() + }) + + const user = userEvent.setup() + await user.click(screen.getByText('Test Connection')) + + await waitFor(() => { + expect(smtpApi.testSMTPConnection).toHaveBeenCalled() + }) + }) + + it('shows test email form when SMTP is configured', async () => { + vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({ + host: 'smtp.example.com', + port: 587, + username: 'user@example.com', + password: '********', + from_address: 'noreply@example.com', + encryption: 'starttls', + configured: true, + }) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('Send Test Email')).toBeTruthy() + }) + + expect(screen.getByPlaceholderText('recipient@example.com')).toBeTruthy() + }) + + it('sends test email', async () => { + vi.mocked(smtpApi.getSMTPConfig).mockResolvedValue({ + host: 'smtp.example.com', + port: 587, + username: 'user@example.com', + password: '********', + from_address: 'noreply@example.com', + encryption: 'starttls', + configured: true, + }) + vi.mocked(smtpApi.sendTestEmail).mockResolvedValue({ + success: true, + message: 'Email sent', + }) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('Send Test Email')).toBeTruthy() + }) + + const user = userEvent.setup() + await user.type( + screen.getByPlaceholderText('recipient@example.com'), + 'test@test.com' + ) + await user.click(screen.getByRole('button', { name: /Send Test/i })) + + await waitFor(() => { + expect(smtpApi.sendTestEmail).toHaveBeenCalledWith({ to: 'test@test.com' }) + }) + }) +}) diff --git a/frontend/src/pages/__tests__/UsersPage.test.tsx b/frontend/src/pages/__tests__/UsersPage.test.tsx new file mode 100644 index 00000000..4e1ec673 --- /dev/null +++ b/frontend/src/pages/__tests__/UsersPage.test.tsx @@ -0,0 +1,281 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import { vi, describe, it, expect, beforeEach } from 'vitest' +import UsersPage from '../UsersPage' +import * as usersApi from '../../api/users' +import * as proxyHostsApi from '../../api/proxyHosts' + +// Mock APIs +vi.mock('../../api/users', () => ({ + listUsers: vi.fn(), + getUser: vi.fn(), + createUser: vi.fn(), + inviteUser: vi.fn(), + updateUser: vi.fn(), + deleteUser: vi.fn(), + updateUserPermissions: vi.fn(), + validateInvite: vi.fn(), + acceptInvite: vi.fn(), +})) + +vi.mock('../../api/proxyHosts', () => ({ + getProxyHosts: vi.fn(), +})) + +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + +const renderWithProviders = (ui: React.ReactNode) => { + const queryClient = createQueryClient() + return render( + + {ui} + + ) +} + +const mockUsers = [ + { + id: 1, + uuid: '123-456', + email: 'admin@example.com', + name: 'Admin User', + role: 'admin' as const, + enabled: true, + permission_mode: 'allow_all' as const, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + { + id: 2, + uuid: '789-012', + email: 'user@example.com', + name: 'Regular User', + role: 'user' as const, + enabled: true, + invite_status: 'accepted' as const, + permission_mode: 'allow_all' as const, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + { + id: 3, + uuid: '345-678', + email: 'pending@example.com', + name: '', + role: 'user' as const, + enabled: false, + invite_status: 'pending' as const, + permission_mode: 'deny_all' as const, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, +] + +const mockProxyHosts = [ + { + uuid: 'host-1', + name: 'Test Host', + domain_names: 'test.example.com', + forward_scheme: 'http', + forward_host: 'localhost', + forward_port: 8080, + ssl_forced: true, + http2_support: true, + hsts_enabled: true, + hsts_subdomains: false, + block_exploits: true, + websocket_support: false, + application: 'none' as const, + locations: [], + enabled: true, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, +] + +describe('UsersPage', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(mockProxyHosts) + }) + + it('renders loading state initially', () => { + vi.mocked(usersApi.listUsers).mockReturnValue(new Promise(() => {})) + + renderWithProviders() + + expect(document.querySelector('.animate-spin')).toBeTruthy() + }) + + it('renders user list', async () => { + vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('User Management')).toBeTruthy() + }) + + expect(screen.getByText('Admin User')).toBeTruthy() + expect(screen.getByText('admin@example.com')).toBeTruthy() + expect(screen.getByText('Regular User')).toBeTruthy() + expect(screen.getByText('user@example.com')).toBeTruthy() + }) + + it('shows pending invite status', async () => { + vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('Pending Invite')).toBeTruthy() + }) + }) + + it('shows active status for accepted users', async () => { + vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getAllByText('Active').length).toBeGreaterThan(0) + }) + }) + + it('opens invite modal when clicking invite button', async () => { + vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('Invite User')).toBeTruthy() + }) + + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: /Invite User/i })) + + await waitFor(() => { + expect(screen.getByPlaceholderText('user@example.com')).toBeTruthy() + }) + }) + + it('shows permission mode in user list', async () => { + vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getAllByText('Blacklist').length).toBeGreaterThan(0) + }) + + expect(screen.getByText('Whitelist')).toBeTruthy() + }) + + it('toggles user enabled status', async () => { + vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers) + vi.mocked(usersApi.updateUser).mockResolvedValue({ message: 'Updated' }) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('Regular User')).toBeTruthy() + }) + + // Find the switch for the non-admin user and toggle it + const switches = screen.getAllByRole('checkbox') + // The second switch should be for the regular user (admin switch is disabled) + const userSwitch = switches.find( + (sw) => !(sw as HTMLInputElement).disabled && (sw as HTMLInputElement).checked + ) + + if (userSwitch) { + const user = userEvent.setup() + await user.click(userSwitch) + + await waitFor(() => { + expect(usersApi.updateUser).toHaveBeenCalledWith(2, { enabled: false }) + }) + } + }) + + it('invites a new user', async () => { + vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers) + vi.mocked(usersApi.inviteUser).mockResolvedValue({ + id: 4, + uuid: 'new-user', + email: 'new@example.com', + role: 'user', + invite_token: 'test-token-123', + email_sent: false, + expires_at: '2024-01-03T00:00:00Z', + }) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('Invite User')).toBeTruthy() + }) + + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: /Invite User/i })) + + // Wait for modal to open - look for the modal's email input placeholder + await waitFor(() => { + expect(screen.getByPlaceholderText('user@example.com')).toBeTruthy() + }) + + await user.type(screen.getByPlaceholderText('user@example.com'), 'new@example.com') + await user.click(screen.getByRole('button', { name: /Send Invite/i })) + + await waitFor(() => { + expect(usersApi.inviteUser).toHaveBeenCalledWith({ + email: 'new@example.com', + role: 'user', + permission_mode: 'allow_all', + permitted_hosts: [], + }) + }) + }) + + it('deletes a user after confirmation', async () => { + vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers) + vi.mocked(usersApi.deleteUser).mockResolvedValue({ message: 'Deleted' }) + + // Mock window.confirm + const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('Regular User')).toBeTruthy() + }) + + // Find delete buttons (trash icons) - admin user's delete button is disabled + const deleteButtons = screen.getAllByTitle('Delete User') + // Find the first non-disabled delete button + const enabledDeleteButton = deleteButtons.find((btn) => !(btn as HTMLButtonElement).disabled) + + expect(enabledDeleteButton).toBeTruthy() + + const user = userEvent.setup() + await user.click(enabledDeleteButton!) + + await waitFor(() => { + expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to delete this user?') + }) + + await waitFor(() => { + expect(usersApi.deleteUser).toHaveBeenCalled() + }) + + confirmSpy.mockRestore() + }) +})