- Removed redundant `gin.SetMode(gin.TestMode)` calls from individual test files. - Introduced a centralized `TestMain` function in `testmain_test.go` to set the Gin mode for all tests. - Ensured consistent test environment setup across various handler test files.
3020 lines
89 KiB
Go
3020 lines
89 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Wikid82/charon/backend/internal/config"
|
|
"github.com/Wikid82/charon/backend/internal/models"
|
|
"github.com/Wikid82/charon/backend/internal/services"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func setupUserHandler(t *testing.T) (*UserHandler, *gorm.DB) {
|
|
db := OpenTestDB(t)
|
|
_ = db.AutoMigrate(&models.User{}, &models.Setting{}, &models.SecurityAudit{})
|
|
return NewUserHandler(db, nil), db
|
|
}
|
|
|
|
func TestMapsKeys(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
keys := mapsKeys(map[string]any{"email": "a@example.com", "name": "Alice", "enabled": true})
|
|
assert.Len(t, keys, 3)
|
|
assert.Contains(t, keys, "email")
|
|
assert.Contains(t, keys, "name")
|
|
assert.Contains(t, keys, "enabled")
|
|
}
|
|
|
|
func TestUserHandler_actorFromContext(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler, _ := setupUserHandler(t)
|
|
|
|
rec1 := httptest.NewRecorder()
|
|
ctx1, _ := gin.CreateTestContext(rec1)
|
|
req1 := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
|
|
req1.RemoteAddr = "198.51.100.10:1234"
|
|
ctx1.Request = req1
|
|
assert.Equal(t, "198.51.100.10", handler.actorFromContext(ctx1))
|
|
|
|
rec2 := httptest.NewRecorder()
|
|
ctx2, _ := gin.CreateTestContext(rec2)
|
|
req2 := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
|
|
ctx2.Request = req2
|
|
ctx2.Set("userID", uint(42))
|
|
assert.Equal(t, "42", handler.actorFromContext(ctx2))
|
|
}
|
|
|
|
func TestUserHandler_logUserAudit_NoOpBranches(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler, _ := setupUserHandler(t)
|
|
rec := httptest.NewRecorder()
|
|
ctx, _ := gin.CreateTestContext(rec)
|
|
ctx.Request = httptest.NewRequest(http.MethodGet, "/", http.NoBody)
|
|
|
|
// nil user should be a no-op
|
|
handler.logUserAudit(ctx, "noop", nil, map[string]any{"x": 1})
|
|
|
|
// nil security service should be a no-op
|
|
handler.securitySvc = nil
|
|
handler.logUserAudit(ctx, "noop", &models.User{UUID: uuid.NewString(), Email: "user@example.com"}, map[string]any{"x": 1})
|
|
}
|
|
|
|
func TestUserHandler_GetSetupStatus(t *testing.T) {
|
|
handler, db := setupUserHandler(t)
|
|
r := gin.New()
|
|
r.GET("/setup", handler.GetSetupStatus)
|
|
|
|
// No users -> setup required
|
|
req, _ := http.NewRequest("GET", "/setup", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
assert.Contains(t, w.Body.String(), "\"setupRequired\":true")
|
|
|
|
// Create user -> setup not required
|
|
db.Create(&models.User{Email: "test@example.com"})
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
assert.Contains(t, w.Body.String(), "\"setupRequired\":false")
|
|
}
|
|
|
|
func TestUserHandler_Setup(t *testing.T) {
|
|
handler, _ := setupUserHandler(t)
|
|
r := gin.New()
|
|
r.POST("/setup", handler.Setup)
|
|
|
|
// 1. Invalid JSON (Before setup is done)
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/setup", bytes.NewBuffer([]byte("invalid json")))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
|
|
// 2. Valid Setup
|
|
body := map[string]string{
|
|
"name": "Admin",
|
|
"email": "admin@example.com",
|
|
"password": "password123",
|
|
}
|
|
jsonBody, _ := json.Marshal(body)
|
|
req, _ = http.NewRequest("POST", "/setup", bytes.NewBuffer(jsonBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusCreated, w.Code)
|
|
assert.Contains(t, w.Body.String(), "Setup completed successfully")
|
|
|
|
// 3. Try again -> should fail (already setup)
|
|
w = httptest.NewRecorder()
|
|
req, _ = http.NewRequest("POST", "/setup", bytes.NewBuffer(jsonBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
assert.Equal(t, http.StatusForbidden, w.Code)
|
|
}
|
|
|
|
func TestUserHandler_Setup_OneWayInvariant_ReentryRejectedAndSingleUser(t *testing.T) {
|
|
handler, db := setupUserHandler(t)
|
|
r := gin.New()
|
|
r.POST("/setup", handler.Setup)
|
|
|
|
initialBody := map[string]string{
|
|
"name": "Admin",
|
|
"email": "admin@example.com",
|
|
"password": "password123",
|
|
}
|
|
initialJSON, _ := json.Marshal(initialBody)
|
|
|
|
firstReq := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBuffer(initialJSON))
|
|
firstReq.Header.Set("Content-Type", "application/json")
|
|
firstResp := httptest.NewRecorder()
|
|
r.ServeHTTP(firstResp, firstReq)
|
|
require.Equal(t, http.StatusCreated, firstResp.Code)
|
|
|
|
secondBody := map[string]string{
|
|
"name": "Different Admin",
|
|
"email": "different@example.com",
|
|
"password": "password123",
|
|
}
|
|
secondJSON, _ := json.Marshal(secondBody)
|
|
secondReq := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBuffer(secondJSON))
|
|
secondReq.Header.Set("Content-Type", "application/json")
|
|
secondResp := httptest.NewRecorder()
|
|
r.ServeHTTP(secondResp, secondReq)
|
|
|
|
require.Equal(t, http.StatusForbidden, secondResp.Code)
|
|
|
|
var userCount int64
|
|
require.NoError(t, db.Model(&models.User{}).Count(&userCount).Error)
|
|
assert.Equal(t, int64(1), userCount)
|
|
}
|
|
|
|
func TestUserHandler_Setup_ConcurrentAttemptInvariant(t *testing.T) {
|
|
handler, db := setupUserHandler(t)
|
|
r := gin.New()
|
|
r.POST("/setup", handler.Setup)
|
|
|
|
concurrency := 6
|
|
start := make(chan struct{})
|
|
statuses := make(chan int, concurrency)
|
|
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < concurrency; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-start
|
|
|
|
body := map[string]string{
|
|
"name": "Admin",
|
|
"email": "admin@example.com",
|
|
"password": "password123",
|
|
}
|
|
jsonBody, _ := json.Marshal(body)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBuffer(jsonBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp := httptest.NewRecorder()
|
|
r.ServeHTTP(resp, req)
|
|
statuses <- resp.Code
|
|
}()
|
|
}
|
|
|
|
close(start)
|
|
wg.Wait()
|
|
close(statuses)
|
|
|
|
createdCount := 0
|
|
forbiddenOrConflictCount := 0
|
|
for status := range statuses {
|
|
if status == http.StatusCreated {
|
|
createdCount++
|
|
continue
|
|
}
|
|
|
|
if status == http.StatusForbidden || status == http.StatusConflict {
|
|
forbiddenOrConflictCount++
|
|
continue
|
|
}
|
|
|
|
t.Fatalf("unexpected setup concurrency status: %d", status)
|
|
}
|
|
|
|
assert.Equal(t, 1, createdCount)
|
|
assert.Equal(t, concurrency-1, forbiddenOrConflictCount)
|
|
|
|
var userCount int64
|
|
require.NoError(t, db.Model(&models.User{}).Count(&userCount).Error)
|
|
assert.Equal(t, int64(1), userCount)
|
|
}
|
|
|
|
func TestUserHandler_Setup_ResponseSecretEchoContract(t *testing.T) {
|
|
handler, _ := setupUserHandler(t)
|
|
r := gin.New()
|
|
r.POST("/setup", handler.Setup)
|
|
|
|
body := map[string]string{
|
|
"name": "Admin",
|
|
"email": "admin@example.com",
|
|
"password": "password123",
|
|
}
|
|
jsonBody, _ := json.Marshal(body)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBuffer(jsonBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp := httptest.NewRecorder()
|
|
r.ServeHTTP(resp, req)
|
|
require.Equal(t, http.StatusCreated, resp.Code)
|
|
|
|
var payload map[string]any
|
|
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
|
|
|
|
userValue, ok := payload["user"]
|
|
require.True(t, ok)
|
|
userMap, ok := userValue.(map[string]any)
|
|
require.True(t, ok)
|
|
|
|
_, hasAPIKey := userMap["api_key"]
|
|
_, hasPassword := userMap["password"]
|
|
_, hasPasswordHash := userMap["password_hash"]
|
|
_, hasInviteToken := userMap["invite_token"]
|
|
|
|
assert.False(t, hasAPIKey)
|
|
assert.False(t, hasPassword)
|
|
assert.False(t, hasPasswordHash)
|
|
assert.False(t, hasInviteToken)
|
|
}
|
|
|
|
func TestUserHandler_GetProfile_SecretEchoContract(t *testing.T) {
|
|
handler, db := setupUserHandler(t)
|
|
|
|
user := &models.User{
|
|
UUID: uuid.NewString(),
|
|
Email: "profile@example.com",
|
|
Name: "Profile User",
|
|
APIKey: "real-secret-api-key",
|
|
InviteToken: "invite-secret-token",
|
|
PasswordHash: "hashed-password-value",
|
|
}
|
|
require.NoError(t, db.Create(user).Error)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("userID", user.ID)
|
|
c.Next()
|
|
})
|
|
r.GET("/profile", handler.GetProfile)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/profile", http.NoBody)
|
|
resp := httptest.NewRecorder()
|
|
r.ServeHTTP(resp, req)
|
|
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
var payload map[string]any
|
|
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
|
|
|
|
_, hasAPIKey := payload["api_key"]
|
|
_, hasPassword := payload["password"]
|
|
_, hasPasswordHash := payload["password_hash"]
|
|
_, hasInviteToken := payload["invite_token"]
|
|
|
|
assert.False(t, hasAPIKey)
|
|
assert.False(t, hasPassword)
|
|
assert.False(t, hasPasswordHash)
|
|
assert.False(t, hasInviteToken)
|
|
assert.Equal(t, "********", payload["api_key_masked"])
|
|
}
|
|
|
|
func TestUserHandler_ListUsers_SecretEchoContract(t *testing.T) {
|
|
handler, db := setupUserHandlerWithProxyHosts(t)
|
|
|
|
user := &models.User{
|
|
UUID: uuid.NewString(),
|
|
Email: "user@example.com",
|
|
Name: "User",
|
|
Role: models.RoleUser,
|
|
APIKey: "raw-api-key",
|
|
InviteToken: "raw-invite-token",
|
|
PasswordHash: "raw-password-hash",
|
|
}
|
|
require.NoError(t, db.Create(user).Error)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Next()
|
|
})
|
|
r.GET("/users", handler.ListUsers)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/users", http.NoBody)
|
|
resp := httptest.NewRecorder()
|
|
r.ServeHTTP(resp, req)
|
|
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
var users []map[string]any
|
|
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &users))
|
|
require.Len(t, users, 1)
|
|
|
|
_, hasAPIKey := users[0]["api_key"]
|
|
_, hasPassword := users[0]["password"]
|
|
_, hasPasswordHash := users[0]["password_hash"]
|
|
_, hasInviteToken := users[0]["invite_token"]
|
|
|
|
assert.False(t, hasAPIKey)
|
|
assert.False(t, hasPassword)
|
|
assert.False(t, hasPasswordHash)
|
|
assert.False(t, hasInviteToken)
|
|
}
|
|
|
|
func TestUserHandler_Setup_DBError(t *testing.T) {
|
|
// Can't easily mock DB error with sqlite memory unless we close it or something.
|
|
// But we can try to insert duplicate email if we had a unique constraint and pre-seeded data,
|
|
// but Setup checks if ANY user exists first.
|
|
// So if we have a user, it returns Forbidden.
|
|
// If we don't, it tries to create.
|
|
// If we want Create to fail, maybe invalid data that passes binding but fails DB constraint?
|
|
// User model has validation?
|
|
// Let's try empty password if allowed by binding but rejected by DB?
|
|
// Or very long string?
|
|
}
|
|
|
|
func TestUserHandler_RegenerateAPIKey(t *testing.T) {
|
|
handler, db := setupUserHandler(t)
|
|
|
|
user := &models.User{Email: "api@example.com"}
|
|
db.Create(user)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("userID", user.ID)
|
|
c.Next()
|
|
})
|
|
r.POST("/api-key", handler.RegenerateAPIKey)
|
|
|
|
req, _ := http.NewRequest("POST", "/api-key", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var resp map[string]any
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err, "Failed to unmarshal response")
|
|
assert.Equal(t, "API key regenerated successfully", resp["message"])
|
|
assert.Equal(t, "********", resp["api_key_masked"])
|
|
|
|
// Verify DB
|
|
var updatedUser models.User
|
|
db.First(&updatedUser, user.ID)
|
|
assert.NotEmpty(t, updatedUser.APIKey)
|
|
}
|
|
|
|
func TestUserHandler_GetProfile(t *testing.T) {
|
|
handler, db := setupUserHandler(t)
|
|
|
|
user := &models.User{
|
|
Email: "profile@example.com",
|
|
Name: "Profile User",
|
|
APIKey: "existing-key",
|
|
}
|
|
db.Create(user)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("userID", user.ID)
|
|
c.Next()
|
|
})
|
|
r.GET("/profile", handler.GetProfile)
|
|
|
|
req, _ := http.NewRequest("GET", "/profile", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var resp models.User
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err, "Failed to unmarshal response")
|
|
assert.Equal(t, user.Email, resp.Email)
|
|
// APIKey is not exposed in JSON (json:"-" tag), so it should be empty in response
|
|
assert.Empty(t, resp.APIKey, "APIKey should not be exposed in profile response")
|
|
}
|
|
|
|
func TestUserHandler_RegisterRoutes(t *testing.T) {
|
|
handler, _ := setupUserHandler(t)
|
|
r := gin.New()
|
|
api := r.Group("/api")
|
|
handler.RegisterRoutes(api)
|
|
|
|
routes := r.Routes()
|
|
expectedRoutes := map[string]string{
|
|
"/api/setup": "GET,POST",
|
|
"/api/profile": "GET",
|
|
"/api/regenerate-api-key": "POST",
|
|
}
|
|
|
|
for path := range expectedRoutes {
|
|
found := false
|
|
for _, route := range routes {
|
|
if route.Path == path {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, found, "Route %s not found", path)
|
|
}
|
|
}
|
|
|
|
func TestUserHandler_Errors(t *testing.T) {
|
|
handler, db := setupUserHandler(t)
|
|
r := gin.New()
|
|
|
|
// Middleware to simulate missing userID
|
|
r.GET("/profile-no-auth", func(c *gin.Context) {
|
|
// No userID set
|
|
handler.GetProfile(c)
|
|
})
|
|
r.POST("/api-key-no-auth", func(c *gin.Context) {
|
|
// No userID set
|
|
handler.RegenerateAPIKey(c)
|
|
})
|
|
|
|
// Middleware to simulate non-existent user
|
|
r.GET("/profile-not-found", func(c *gin.Context) {
|
|
c.Set("userID", uint(99999))
|
|
handler.GetProfile(c)
|
|
})
|
|
r.POST("/api-key-not-found", func(c *gin.Context) {
|
|
c.Set("userID", uint(99999))
|
|
handler.RegenerateAPIKey(c)
|
|
})
|
|
|
|
// Test Unauthorized
|
|
req, _ := http.NewRequest("GET", "/profile-no-auth", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
|
|
req, _ = http.NewRequest("POST", "/api-key-no-auth", http.NoBody)
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
|
|
// Test Not Found (GetProfile)
|
|
req, _ = http.NewRequest("GET", "/profile-not-found", http.NoBody)
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
|
|
// Test DB Error (RegenerateAPIKey) - Hard to mock DB error on update with sqlite memory,
|
|
// but we can try to update a non-existent user which GORM Update might not treat as error unless we check RowsAffected.
|
|
// The handler code: if err := h.DB.Model(&models.User{}).Where("id = ?", userID).Update("api_key", apiKey).Error; err != nil
|
|
// Update on non-existent record usually returns nil error in GORM unless configured otherwise.
|
|
// However, let's see if we can force an error by closing DB? No, shared DB.
|
|
// We can drop the table?
|
|
_ = db.Migrator().DropTable(&models.User{})
|
|
req, _ = http.NewRequest("POST", "/api-key-not-found", http.NoBody)
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
// If table missing, Update should fail
|
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
}
|
|
|
|
func TestUserHandler_UpdateProfile(t *testing.T) {
|
|
handler, db := setupUserHandler(t)
|
|
|
|
// Create user
|
|
user := &models.User{
|
|
UUID: uuid.NewString(),
|
|
Email: "test@example.com",
|
|
Name: "Test User",
|
|
APIKey: uuid.NewString(),
|
|
}
|
|
_ = user.SetPassword("password123")
|
|
db.Create(user)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("userID", user.ID)
|
|
c.Next()
|
|
})
|
|
r.PUT("/profile", handler.UpdateProfile)
|
|
|
|
// 1. Success - Name only
|
|
t.Run("Success Name Only", func(t *testing.T) {
|
|
body := map[string]string{
|
|
"name": "Updated Name",
|
|
"email": "test@example.com",
|
|
}
|
|
jsonBody, _ := json.Marshal(body)
|
|
req := httptest.NewRequest("PUT", "/profile", bytes.NewBuffer(jsonBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var updatedUser models.User
|
|
db.First(&updatedUser, user.ID)
|
|
assert.Equal(t, "Updated Name", updatedUser.Name)
|
|
})
|
|
|
|
// 2. Success - Email change with password
|
|
t.Run("Success Email Change", func(t *testing.T) {
|
|
body := map[string]string{
|
|
"name": "Updated Name",
|
|
"email": "newemail@example.com",
|
|
"current_password": "password123",
|
|
}
|
|
jsonBody, _ := json.Marshal(body)
|
|
req := httptest.NewRequest("PUT", "/profile", bytes.NewBuffer(jsonBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var updatedUser models.User
|
|
db.First(&updatedUser, user.ID)
|
|
assert.Equal(t, "newemail@example.com", updatedUser.Email)
|
|
})
|
|
|
|
// 3. Fail - Email change without password
|
|
t.Run("Fail Email Change No Password", func(t *testing.T) {
|
|
// Reset email
|
|
db.Model(user).Update("email", "test@example.com")
|
|
|
|
body := map[string]string{
|
|
"name": "Updated Name",
|
|
"email": "another@example.com",
|
|
}
|
|
jsonBody, _ := json.Marshal(body)
|
|
req := httptest.NewRequest("PUT", "/profile", bytes.NewBuffer(jsonBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
})
|
|
|
|
// 4. Fail - Email change wrong password
|
|
t.Run("Fail Email Change Wrong Password", func(t *testing.T) {
|
|
body := map[string]string{
|
|
"name": "Updated Name",
|
|
"email": "another@example.com",
|
|
"current_password": "wrongpassword",
|
|
}
|
|
jsonBody, _ := json.Marshal(body)
|
|
req := httptest.NewRequest("PUT", "/profile", bytes.NewBuffer(jsonBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
})
|
|
|
|
// 5. Fail - Email already in use
|
|
t.Run("Fail Email In Use", func(t *testing.T) {
|
|
// Create another user
|
|
otherUser := &models.User{
|
|
UUID: uuid.NewString(),
|
|
Email: "other@example.com",
|
|
Name: "Other User",
|
|
APIKey: uuid.NewString(),
|
|
}
|
|
db.Create(otherUser)
|
|
|
|
body := map[string]string{
|
|
"name": "Updated Name",
|
|
"email": "other@example.com",
|
|
}
|
|
jsonBody, _ := json.Marshal(body)
|
|
req := httptest.NewRequest("PUT", "/profile", 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_UpdateProfile_Errors(t *testing.T) {
|
|
handler, _ := setupUserHandler(t)
|
|
r := gin.New()
|
|
|
|
// 1. Unauthorized (no userID)
|
|
r.PUT("/profile-no-auth", handler.UpdateProfile)
|
|
req, _ := http.NewRequest("PUT", "/profile-no-auth", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
|
|
// Middleware for subsequent tests
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("userID", uint(999)) // Non-existent ID
|
|
c.Next()
|
|
})
|
|
r.PUT("/profile", handler.UpdateProfile)
|
|
|
|
// 2. BindJSON error
|
|
req, _ = http.NewRequest("PUT", "/profile", bytes.NewBufferString("invalid"))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
|
|
// 3. User not found
|
|
body := map[string]string{"name": "New Name", "email": "new@example.com"}
|
|
jsonBody, _ := json.Marshal(body)
|
|
req, _ = http.NewRequest("PUT", "/profile", bytes.NewBuffer(jsonBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w = httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
}
|
|
|
|
// ============= User Management Tests (Admin functions) =============
|
|
|
|
func setupUserHandlerWithProxyHosts(t *testing.T) (*UserHandler, *gorm.DB) {
|
|
db := OpenTestDB(t)
|
|
_ = db.AutoMigrate(&models.User{}, &models.Setting{}, &models.ProxyHost{}, &models.SecurityAudit{})
|
|
return NewUserHandler(db, nil), db
|
|
}
|
|
|
|
func TestUserHandler_ListUsers_NonAdmin(t *testing.T) {
|
|
handler, _ := setupUserHandlerWithProxyHosts(t)
|
|
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", http.NoBody)
|
|
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)
|
|
|
|
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", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var users []map[string]any
|
|
err := json.Unmarshal(w.Body.Bytes(), &users)
|
|
require.NoError(t, err, "Failed to unmarshal response")
|
|
assert.Len(t, users, 2)
|
|
}
|
|
|
|
func TestUserHandler_CreateUser_NonAdmin(t *testing.T) {
|
|
handler, _ := setupUserHandlerWithProxyHosts(t)
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "user")
|
|
c.Next()
|
|
})
|
|
r.POST("/users", handler.CreateUser)
|
|
|
|
body := map[string]any{
|
|
"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, db := setupUserHandlerWithProxyHosts(t)
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Set("userID", uint(99))
|
|
c.Next()
|
|
})
|
|
r.POST("/users", handler.CreateUser)
|
|
|
|
body := map[string]any{
|
|
"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)
|
|
handler.securitySvc.Flush()
|
|
|
|
var audit models.SecurityAudit
|
|
require.NoError(t, db.Where("action = ? AND event_category = ?", "user_create", "user").First(&audit).Error)
|
|
assert.Equal(t, "99", audit.Actor)
|
|
}
|
|
|
|
func TestUserHandler_CreateUser_InvalidJSON(t *testing.T) {
|
|
handler, _ := setupUserHandlerWithProxyHosts(t)
|
|
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)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Next()
|
|
})
|
|
r.POST("/users", handler.CreateUser)
|
|
|
|
body := map[string]any{
|
|
"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)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Next()
|
|
})
|
|
r.POST("/users", handler.CreateUser)
|
|
|
|
body := map[string]any{
|
|
"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)
|
|
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", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusForbidden, w.Code)
|
|
}
|
|
|
|
func TestUserHandler_GetUser_InvalidID(t *testing.T) {
|
|
handler, _ := setupUserHandlerWithProxyHosts(t)
|
|
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", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func TestUserHandler_GetUser_NotFound(t *testing.T) {
|
|
handler, _ := setupUserHandlerWithProxyHosts(t)
|
|
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", http.NoBody)
|
|
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)
|
|
|
|
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", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
|
|
func TestUserHandler_UpdateUser_NonAdmin(t *testing.T) {
|
|
handler, db := setupUserHandlerWithProxyHosts(t)
|
|
|
|
// Create a target user so it exists in the DB
|
|
target := &models.User{UUID: uuid.NewString(), Email: "target@example.com", Name: "Target", APIKey: uuid.NewString(), Role: models.RoleUser}
|
|
db.Create(target)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "user")
|
|
c.Set("userID", uint(999))
|
|
c.Next()
|
|
})
|
|
r.PUT("/users/:id", handler.UpdateUser)
|
|
|
|
body := map[string]any{"name": "Updated"}
|
|
jsonBody, _ := json.Marshal(body)
|
|
req := httptest.NewRequest("PUT", fmt.Sprintf("/users/%d", target.ID), 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)
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Set("userID", uint(11))
|
|
c.Next()
|
|
})
|
|
r.PUT("/users/:id", handler.UpdateUser)
|
|
|
|
body := map[string]any{"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)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Set("userID", uint(11))
|
|
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)
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Set("userID", uint(11))
|
|
c.Next()
|
|
})
|
|
r.PUT("/users/:id", handler.UpdateUser)
|
|
|
|
body := map[string]any{"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: models.RoleUser}
|
|
db.Create(user)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Set("userID", uint(11))
|
|
c.Next()
|
|
})
|
|
r.PUT("/users/:id", handler.UpdateUser)
|
|
|
|
body := map[string]any{
|
|
"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)
|
|
handler.securitySvc.Flush()
|
|
|
|
var audit models.SecurityAudit
|
|
require.NoError(t, db.Where("action = ? AND event_category = ?", "user_update", "user").First(&audit).Error)
|
|
assert.Equal(t, user.UUID, audit.ResourceUUID)
|
|
}
|
|
|
|
func TestUserHandler_UpdateUser_PasswordReset(t *testing.T) {
|
|
handler, db := setupUserHandlerWithProxyHosts(t)
|
|
|
|
user := &models.User{UUID: uuid.NewString(), Email: "reset@example.com", Name: "Reset User", Role: models.RoleUser}
|
|
require.NoError(t, user.SetPassword("oldpassword123"))
|
|
lockUntil := time.Now().Add(10 * time.Minute)
|
|
user.FailedLoginAttempts = 4
|
|
user.LockedUntil = &lockUntil
|
|
db.Create(user)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Set("userID", uint(11))
|
|
c.Next()
|
|
})
|
|
r.PUT("/users/:id", handler.UpdateUser)
|
|
|
|
body := map[string]any{
|
|
"password": "newpassword123",
|
|
}
|
|
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)
|
|
|
|
var updated models.User
|
|
db.First(&updated, user.ID)
|
|
assert.True(t, updated.CheckPassword("newpassword123"))
|
|
assert.False(t, updated.CheckPassword("oldpassword123"))
|
|
assert.Equal(t, 0, updated.FailedLoginAttempts)
|
|
assert.Nil(t, updated.LockedUntil)
|
|
}
|
|
|
|
func TestUserHandler_DeleteUser_NonAdmin(t *testing.T) {
|
|
handler, _ := setupUserHandlerWithProxyHosts(t)
|
|
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", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusForbidden, w.Code)
|
|
}
|
|
|
|
func TestUserHandler_DeleteUser_InvalidID(t *testing.T) {
|
|
handler, _ := setupUserHandlerWithProxyHosts(t)
|
|
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", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func TestUserHandler_DeleteUser_NotFound(t *testing.T) {
|
|
handler, _ := setupUserHandlerWithProxyHosts(t)
|
|
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", http.NoBody)
|
|
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)
|
|
|
|
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", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
handler.securitySvc.Flush()
|
|
|
|
var audit models.SecurityAudit
|
|
require.NoError(t, db.Where("action = ? AND event_category = ?", "user_delete", "user").First(&audit).Error)
|
|
assert.Equal(t, user.UUID, audit.ResourceUUID)
|
|
}
|
|
|
|
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)
|
|
|
|
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", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusForbidden, w.Code)
|
|
}
|
|
|
|
func TestUserHandler_UpdateUserPermissions_NonAdmin(t *testing.T) {
|
|
handler, _ := setupUserHandlerWithProxyHosts(t)
|
|
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]any{"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)
|
|
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]any{"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: models.RoleUser,
|
|
Enabled: true,
|
|
}
|
|
db.Create(user)
|
|
|
|
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)
|
|
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]any{"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)
|
|
|
|
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]any{
|
|
"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)
|
|
r := gin.New()
|
|
r.GET("/invite/validate", handler.ValidateInvite)
|
|
|
|
req := httptest.NewRequest("GET", "/invite/validate", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func TestUserHandler_ValidateInvite_InvalidToken(t *testing.T) {
|
|
handler, _ := setupUserHandlerWithProxyHosts(t)
|
|
r := gin.New()
|
|
r.GET("/invite/validate", handler.ValidateInvite)
|
|
|
|
req := httptest.NewRequest("GET", "/invite/validate?token=invalidtoken", http.NoBody)
|
|
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)
|
|
|
|
r := gin.New()
|
|
r.GET("/invite/validate", handler.ValidateInvite)
|
|
|
|
req := httptest.NewRequest("GET", "/invite/validate?token=expiredtoken123", http.NoBody)
|
|
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)
|
|
|
|
r := gin.New()
|
|
r.GET("/invite/validate", handler.ValidateInvite)
|
|
|
|
req := httptest.NewRequest("GET", "/invite/validate?token=acceptedtoken123", http.NoBody)
|
|
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)
|
|
|
|
r := gin.New()
|
|
r.GET("/invite/validate", handler.ValidateInvite)
|
|
|
|
req := httptest.NewRequest("GET", "/invite/validate?token=validtoken123", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var resp map[string]any
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err, "Failed to unmarshal response")
|
|
assert.Equal(t, "valid@example.com", resp["email"])
|
|
}
|
|
|
|
func TestUserHandler_AcceptInvite_InvalidJSON(t *testing.T) {
|
|
handler, _ := setupUserHandlerWithProxyHosts(t)
|
|
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)
|
|
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)
|
|
|
|
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)
|
|
handler.securitySvc.Flush()
|
|
|
|
// Verify user was updated
|
|
var updated models.User
|
|
db.First(&updated, user.ID)
|
|
assert.Equal(t, "accepted", updated.InviteStatus)
|
|
assert.True(t, updated.Enabled)
|
|
|
|
var audit models.SecurityAudit
|
|
require.NoError(t, db.Where("action = ? AND event_category = ?", "user_invite_accept", "user").First(&audit).Error)
|
|
assert.Equal(t, user.UUID, audit.ResourceUUID)
|
|
}
|
|
|
|
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)
|
|
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)
|
|
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)
|
|
|
|
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: models.RoleAdmin,
|
|
}
|
|
db.Create(admin)
|
|
|
|
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]any{
|
|
"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)
|
|
handler.securitySvc.Flush()
|
|
|
|
var resp map[string]any
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err, "Failed to unmarshal response")
|
|
assert.Equal(t, "********", resp["invite_token_masked"])
|
|
assert.Equal(t, "", resp["invite_url"])
|
|
// 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)
|
|
|
|
var audit models.SecurityAudit
|
|
require.NoError(t, db.Where("action = ? AND event_category = ?", "user_invite", "user").First(&audit).Error)
|
|
assert.Equal(t, user.UUID, audit.ResourceUUID)
|
|
}
|
|
|
|
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: models.RoleAdmin,
|
|
}
|
|
db.Create(admin)
|
|
|
|
// Create proxy host
|
|
host := &models.ProxyHost{
|
|
UUID: uuid.NewString(),
|
|
Name: "Test Host",
|
|
DomainNames: "test.example.com",
|
|
}
|
|
db.Create(host)
|
|
|
|
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]any{
|
|
"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 TestUserHandler_InviteUser_WithSMTPConfigured(t *testing.T) {
|
|
handler, db := setupUserHandlerWithProxyHosts(t)
|
|
|
|
// Create admin user
|
|
admin := &models.User{
|
|
UUID: uuid.NewString(),
|
|
APIKey: uuid.NewString(),
|
|
Email: "admin-smtp@example.com",
|
|
Role: models.RoleAdmin,
|
|
}
|
|
db.Create(admin)
|
|
|
|
// Configure SMTP settings to trigger email code path and getAppName call
|
|
smtpSettings := []models.Setting{
|
|
{Key: "smtp_host", Value: "smtp.example.com", Type: "string", Category: "smtp"},
|
|
{Key: "smtp_port", Value: "587", Type: "integer", Category: "smtp"},
|
|
{Key: "smtp_username", Value: "user@example.com", Type: "string", Category: "smtp"},
|
|
{Key: "smtp_password", Value: "password", Type: "string", Category: "smtp"},
|
|
{Key: "smtp_from_address", Value: "noreply@example.com", Type: "string", Category: "smtp"},
|
|
{Key: "app_name", Value: "TestApp", Type: "string", Category: "app"},
|
|
}
|
|
for _, setting := range smtpSettings {
|
|
db.Create(&setting)
|
|
}
|
|
|
|
// Reinitialize mail service to pick up new settings
|
|
handler.MailService = services.NewMailService(db)
|
|
|
|
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]any{
|
|
"email": "smtp-test@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.StatusCreated, w.Code)
|
|
|
|
// Verify user was created
|
|
var user models.User
|
|
db.Where("email = ?", "smtp-test@example.com").First(&user)
|
|
assert.Equal(t, "pending", user.InviteStatus)
|
|
assert.False(t, user.Enabled)
|
|
|
|
// Note: email_sent will be false because we can't actually send email in tests,
|
|
// but the code path through IsConfigured() and getAppName() is still executed
|
|
var resp map[string]any
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err, "Failed to unmarshal response")
|
|
assert.Equal(t, "********", resp["invite_token_masked"])
|
|
assert.Equal(t, "", resp["invite_url"])
|
|
assert.Equal(t, false, resp["email_sent"].(bool))
|
|
}
|
|
|
|
func TestUserHandler_InviteUser_WithSMTPAndConfiguredPublicURL_IncludesInviteURL(t *testing.T) {
|
|
handler, db := setupUserHandlerWithProxyHosts(t)
|
|
|
|
admin := &models.User{
|
|
UUID: uuid.NewString(),
|
|
APIKey: uuid.NewString(),
|
|
Email: "admin-publicurl@example.com",
|
|
Role: models.RoleAdmin,
|
|
}
|
|
db.Create(admin)
|
|
|
|
settings := []models.Setting{
|
|
{Key: "smtp_host", Value: "smtp.example.com", Type: "string", Category: "smtp"},
|
|
{Key: "smtp_port", Value: "587", Type: "integer", Category: "smtp"},
|
|
{Key: "smtp_username", Value: "user@example.com", Type: "string", Category: "smtp"},
|
|
{Key: "smtp_password", Value: "password", Type: "string", Category: "smtp"},
|
|
{Key: "smtp_from_address", Value: "noreply@example.com", Type: "string", Category: "smtp"},
|
|
{Key: "app.public_url", Value: "https://charon.example.com", Type: "string", Category: "app"},
|
|
}
|
|
for _, setting := range settings {
|
|
db.Create(&setting)
|
|
}
|
|
|
|
handler.MailService = services.NewMailService(db)
|
|
|
|
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]any{
|
|
"email": "smtp-public-url@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.StatusCreated, w.Code)
|
|
|
|
var resp map[string]any
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err, "Failed to unmarshal response")
|
|
assert.Equal(t, "********", resp["invite_token_masked"])
|
|
assert.Equal(t, "[REDACTED]", resp["invite_url"])
|
|
assert.Equal(t, true, resp["email_sent"].(bool))
|
|
}
|
|
|
|
func TestUserHandler_InviteUser_WithSMTPAndMalformedPublicURL_DoesNotExposeInviteURL(t *testing.T) {
|
|
handler, db := setupUserHandlerWithProxyHosts(t)
|
|
|
|
admin := &models.User{
|
|
UUID: uuid.NewString(),
|
|
APIKey: uuid.NewString(),
|
|
Email: "admin-malformed-publicurl@example.com",
|
|
Role: models.RoleAdmin,
|
|
}
|
|
db.Create(admin)
|
|
|
|
settings := []models.Setting{
|
|
{Key: "smtp_host", Value: "smtp.example.com", Type: "string", Category: "smtp"},
|
|
{Key: "smtp_port", Value: "587", Type: "integer", Category: "smtp"},
|
|
{Key: "smtp_username", Value: "user@example.com", Type: "string", Category: "smtp"},
|
|
{Key: "smtp_password", Value: "password", Type: "string", Category: "smtp"},
|
|
{Key: "smtp_from_address", Value: "noreply@example.com", Type: "string", Category: "smtp"},
|
|
{Key: "app.public_url", Value: "https://charon.example.com/path", Type: "string", Category: "app"},
|
|
}
|
|
for _, setting := range settings {
|
|
db.Create(&setting)
|
|
}
|
|
|
|
handler.MailService = services.NewMailService(db)
|
|
|
|
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]any{
|
|
"email": "smtp-malformed-url@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.StatusCreated, w.Code)
|
|
|
|
var resp map[string]any
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err, "Failed to unmarshal response")
|
|
assert.Equal(t, "********", resp["invite_token_masked"])
|
|
assert.Equal(t, "", resp["invite_url"])
|
|
assert.Equal(t, false, resp["email_sent"].(bool))
|
|
}
|
|
|
|
func TestUserHandler_InviteUser_WithSMTPConfigured_DefaultAppName(t *testing.T) {
|
|
handler, db := setupUserHandlerWithProxyHosts(t)
|
|
|
|
// Create admin user
|
|
admin := &models.User{
|
|
UUID: uuid.NewString(),
|
|
APIKey: uuid.NewString(),
|
|
Email: "admin-smtp-default@example.com",
|
|
Role: models.RoleAdmin,
|
|
}
|
|
db.Create(admin)
|
|
|
|
// Configure SMTP settings WITHOUT app_name to trigger default "Charon" path
|
|
smtpSettings := []models.Setting{
|
|
{Key: "smtp_host", Value: "smtp.example.com", Type: "string", Category: "smtp"},
|
|
{Key: "smtp_port", Value: "587", Type: "integer", Category: "smtp"},
|
|
{Key: "smtp_username", Value: "user@example.com", Type: "string", Category: "smtp"},
|
|
{Key: "smtp_password", Value: "password", Type: "string", Category: "smtp"},
|
|
{Key: "smtp_from_address", Value: "noreply@example.com", Type: "string", Category: "smtp"},
|
|
// Intentionally NOT setting app_name to test default path
|
|
}
|
|
for _, setting := range smtpSettings {
|
|
db.Create(&setting)
|
|
}
|
|
|
|
// Reinitialize mail service to pick up new settings
|
|
handler.MailService = services.NewMailService(db)
|
|
|
|
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]any{
|
|
"email": "smtp-test-default@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.StatusCreated, w.Code)
|
|
|
|
// Verify user was created
|
|
var user models.User
|
|
db.Where("email = ?", "smtp-test-default@example.com").First(&user)
|
|
assert.Equal(t, "pending", user.InviteStatus)
|
|
assert.False(t, user.Enabled)
|
|
|
|
var resp map[string]any
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err, "Failed to unmarshal response")
|
|
assert.Equal(t, "********", resp["invite_token_masked"])
|
|
}
|
|
|
|
// Note: TestGetBaseURL and TestGetAppName have been removed as these internal helper
|
|
// functions have been refactored into the utils package. URL functionality is tested
|
|
// via integration tests and the utils package should have its own unit tests.
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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)
|
|
}
|
|
|
|
// ============= Priority 1: Zero Coverage Functions =============
|
|
|
|
// PreviewInviteURL Tests
|
|
func TestUserHandler_PreviewInviteURL_NonAdmin(t *testing.T) {
|
|
handler, _ := setupUserHandlerWithProxyHosts(t)
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "user")
|
|
c.Next()
|
|
})
|
|
r.POST("/users/preview-invite-url", handler.PreviewInviteURL)
|
|
|
|
body := map[string]string{"email": "test@example.com"}
|
|
jsonBody, _ := json.Marshal(body)
|
|
req := httptest.NewRequest("POST", "/users/preview-invite-url", bytes.NewBuffer(jsonBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusForbidden, w.Code)
|
|
assert.Contains(t, w.Body.String(), "admin privileges required")
|
|
}
|
|
|
|
func TestUserHandler_PreviewInviteURL_InvalidJSON(t *testing.T) {
|
|
handler, _ := setupUserHandlerWithProxyHosts(t)
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Next()
|
|
})
|
|
r.POST("/users/preview-invite-url", handler.PreviewInviteURL)
|
|
|
|
req := httptest.NewRequest("POST", "/users/preview-invite-url", 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_PreviewInviteURL_Success_Unconfigured(t *testing.T) {
|
|
handler, _ := setupUserHandlerWithProxyHosts(t)
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Next()
|
|
})
|
|
r.POST("/users/preview-invite-url", handler.PreviewInviteURL)
|
|
|
|
body := map[string]string{"email": "test@example.com"}
|
|
jsonBody, _ := json.Marshal(body)
|
|
req := httptest.NewRequest("POST", "/users/preview-invite-url", bytes.NewBuffer(jsonBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var resp map[string]any
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err, "Failed to unmarshal response")
|
|
|
|
assert.Equal(t, false, resp["is_configured"].(bool))
|
|
assert.Equal(t, true, resp["warning"].(bool))
|
|
assert.Contains(t, resp["warning_message"].(string), "not configured")
|
|
// When unconfigured, base_url and preview_url must be empty (CodeQL go/email-injection remediation)
|
|
assert.Equal(t, "", resp["base_url"].(string), "base_url must be empty when public_url is not configured")
|
|
assert.Equal(t, "", resp["preview_url"].(string), "preview_url must be empty when public_url is not configured")
|
|
assert.Equal(t, "test@example.com", resp["email"].(string))
|
|
}
|
|
|
|
func TestUserHandler_PreviewInviteURL_Success_Configured(t *testing.T) {
|
|
handler, db := setupUserHandlerWithProxyHosts(t)
|
|
|
|
// Create public_url setting
|
|
publicURLSetting := &models.Setting{
|
|
Key: "app.public_url",
|
|
Value: "https://charon.example.com",
|
|
Type: "string",
|
|
Category: "app",
|
|
}
|
|
db.Create(publicURLSetting)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Next()
|
|
})
|
|
r.POST("/users/preview-invite-url", handler.PreviewInviteURL)
|
|
|
|
body := map[string]string{"email": "test@example.com"}
|
|
jsonBody, _ := json.Marshal(body)
|
|
req := httptest.NewRequest("POST", "/users/preview-invite-url", bytes.NewBuffer(jsonBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var resp map[string]any
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err, "Failed to unmarshal response")
|
|
|
|
assert.Equal(t, true, resp["is_configured"].(bool))
|
|
assert.Equal(t, false, resp["warning"].(bool))
|
|
assert.Contains(t, resp["preview_url"].(string), "https://charon.example.com")
|
|
assert.Contains(t, resp["preview_url"].(string), "SAMPLE_TOKEN_PREVIEW")
|
|
assert.Equal(t, "https://charon.example.com", resp["base_url"].(string))
|
|
assert.Equal(t, "test@example.com", resp["email"].(string))
|
|
}
|
|
|
|
// getAppName Tests
|
|
func TestGetAppName_Default(t *testing.T) {
|
|
_, db := setupUserHandlerWithProxyHosts(t)
|
|
|
|
appName := getAppName(db)
|
|
|
|
assert.Equal(t, "Charon", appName)
|
|
}
|
|
|
|
func TestGetAppName_FromSettings(t *testing.T) {
|
|
_, db := setupUserHandlerWithProxyHosts(t)
|
|
|
|
// Create app_name setting
|
|
appNameSetting := &models.Setting{
|
|
Key: "app_name",
|
|
Value: "MyCustomApp",
|
|
Type: "string",
|
|
Category: "app",
|
|
}
|
|
db.Create(appNameSetting)
|
|
|
|
appName := getAppName(db)
|
|
|
|
assert.Equal(t, "MyCustomApp", appName)
|
|
}
|
|
|
|
func TestGetAppName_EmptyValue(t *testing.T) {
|
|
_, db := setupUserHandlerWithProxyHosts(t)
|
|
|
|
// Create app_name setting with empty value
|
|
appNameSetting := &models.Setting{
|
|
Key: "app_name",
|
|
Value: "",
|
|
Type: "string",
|
|
Category: "app",
|
|
}
|
|
db.Create(appNameSetting)
|
|
|
|
appName := getAppName(db)
|
|
|
|
// Should return default when value is empty
|
|
assert.Equal(t, "Charon", appName)
|
|
}
|
|
|
|
// ============= Priority 2: Error Paths =============
|
|
|
|
func TestUserHandler_UpdateUser_EmailConflict(t *testing.T) {
|
|
handler, db := setupUserHandlerWithProxyHosts(t)
|
|
|
|
// Create two users
|
|
user1 := &models.User{
|
|
UUID: uuid.NewString(),
|
|
APIKey: uuid.NewString(),
|
|
Email: "user1@example.com",
|
|
Name: "User 1",
|
|
}
|
|
user2 := &models.User{
|
|
UUID: uuid.NewString(),
|
|
APIKey: uuid.NewString(),
|
|
Email: "user2@example.com",
|
|
Name: "User 2",
|
|
}
|
|
db.Create(user1)
|
|
db.Create(user2)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Set("userID", uint(11))
|
|
c.Next()
|
|
})
|
|
r.PUT("/users/:id", handler.UpdateUser)
|
|
|
|
// Try to update user1's email to user2's email
|
|
body := map[string]string{
|
|
"email": "user2@example.com",
|
|
}
|
|
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.StatusConflict, w.Code)
|
|
assert.Contains(t, w.Body.String(), "Email already in use")
|
|
}
|
|
|
|
// ============= Priority 3: Edge Cases and Defaults =============
|
|
|
|
func TestUserHandler_CreateUser_EmailNormalization(t *testing.T) {
|
|
handler, db := setupUserHandlerWithProxyHosts(t)
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Next()
|
|
})
|
|
r.POST("/users", handler.CreateUser)
|
|
|
|
// Create user with mixed-case email
|
|
body := map[string]any{
|
|
"email": "User@Example.COM",
|
|
"name": "Test 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)
|
|
|
|
// Verify email is stored lowercase
|
|
var user models.User
|
|
db.Where("email = ?", "user@example.com").First(&user)
|
|
assert.Equal(t, "user@example.com", user.Email)
|
|
}
|
|
|
|
func TestUserHandler_InviteUser_EmailNormalization(t *testing.T) {
|
|
handler, db := setupUserHandlerWithProxyHosts(t)
|
|
|
|
// Create admin user
|
|
admin := &models.User{
|
|
UUID: uuid.NewString(),
|
|
APIKey: uuid.NewString(),
|
|
Email: "admin@example.com",
|
|
Role: models.RoleAdmin,
|
|
}
|
|
db.Create(admin)
|
|
|
|
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)
|
|
|
|
// Invite user with mixed-case email
|
|
body := map[string]any{
|
|
"email": "Invite@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.StatusCreated, w.Code)
|
|
|
|
// Verify email is stored lowercase
|
|
var user models.User
|
|
db.Where("email = ?", "invite@example.com").First(&user)
|
|
assert.Equal(t, "invite@example.com", user.Email)
|
|
}
|
|
|
|
func TestUserHandler_CreateUser_DefaultPermissionMode(t *testing.T) {
|
|
handler, db := setupUserHandlerWithProxyHosts(t)
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Next()
|
|
})
|
|
r.POST("/users", handler.CreateUser)
|
|
|
|
// Create user without specifying permission_mode
|
|
body := map[string]any{
|
|
"email": "defaultperms@example.com",
|
|
"name": "Default Perms 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)
|
|
|
|
// Verify permission_mode defaults to "allow_all"
|
|
var user models.User
|
|
db.Where("email = ?", "defaultperms@example.com").First(&user)
|
|
assert.Equal(t, models.PermissionModeAllowAll, user.PermissionMode)
|
|
}
|
|
|
|
func TestUserHandler_InviteUser_DefaultPermissionMode(t *testing.T) {
|
|
handler, db := setupUserHandlerWithProxyHosts(t)
|
|
|
|
// Create admin user
|
|
admin := &models.User{
|
|
UUID: uuid.NewString(),
|
|
APIKey: uuid.NewString(),
|
|
Email: "admin@example.com",
|
|
Role: models.RoleAdmin,
|
|
}
|
|
db.Create(admin)
|
|
|
|
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)
|
|
|
|
// Invite user without specifying permission_mode
|
|
body := map[string]any{
|
|
"email": "defaultinvite@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.StatusCreated, w.Code)
|
|
|
|
// Verify permission_mode defaults to "allow_all"
|
|
var user models.User
|
|
db.Where("email = ?", "defaultinvite@example.com").First(&user)
|
|
assert.Equal(t, models.PermissionModeAllowAll, user.PermissionMode)
|
|
}
|
|
|
|
func TestUserHandler_CreateUser_DefaultRole(t *testing.T) {
|
|
handler, db := setupUserHandlerWithProxyHosts(t)
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Next()
|
|
})
|
|
r.POST("/users", handler.CreateUser)
|
|
|
|
// Create user without specifying role
|
|
body := map[string]any{
|
|
"email": "defaultrole@example.com",
|
|
"name": "Default Role 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)
|
|
|
|
// Verify role defaults to "user"
|
|
var user models.User
|
|
db.Where("email = ?", "defaultrole@example.com").First(&user)
|
|
assert.Equal(t, models.RoleUser, user.Role)
|
|
}
|
|
|
|
func TestUserHandler_InviteUser_DefaultRole(t *testing.T) {
|
|
handler, db := setupUserHandlerWithProxyHosts(t)
|
|
|
|
// Create admin user
|
|
admin := &models.User{
|
|
UUID: uuid.NewString(),
|
|
APIKey: uuid.NewString(),
|
|
Email: "admin@example.com",
|
|
Role: models.RoleAdmin,
|
|
}
|
|
db.Create(admin)
|
|
|
|
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)
|
|
|
|
// Invite user without specifying role
|
|
body := map[string]any{
|
|
"email": "defaultroleinvite@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.StatusCreated, w.Code)
|
|
|
|
// Verify role defaults to "user"
|
|
var user models.User
|
|
db.Where("email = ?", "defaultroleinvite@example.com").First(&user)
|
|
assert.Equal(t, models.RoleUser, user.Role)
|
|
}
|
|
|
|
// TestUserHandler_PreviewInviteURL_Unconfigured_DoesNotUseRequestHost verifies that
|
|
// when app.public_url is not configured, the preview does NOT use request Host header.
|
|
// This prevents host header injection attacks (CodeQL go/email-injection remediation).
|
|
func TestUserHandler_PreviewInviteURL_Unconfigured_DoesNotUseRequestHost(t *testing.T) {
|
|
handler, _ := setupUserHandlerWithProxyHosts(t)
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Next()
|
|
})
|
|
r.POST("/users/preview-invite-url", handler.PreviewInviteURL)
|
|
|
|
body := map[string]string{"email": "test@example.com"}
|
|
jsonBody, _ := json.Marshal(body)
|
|
req := httptest.NewRequest("POST", "/users/preview-invite-url", bytes.NewBuffer(jsonBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
// Set malicious Host and X-Forwarded-Proto headers
|
|
req.Host = "evil.example.com"
|
|
req.Header.Set("X-Forwarded-Proto", "https")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
var resp map[string]any
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err, "Failed to unmarshal response")
|
|
|
|
// Response must NOT contain the malicious host
|
|
responseJSON := w.Body.String()
|
|
assert.NotContains(t, responseJSON, "evil.example.com", "Malicious Host header must not appear in response")
|
|
// Verify base_url and preview_url are empty
|
|
assert.Equal(t, "", resp["base_url"].(string))
|
|
assert.Equal(t, "", resp["preview_url"].(string))
|
|
}
|
|
|
|
// ============= Priority 4: Integration Edge Cases =============
|
|
|
|
func TestUserHandler_CreateUser_EmptyPermittedHosts(t *testing.T) {
|
|
handler, db := setupUserHandlerWithProxyHosts(t)
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Next()
|
|
})
|
|
r.POST("/users", handler.CreateUser)
|
|
|
|
// Create user with deny_all mode but empty permitted_hosts
|
|
body := map[string]any{
|
|
"email": "emptyhosts@example.com",
|
|
"name": "Empty Hosts User",
|
|
"password": "password123",
|
|
"permission_mode": "deny_all",
|
|
"permitted_hosts": []uint{},
|
|
}
|
|
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)
|
|
|
|
// Verify user was created with deny_all mode and no permitted hosts
|
|
var user models.User
|
|
db.Preload("PermittedHosts").Where("email = ?", "emptyhosts@example.com").First(&user)
|
|
assert.Equal(t, models.PermissionModeDenyAll, user.PermissionMode)
|
|
assert.Len(t, user.PermittedHosts, 0)
|
|
}
|
|
|
|
func TestUserHandler_CreateUser_NonExistentPermittedHosts(t *testing.T) {
|
|
handler, db := setupUserHandlerWithProxyHosts(t)
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Next()
|
|
})
|
|
r.POST("/users", handler.CreateUser)
|
|
|
|
// Create user with non-existent host IDs
|
|
body := map[string]any{
|
|
"email": "nonexistenthosts@example.com",
|
|
"name": "Non-Existent Hosts User",
|
|
"password": "password123",
|
|
"permission_mode": "deny_all",
|
|
"permitted_hosts": []uint{999, 1000},
|
|
}
|
|
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)
|
|
|
|
// Verify user was created but no hosts were associated (non-existent IDs are ignored)
|
|
var user models.User
|
|
db.Preload("PermittedHosts").Where("email = ?", "nonexistenthosts@example.com").First(&user)
|
|
assert.Len(t, user.PermittedHosts, 0)
|
|
}
|
|
|
|
// ============= ResendInvite Tests =============
|
|
|
|
func TestResendInvite_NonAdmin(t *testing.T) {
|
|
handler, _ := setupUserHandlerWithProxyHosts(t)
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "user")
|
|
c.Next()
|
|
})
|
|
r.POST("/users/:id/resend-invite", handler.ResendInvite)
|
|
|
|
req := httptest.NewRequest("POST", "/users/1/resend-invite", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusForbidden, w.Code)
|
|
assert.Contains(t, w.Body.String(), "admin privileges required")
|
|
}
|
|
|
|
func TestResendInvite_InvalidID(t *testing.T) {
|
|
handler, _ := setupUserHandlerWithProxyHosts(t)
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Next()
|
|
})
|
|
r.POST("/users/:id/resend-invite", handler.ResendInvite)
|
|
|
|
req := httptest.NewRequest("POST", "/users/invalid/resend-invite", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
assert.Contains(t, w.Body.String(), "Invalid user ID")
|
|
}
|
|
|
|
func TestResendInvite_UserNotFound(t *testing.T) {
|
|
handler, _ := setupUserHandlerWithProxyHosts(t)
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Next()
|
|
})
|
|
r.POST("/users/:id/resend-invite", handler.ResendInvite)
|
|
|
|
req := httptest.NewRequest("POST", "/users/999/resend-invite", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
assert.Contains(t, w.Body.String(), "User not found")
|
|
}
|
|
|
|
func TestResendInvite_UserNotPending(t *testing.T) {
|
|
handler, db := setupUserHandlerWithProxyHosts(t)
|
|
|
|
// Create user with accepted invite (not pending)
|
|
user := &models.User{
|
|
UUID: uuid.NewString(),
|
|
APIKey: uuid.NewString(),
|
|
Email: "accepted-user@example.com",
|
|
Name: "Accepted User",
|
|
InviteStatus: "accepted",
|
|
Enabled: true,
|
|
}
|
|
db.Create(user)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Next()
|
|
})
|
|
r.POST("/users/:id/resend-invite", handler.ResendInvite)
|
|
|
|
req := httptest.NewRequest("POST", "/users/"+strconv.FormatUint(uint64(user.ID), 10)+"/resend-invite", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
assert.Contains(t, w.Body.String(), "does not have a pending invite")
|
|
}
|
|
|
|
func TestResendInvite_Success(t *testing.T) {
|
|
handler, db := setupUserHandlerWithProxyHosts(t)
|
|
|
|
// Create user with pending invite
|
|
expires := time.Now().Add(24 * time.Hour)
|
|
user := &models.User{
|
|
UUID: uuid.NewString(),
|
|
APIKey: uuid.NewString(),
|
|
Email: "pending-user@example.com",
|
|
Name: "Pending User",
|
|
InviteStatus: "pending",
|
|
InviteToken: "oldtoken123",
|
|
InviteExpires: &expires,
|
|
Enabled: false,
|
|
}
|
|
db.Create(user)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Next()
|
|
})
|
|
r.POST("/users/:id/resend-invite", handler.ResendInvite)
|
|
|
|
req := httptest.NewRequest("POST", "/users/"+strconv.FormatUint(uint64(user.ID), 10)+"/resend-invite", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp map[string]any
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err, "Failed to unmarshal response")
|
|
assert.Equal(t, "********", resp["invite_token_masked"])
|
|
assert.Equal(t, "pending-user@example.com", resp["email"])
|
|
assert.Equal(t, false, resp["email_sent"].(bool)) // No SMTP configured
|
|
|
|
// Verify token was updated in DB
|
|
var updatedUser models.User
|
|
db.First(&updatedUser, user.ID)
|
|
assert.NotEqual(t, "oldtoken123", updatedUser.InviteToken)
|
|
assert.NotEmpty(t, updatedUser.InviteToken)
|
|
}
|
|
|
|
func TestResendInvite_WithExpiredInvite(t *testing.T) {
|
|
handler, db := setupUserHandlerWithProxyHosts(t)
|
|
|
|
// Create user with expired pending invite
|
|
expired := time.Now().Add(-24 * time.Hour)
|
|
user := &models.User{
|
|
UUID: uuid.NewString(),
|
|
APIKey: uuid.NewString(),
|
|
Email: "expired-pending@example.com",
|
|
Name: "Expired Pending User",
|
|
InviteStatus: "pending",
|
|
InviteToken: "expiredtoken",
|
|
InviteExpires: &expired,
|
|
Enabled: false,
|
|
}
|
|
db.Create(user)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Next()
|
|
})
|
|
r.POST("/users/:id/resend-invite", handler.ResendInvite)
|
|
|
|
req := httptest.NewRequest("POST", "/users/"+strconv.FormatUint(uint64(user.ID), 10)+"/resend-invite", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Should succeed - resend should work even if previous invite expired
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var resp map[string]any
|
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
|
require.NoError(t, err, "Failed to unmarshal response")
|
|
assert.Equal(t, "********", resp["invite_token_masked"])
|
|
|
|
// Verify new expiration is in the future
|
|
var updatedUser models.User
|
|
db.First(&updatedUser, user.ID)
|
|
assert.True(t, updatedUser.InviteExpires.After(time.Now()))
|
|
}
|
|
|
|
// ===== Additional coverage for uncovered utility functions =====
|
|
|
|
func TestIsSetupConflictError(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
expected bool
|
|
}{
|
|
{"nil error", nil, false},
|
|
{"unique constraint failed", errors.New("UNIQUE constraint failed: users.email"), true},
|
|
{"duplicate key", errors.New("duplicate key value violates unique constraint"), true},
|
|
{"database is locked", errors.New("database is locked"), true},
|
|
{"database table is locked", errors.New("database table is locked"), true},
|
|
{"case insensitive", errors.New("UNIQUE CONSTRAINT FAILED"), true},
|
|
{"unrelated error", errors.New("connection refused"), false},
|
|
{"empty error", errors.New(""), false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := isSetupConflictError(tt.err)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMaskSecretForResponse(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{"non-empty secret", "my-secret-key", "********"},
|
|
{"empty string", "", ""},
|
|
{"whitespace only", " ", ""},
|
|
{"single char", "x", "********"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := maskSecretForResponse(tt.input)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRedactInviteURL(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{"non-empty url", "https://example.com/invite/abc123", "[REDACTED]"},
|
|
{"empty string", "", ""},
|
|
{"whitespace only", " ", ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := redactInviteURL(tt.input)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Passthrough rejection tests ---
|
|
|
|
func setupPassthroughRouter(handler *UserHandler) *gin.Engine {
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", string(models.RolePassthrough))
|
|
c.Next()
|
|
})
|
|
r.POST("/api-key", handler.RegenerateAPIKey)
|
|
r.GET("/profile", handler.GetProfile)
|
|
r.PUT("/profile", handler.UpdateProfile)
|
|
return r
|
|
}
|
|
|
|
func TestUserHandler_RegenerateAPIKey_PassthroughRejected(t *testing.T) {
|
|
handler, _ := setupUserHandler(t)
|
|
r := setupPassthroughRouter(handler)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api-key", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusForbidden, w.Code)
|
|
assert.Contains(t, w.Body.String(), "Passthrough users cannot manage API keys")
|
|
}
|
|
|
|
func TestUserHandler_GetProfile_PassthroughRejected(t *testing.T) {
|
|
handler, _ := setupUserHandler(t)
|
|
r := setupPassthroughRouter(handler)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/profile", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusForbidden, w.Code)
|
|
assert.Contains(t, w.Body.String(), "Passthrough users cannot access profile")
|
|
}
|
|
|
|
func TestUserHandler_UpdateProfile_PassthroughRejected(t *testing.T) {
|
|
handler, _ := setupUserHandler(t)
|
|
r := setupPassthroughRouter(handler)
|
|
|
|
body, _ := json.Marshal(map[string]string{"name": "Test"})
|
|
req := httptest.NewRequest(http.MethodPut, "/profile", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusForbidden, w.Code)
|
|
assert.Contains(t, w.Body.String(), "Passthrough users cannot update profile")
|
|
}
|
|
|
|
// --- CreateUser / InviteUser invalid role ---
|
|
|
|
func TestUserHandler_CreateUser_InvalidRole(t *testing.T) {
|
|
handler, _ := setupUserHandler(t)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Next()
|
|
})
|
|
r.POST("/users", handler.CreateUser)
|
|
|
|
body, _ := json.Marshal(map[string]string{
|
|
"name": "Test User",
|
|
"email": "new@example.com",
|
|
"role": "superadmin",
|
|
"password": "password123",
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
assert.Contains(t, w.Body.String(), "Invalid role")
|
|
}
|
|
|
|
func TestUserHandler_InviteUser_InvalidRole(t *testing.T) {
|
|
handler, _ := setupUserHandler(t)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Set("userID", uint(1))
|
|
c.Next()
|
|
})
|
|
r.POST("/invite", handler.InviteUser)
|
|
|
|
body, _ := json.Marshal(map[string]string{
|
|
"email": "invite@example.com",
|
|
"role": "superadmin",
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/invite", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
assert.Contains(t, w.Body.String(), "Invalid role")
|
|
}
|
|
|
|
// --- UpdateUser authentication/session edge cases ---
|
|
|
|
func TestUserHandler_UpdateUser_MissingUserID(t *testing.T) {
|
|
handler, db := setupUserHandler(t)
|
|
|
|
user := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target@example.com", Role: models.RoleUser, Enabled: true}
|
|
require.NoError(t, db.Create(&user).Error)
|
|
|
|
r := gin.New()
|
|
// No userID set in context
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Next()
|
|
})
|
|
r.PUT("/users/:id", handler.UpdateUser)
|
|
|
|
body, _ := json.Marshal(map[string]string{"name": "New Name"})
|
|
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", user.ID), bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
assert.Contains(t, w.Body.String(), "Authentication required")
|
|
}
|
|
|
|
func TestUserHandler_UpdateUser_InvalidSessionType(t *testing.T) {
|
|
handler, db := setupUserHandler(t)
|
|
|
|
user := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target2@example.com", Role: models.RoleUser, Enabled: true}
|
|
require.NoError(t, db.Create(&user).Error)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Set("userID", "not-a-uint") // wrong type
|
|
c.Next()
|
|
})
|
|
r.PUT("/users/:id", handler.UpdateUser)
|
|
|
|
body, _ := json.Marshal(map[string]string{"name": "New Name"})
|
|
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", user.ID), bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
assert.Contains(t, w.Body.String(), "Invalid session")
|
|
}
|
|
|
|
// --- UpdateUser role/enabled restriction for non-admin self ---
|
|
|
|
func TestUserHandler_UpdateUser_NonAdminSelfRoleChange(t *testing.T) {
|
|
handler, db := setupUserHandler(t)
|
|
|
|
user := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "self@example.com", Role: models.RoleUser, Enabled: true}
|
|
require.NoError(t, db.Create(&user).Error)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "user") // non-admin
|
|
c.Set("userID", user.ID) // isSelf = true
|
|
c.Next()
|
|
})
|
|
r.PUT("/users/:id", handler.UpdateUser)
|
|
|
|
body, _ := json.Marshal(map[string]string{"role": "admin"})
|
|
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", user.ID), bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusForbidden, w.Code)
|
|
assert.Contains(t, w.Body.String(), "Cannot modify role or enabled status")
|
|
}
|
|
|
|
// --- UpdateUser invalid role string ---
|
|
|
|
func TestUserHandler_UpdateUser_InvalidRole(t *testing.T) {
|
|
handler, db := setupUserHandler(t)
|
|
|
|
target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target3@example.com", Role: models.RoleUser, Enabled: true}
|
|
require.NoError(t, db.Create(&target).Error)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Set("userID", uint(9999)) // not the target
|
|
c.Next()
|
|
})
|
|
r.PUT("/users/:id", handler.UpdateUser)
|
|
|
|
body, _ := json.Marshal(map[string]string{"role": "superadmin"})
|
|
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
assert.Contains(t, w.Body.String(), "Invalid role")
|
|
}
|
|
|
|
// --- UpdateUser self-demotion and self-disable ---
|
|
|
|
func TestUserHandler_UpdateUser_SelfDemotion(t *testing.T) {
|
|
handler, db := setupUserHandler(t)
|
|
|
|
admin := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "admin@self.example.com", Role: models.RoleAdmin, Enabled: true}
|
|
require.NoError(t, db.Create(&admin).Error)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Set("userID", admin.ID) // isSelf = true
|
|
c.Next()
|
|
})
|
|
r.PUT("/users/:id", handler.UpdateUser)
|
|
|
|
body, _ := json.Marshal(map[string]string{"role": "user"})
|
|
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", admin.ID), bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusForbidden, w.Code)
|
|
assert.Contains(t, w.Body.String(), "Cannot change your own role")
|
|
}
|
|
|
|
func TestUserHandler_UpdateUser_SelfDisable(t *testing.T) {
|
|
handler, db := setupUserHandler(t)
|
|
|
|
admin := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "admin@disable.example.com", Role: models.RoleAdmin, Enabled: true}
|
|
require.NoError(t, db.Create(&admin).Error)
|
|
|
|
disabled := false
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Set("userID", admin.ID) // isSelf = true
|
|
c.Next()
|
|
})
|
|
r.PUT("/users/:id", handler.UpdateUser)
|
|
|
|
body, _ := json.Marshal(map[string]interface{}{"enabled": disabled})
|
|
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", admin.ID), bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusForbidden, w.Code)
|
|
assert.Contains(t, w.Body.String(), "Cannot disable your own account")
|
|
}
|
|
|
|
// --- UpdateUser last-admin protection ---
|
|
|
|
func TestUserHandler_UpdateUser_LastAdminDemotion(t *testing.T) {
|
|
handler, db := setupUserHandler(t)
|
|
|
|
// Only one admin in the DB (the target)
|
|
target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "last-admin@example.com", Role: models.RoleAdmin, Enabled: true}
|
|
require.NoError(t, db.Create(&target).Error)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Set("userID", uint(9999)) // different from target; not in DB but role injected via context
|
|
c.Next()
|
|
})
|
|
r.PUT("/users/:id", handler.UpdateUser)
|
|
|
|
body, _ := json.Marshal(map[string]string{"role": "user"})
|
|
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusForbidden, w.Code)
|
|
assert.Contains(t, w.Body.String(), "Cannot demote the last admin")
|
|
}
|
|
|
|
func TestUserHandler_UpdateUser_LastAdminDisable(t *testing.T) {
|
|
handler, db := setupUserHandler(t)
|
|
|
|
target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "last-admin-disable@example.com", Role: models.RoleAdmin, Enabled: true}
|
|
require.NoError(t, db.Create(&target).Error)
|
|
|
|
disabled := false
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Set("userID", uint(9999))
|
|
c.Next()
|
|
})
|
|
r.PUT("/users/:id", handler.UpdateUser)
|
|
|
|
body, _ := json.Marshal(map[string]interface{}{"enabled": disabled})
|
|
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusForbidden, w.Code)
|
|
assert.Contains(t, w.Body.String(), "Cannot disable the last admin")
|
|
}
|
|
|
|
// --- UpdateUser session invalidation ---
|
|
|
|
func TestUserHandler_UpdateUser_WithSessionInvalidation(t *testing.T) {
|
|
db := OpenTestDB(t)
|
|
_ = db.AutoMigrate(&models.User{}, &models.Setting{}, &models.SecurityAudit{})
|
|
|
|
authSvc := services.NewAuthService(db, config.Config{JWTSecret: "test-secret"})
|
|
|
|
caller := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "caller-si@example.com", Role: models.RoleAdmin, Enabled: true}
|
|
target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target-si@example.com", Role: models.RoleUser, Enabled: true}
|
|
require.NoError(t, db.Create(&caller).Error)
|
|
require.NoError(t, db.Create(&target).Error)
|
|
|
|
handler := NewUserHandler(db, authSvc)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Set("userID", caller.ID)
|
|
c.Next()
|
|
})
|
|
r.PUT("/users/:id", handler.UpdateUser)
|
|
|
|
body, _ := json.Marshal(map[string]string{"role": "passthrough"})
|
|
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
assert.Contains(t, w.Body.String(), "User updated successfully")
|
|
|
|
var updated models.User
|
|
require.NoError(t, db.First(&updated, target.ID).Error)
|
|
assert.Greater(t, updated.SessionVersion, uint(0))
|
|
}
|
|
|
|
func TestUserHandler_UpdateUser_SessionInvalidationError(t *testing.T) {
|
|
mainDB := OpenTestDB(t)
|
|
_ = mainDB.AutoMigrate(&models.User{}, &models.Setting{}, &models.SecurityAudit{})
|
|
|
|
// Use a separate empty DB so InvalidateSessions cannot find the user
|
|
authDB := OpenTestDB(t)
|
|
authSvc := services.NewAuthService(authDB, config.Config{JWTSecret: "test-secret"})
|
|
|
|
target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target-sie@example.com", Role: models.RoleUser, Enabled: true}
|
|
require.NoError(t, mainDB.Create(&target).Error)
|
|
|
|
handler := NewUserHandler(mainDB, authSvc)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Set("userID", uint(9999))
|
|
c.Next()
|
|
})
|
|
r.PUT("/users/:id", handler.UpdateUser)
|
|
|
|
body, _ := json.Marshal(map[string]string{"role": "passthrough"})
|
|
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
assert.Contains(t, w.Body.String(), "Failed to invalidate sessions")
|
|
}
|