2026-04-07 12:08:16 +08:00
|
|
|
|
package handler_test
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"io"
|
2026-04-11 20:05:40 +08:00
|
|
|
|
"mime/multipart"
|
2026-04-07 12:08:16 +08:00
|
|
|
|
"net/http"
|
2026-05-28 15:19:13 +08:00
|
|
|
|
"net/http/cookiejar"
|
2026-04-07 12:08:16 +08:00
|
|
|
|
"net/http/httptest"
|
2026-05-28 15:19:13 +08:00
|
|
|
|
"os"
|
2026-04-07 12:08:16 +08:00
|
|
|
|
"sync"
|
|
|
|
|
|
"sync/atomic"
|
|
|
|
|
|
"testing"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
|
|
"github.com/user-management-system/internal/api/handler"
|
|
|
|
|
|
"github.com/user-management-system/internal/api/middleware"
|
|
|
|
|
|
"github.com/user-management-system/internal/api/router"
|
|
|
|
|
|
"github.com/user-management-system/internal/auth"
|
|
|
|
|
|
"github.com/user-management-system/internal/cache"
|
|
|
|
|
|
"github.com/user-management-system/internal/config"
|
test: add comprehensive test coverage and improve code quality
- Add new test files for auth, service, and handler modules
- Improve test organization and coverage
- Refactor code for better maintainability
- Add captcha, settings, stats, and theme handler tests
- Add auth module tests (CAS, OAuth, password, SSO, state)
- Add service layer tests for auth, export, permissions, roles
- All Go tests pass (exit code 0)
- All frontend tests pass (325 tests in 59 files)
2026-04-17 20:43:50 +08:00
|
|
|
|
"github.com/user-management-system/internal/domain"
|
2026-04-07 12:08:16 +08:00
|
|
|
|
"github.com/user-management-system/internal/repository"
|
|
|
|
|
|
"github.com/user-management-system/internal/service"
|
|
|
|
|
|
gormsqlite "gorm.io/driver/sqlite"
|
|
|
|
|
|
"gorm.io/gorm"
|
|
|
|
|
|
"gorm.io/gorm/logger"
|
|
|
|
|
|
_ "modernc.org/sqlite"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
var handlerDbCounter int64
|
|
|
|
|
|
|
|
|
|
|
|
func setupHandlerTestServer(t *testing.T) (*httptest.Server, func()) {
|
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
|
2026-05-28 15:19:13 +08:00
|
|
|
|
previousBootstrapSecret, hadBootstrapSecret := os.LookupEnv("BOOTSTRAP_SECRET")
|
|
|
|
|
|
if err := os.Setenv("BOOTSTRAP_SECRET", "test-bootstrap-secret"); err != nil {
|
|
|
|
|
|
t.Fatalf("set bootstrap secret failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 12:08:16 +08:00
|
|
|
|
id := atomic.AddInt64(&handlerDbCounter, 1)
|
|
|
|
|
|
dsn := fmt.Sprintf("file:handlerdb_%d_%s?mode=memory&cache=shared", id, t.Name())
|
|
|
|
|
|
db, err := gorm.Open(gormsqlite.New(gormsqlite.Config{
|
|
|
|
|
|
DriverName: "sqlite",
|
|
|
|
|
|
DSN: dsn,
|
|
|
|
|
|
}), &gorm.Config{
|
|
|
|
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Skipf("skipping handler test (SQLite unavailable): %v", err)
|
|
|
|
|
|
return nil, func() {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err := db.AutoMigrate(
|
|
|
|
|
|
&domain.User{},
|
|
|
|
|
|
&domain.Role{},
|
|
|
|
|
|
&domain.Permission{},
|
|
|
|
|
|
&domain.UserRole{},
|
|
|
|
|
|
&domain.RolePermission{},
|
|
|
|
|
|
&domain.Device{},
|
|
|
|
|
|
&domain.LoginLog{},
|
|
|
|
|
|
&domain.OperationLog{},
|
|
|
|
|
|
&domain.SocialAccount{},
|
|
|
|
|
|
&domain.Webhook{},
|
|
|
|
|
|
&domain.WebhookDelivery{},
|
|
|
|
|
|
); err != nil {
|
|
|
|
|
|
t.Fatalf("db migration failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 15:19:13 +08:00
|
|
|
|
adminRole := &domain.Role{Code: "admin", Name: "管理员", Status: domain.RoleStatusEnabled}
|
|
|
|
|
|
if err := db.Create(adminRole).Error; err != nil {
|
|
|
|
|
|
t.Fatalf("seed admin role failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, permission := range domain.DefaultPermissions() {
|
|
|
|
|
|
perm := permission
|
|
|
|
|
|
if err := db.Create(&perm).Error; err != nil {
|
|
|
|
|
|
t.Fatalf("seed permission %s failed: %v", perm.Code, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := db.Create(&domain.RolePermission{RoleID: adminRole.ID, PermissionID: perm.ID}).Error; err != nil {
|
|
|
|
|
|
t.Fatalf("seed role permission %s failed: %v", perm.Code, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 12:08:16 +08:00
|
|
|
|
jwtManager, err := auth.NewJWTWithOptions(auth.JWTOptions{
|
|
|
|
|
|
HS256Secret: "test-handler-secret-key",
|
|
|
|
|
|
AccessTokenExpire: 15 * time.Minute,
|
|
|
|
|
|
RefreshTokenExpire: 7 * 24 * time.Hour,
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("create jwt manager failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
l1Cache := cache.NewL1Cache()
|
|
|
|
|
|
l2Cache := cache.NewRedisCache(false)
|
|
|
|
|
|
cacheManager := cache.NewCacheManager(l1Cache, l2Cache)
|
|
|
|
|
|
|
|
|
|
|
|
userRepo := repository.NewUserRepository(db)
|
|
|
|
|
|
roleRepo := repository.NewRoleRepository(db)
|
|
|
|
|
|
permissionRepo := repository.NewPermissionRepository(db)
|
|
|
|
|
|
userRoleRepo := repository.NewUserRoleRepository(db)
|
|
|
|
|
|
rolePermissionRepo := repository.NewRolePermissionRepository(db)
|
|
|
|
|
|
deviceRepo := repository.NewDeviceRepository(db)
|
|
|
|
|
|
loginLogRepo := repository.NewLoginLogRepository(db)
|
|
|
|
|
|
opLogRepo := repository.NewOperationLogRepository(db)
|
|
|
|
|
|
passwordHistoryRepo := repository.NewPasswordHistoryRepository(db)
|
|
|
|
|
|
|
|
|
|
|
|
authSvc := service.NewAuthService(userRepo, nil, jwtManager, cacheManager, 8, 5, 15*time.Minute)
|
|
|
|
|
|
authSvc.SetRoleRepositories(userRoleRepo, roleRepo)
|
|
|
|
|
|
smsCodeSvc := service.NewSMSCodeService(&service.MockSMSProvider{}, cacheManager, service.DefaultSMSCodeConfig())
|
|
|
|
|
|
authSvc.SetSMSCodeService(smsCodeSvc)
|
|
|
|
|
|
userSvc := service.NewUserService(userRepo, userRoleRepo, roleRepo, passwordHistoryRepo)
|
|
|
|
|
|
roleSvc := service.NewRoleService(roleRepo, rolePermissionRepo)
|
|
|
|
|
|
permSvc := service.NewPermissionService(permissionRepo)
|
|
|
|
|
|
deviceSvc := service.NewDeviceService(deviceRepo, userRepo)
|
|
|
|
|
|
loginLogSvc := service.NewLoginLogService(loginLogRepo)
|
|
|
|
|
|
opLogSvc := service.NewOperationLogService(opLogRepo)
|
2026-05-28 17:28:08 +08:00
|
|
|
|
webhookSvc := service.NewWebhookService(db)
|
2026-04-07 12:08:16 +08:00
|
|
|
|
captchaSvc := service.NewCaptchaService(cacheManager)
|
|
|
|
|
|
totpSvc := service.NewTOTPService(userRepo)
|
|
|
|
|
|
pwdResetCfg := service.DefaultPasswordResetConfig()
|
|
|
|
|
|
pwdResetSvc := service.NewPasswordResetService(userRepo, cacheManager, pwdResetCfg).
|
|
|
|
|
|
WithPasswordHistoryRepo(passwordHistoryRepo)
|
|
|
|
|
|
themeRepo := repository.NewThemeConfigRepository(db)
|
|
|
|
|
|
themeSvc := service.NewThemeService(themeRepo)
|
2026-04-11 20:05:40 +08:00
|
|
|
|
avatarH := handler.NewAvatarHandler(userRepo)
|
2026-04-07 12:08:16 +08:00
|
|
|
|
|
|
|
|
|
|
rateLimitCfg := config.RateLimitConfig{}
|
|
|
|
|
|
rateLimitMiddleware := middleware.NewRateLimitMiddleware(rateLimitCfg)
|
|
|
|
|
|
authMiddleware := middleware.NewAuthMiddleware(
|
test: add comprehensive test coverage and improve code quality
- Add new test files for auth, service, and handler modules
- Improve test organization and coverage
- Refactor code for better maintainability
- Add captcha, settings, stats, and theme handler tests
- Add auth module tests (CAS, OAuth, password, SSO, state)
- Add service layer tests for auth, export, permissions, roles
- All Go tests pass (exit code 0)
- All frontend tests pass (325 tests in 59 files)
2026-04-17 20:43:50 +08:00
|
|
|
|
jwtManager, userRepo, userRoleRepo, l1Cache,
|
2026-04-07 12:08:16 +08:00
|
|
|
|
)
|
|
|
|
|
|
authMiddleware.SetCacheManager(cacheManager)
|
|
|
|
|
|
opLogMiddleware := middleware.NewOperationLogMiddleware(opLogRepo)
|
|
|
|
|
|
|
|
|
|
|
|
authHandler := handler.NewAuthHandler(authSvc)
|
|
|
|
|
|
userHandler := handler.NewUserHandler(userSvc)
|
|
|
|
|
|
roleHandler := handler.NewRoleHandler(roleSvc)
|
|
|
|
|
|
permHandler := handler.NewPermissionHandler(permSvc)
|
|
|
|
|
|
deviceHandler := handler.NewDeviceHandler(deviceSvc)
|
|
|
|
|
|
logHandler := handler.NewLogHandler(loginLogSvc, opLogSvc)
|
2026-05-28 17:28:08 +08:00
|
|
|
|
webhookHandler := handler.NewWebhookHandler(webhookSvc)
|
2026-04-07 12:08:16 +08:00
|
|
|
|
captchaHandler := handler.NewCaptchaHandler(captchaSvc)
|
|
|
|
|
|
totpHandler := handler.NewTOTPHandler(authSvc, totpSvc)
|
|
|
|
|
|
pwdResetHandler := handler.NewPasswordResetHandler(pwdResetSvc)
|
|
|
|
|
|
themeHandler := handler.NewThemeHandler(themeSvc)
|
|
|
|
|
|
|
|
|
|
|
|
r := router.NewRouter(
|
|
|
|
|
|
authHandler, userHandler, roleHandler, permHandler, deviceHandler,
|
|
|
|
|
|
logHandler, authMiddleware, rateLimitMiddleware, opLogMiddleware,
|
2026-05-28 17:28:08 +08:00
|
|
|
|
pwdResetHandler, captchaHandler, totpHandler, webhookHandler,
|
2026-04-11 20:05:40 +08:00
|
|
|
|
nil, nil, nil, nil, nil, themeHandler, nil, nil, nil, avatarH,
|
2026-04-07 12:08:16 +08:00
|
|
|
|
)
|
|
|
|
|
|
engine := r.Setup()
|
|
|
|
|
|
|
|
|
|
|
|
server := httptest.NewServer(engine)
|
|
|
|
|
|
return server, func() {
|
|
|
|
|
|
server.Close()
|
2026-05-28 15:19:13 +08:00
|
|
|
|
if hadBootstrapSecret {
|
|
|
|
|
|
_ = os.Setenv("BOOTSTRAP_SECRET", previousBootstrapSecret)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_ = os.Unsetenv("BOOTSTRAP_SECRET")
|
|
|
|
|
|
}
|
2026-04-07 12:08:16 +08:00
|
|
|
|
if sqlDB, _ := db.DB(); sqlDB != nil {
|
|
|
|
|
|
sqlDB.Close()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func doRequest(method, url string, token string, body interface{}) (*http.Response, string) {
|
|
|
|
|
|
var bodyReader io.Reader
|
|
|
|
|
|
if body != nil {
|
|
|
|
|
|
jsonBytes, _ := json.Marshal(body)
|
|
|
|
|
|
bodyReader = bytes.NewReader(jsonBytes)
|
|
|
|
|
|
}
|
|
|
|
|
|
req, _ := http.NewRequest(method, url, bodyReader)
|
|
|
|
|
|
if token != "" {
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
|
}
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
client := &http.Client{}
|
|
|
|
|
|
resp, _ := client.Do(req)
|
|
|
|
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
|
|
|
|
resp.Body.Close()
|
|
|
|
|
|
return resp, string(bodyBytes)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func doGet(url, token string) (*http.Response, string) {
|
|
|
|
|
|
return doRequest("GET", url, token, nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func doPost(url, token string, body interface{}) (*http.Response, string) {
|
|
|
|
|
|
return doRequest("POST", url, token, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func doPut(url, token string, body interface{}) (*http.Response, string) {
|
|
|
|
|
|
return doRequest("PUT", url, token, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func doDelete(url, token string) (*http.Response, string) {
|
|
|
|
|
|
return doRequest("DELETE", url, token, nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func getToken(baseURL, username, password string) string {
|
|
|
|
|
|
resp, body := doPost(baseURL+"/api/v1/auth/login", "", map[string]interface{}{
|
|
|
|
|
|
"account": username,
|
|
|
|
|
|
"password": password,
|
|
|
|
|
|
})
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
var result map[string]interface{}
|
|
|
|
|
|
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
if result["data"] == nil {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
data := result["data"].(map[string]interface{})
|
|
|
|
|
|
if data["access_token"] == nil {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
return data["access_token"].(string)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func registerUser(baseURL, username, email, password string) bool {
|
|
|
|
|
|
resp, _ := doPost(baseURL+"/api/v1/auth/register", "", map[string]interface{}{
|
|
|
|
|
|
"username": username,
|
|
|
|
|
|
"email": email,
|
|
|
|
|
|
"password": password,
|
|
|
|
|
|
})
|
|
|
|
|
|
return resp.StatusCode == http.StatusCreated
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 17:28:08 +08:00
|
|
|
|
func createDeviceAndGetID(t *testing.T, baseURL, token, deviceID string) int64 {
|
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
|
|
|
|
resp, body := doPost(baseURL+"/api/v1/devices", token, map[string]interface{}{
|
|
|
|
|
|
"device_id": deviceID,
|
|
|
|
|
|
"device_name": "Owned Device",
|
|
|
|
|
|
"device_type": 3,
|
|
|
|
|
|
"device_os": "Linux",
|
|
|
|
|
|
"device_browser": "Chrome",
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
|
|
|
|
t.Fatalf("create device failed: status=%d body=%s", resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var result struct {
|
|
|
|
|
|
Data struct {
|
|
|
|
|
|
ID int64 `json:"id"`
|
|
|
|
|
|
} `json:"data"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
|
|
|
|
|
t.Fatalf("decode create device response failed: %v body=%s", err, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
if result.Data.ID == 0 {
|
|
|
|
|
|
t.Fatalf("expected non-zero device id, body=%s", body)
|
|
|
|
|
|
}
|
|
|
|
|
|
return result.Data.ID
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func createWebhookAndGetID(t *testing.T, baseURL, token, name string) int64 {
|
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
|
|
|
|
resp, body := doPost(baseURL+"/api/v1/webhooks", token, map[string]interface{}{
|
|
|
|
|
|
"name": name,
|
|
|
|
|
|
"url": "https://example.com/webhook",
|
|
|
|
|
|
"events": []string{"user.created"},
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
|
|
|
|
t.Fatalf("create webhook failed: status=%d body=%s", resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var result struct {
|
|
|
|
|
|
Data struct {
|
|
|
|
|
|
ID int64 `json:"id"`
|
|
|
|
|
|
} `json:"data"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
|
|
|
|
|
t.Fatalf("decode create webhook response failed: %v body=%s", err, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
if result.Data.ID == 0 {
|
|
|
|
|
|
t.Fatalf("expected non-zero webhook id, body=%s", body)
|
|
|
|
|
|
}
|
|
|
|
|
|
return result.Data.ID
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 15:19:13 +08:00
|
|
|
|
func bootstrapAdminToken(baseURL, username, email, password string) string {
|
|
|
|
|
|
payload, _ := json.Marshal(map[string]interface{}{
|
|
|
|
|
|
"username": username,
|
|
|
|
|
|
"email": email,
|
|
|
|
|
|
"password": password,
|
|
|
|
|
|
})
|
|
|
|
|
|
req, _ := http.NewRequest("POST", baseURL+"/api/v1/auth/bootstrap-admin", bytes.NewReader(payload))
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
req.Header.Set("X-Bootstrap-Secret", "test-bootstrap-secret")
|
|
|
|
|
|
resp, err := (&http.Client{}).Do(req)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
var result map[string]interface{}
|
|
|
|
|
|
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
data, ok := result["data"].(map[string]interface{})
|
|
|
|
|
|
if !ok || data["access_token"] == nil {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
return data["access_token"].(string)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 12:08:16 +08:00
|
|
|
|
// =============================================================================
|
|
|
|
|
|
// Auth Handler Tests
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
func TestAuthHandler_Register_Success(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
ok := registerUser(server.URL, "testuser", "test@example.com", "Password123!")
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatal("registration should succeed")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestAuthHandler_Register_InvalidJSON(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
req, _ := http.NewRequest("POST", server.URL+"/api/v1/auth/register", bytes.NewReader([]byte("invalid json{")))
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
client := &http.Client{}
|
|
|
|
|
|
resp, err := client.Do(req)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("request failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
|
|
|
|
t.Errorf("expected status %d, got %d", http.StatusBadRequest, resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestAuthHandler_Register_MissingPassword(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
resp, body := doPost(server.URL+"/api/v1/auth/register", "", map[string]interface{}{
|
|
|
|
|
|
"username": "nopassword",
|
|
|
|
|
|
"email": "nopass@example.com",
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
|
|
|
|
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestAuthHandler_Register_DuplicateUsername(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "duplicateuser", "test1@example.com", "Password123!")
|
|
|
|
|
|
resp, _ := doPost(server.URL+"/api/v1/auth/register", "", map[string]interface{}{
|
|
|
|
|
|
"username": "duplicateuser",
|
|
|
|
|
|
"email": "test2@example.com",
|
|
|
|
|
|
"password": "Password123!",
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusConflict {
|
|
|
|
|
|
t.Errorf("expected status %d for duplicate username, got %d", http.StatusConflict, resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestAuthHandler_Login_Success(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "loginuser", "login@example.com", "Password123!")
|
|
|
|
|
|
resp, body := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
|
|
|
|
|
|
"account": "loginuser",
|
|
|
|
|
|
"password": "Password123!",
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
|
t.Errorf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var result map[string]interface{}
|
|
|
|
|
|
json.Unmarshal([]byte(body), &result)
|
|
|
|
|
|
if result["data"] == nil {
|
|
|
|
|
|
t.Fatal("response should contain data with access_token")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 15:19:13 +08:00
|
|
|
|
func TestAuthHandler_Login_SetsSessionCookies(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "cookieuser", "cookie@example.com", "Password123!")
|
|
|
|
|
|
resp, body := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
|
|
|
|
|
|
"account": "cookieuser",
|
|
|
|
|
|
"password": "Password123!",
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
|
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cookies := resp.Cookies()
|
|
|
|
|
|
var hasRefreshCookie bool
|
|
|
|
|
|
var hasPresenceCookie bool
|
|
|
|
|
|
for _, cookie := range cookies {
|
|
|
|
|
|
switch cookie.Name {
|
|
|
|
|
|
case "ums_refresh_token":
|
|
|
|
|
|
hasRefreshCookie = cookie.HttpOnly && cookie.Value != ""
|
|
|
|
|
|
case "ums_session_present":
|
|
|
|
|
|
hasPresenceCookie = !cookie.HttpOnly && cookie.Value == "1"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if !hasRefreshCookie {
|
|
|
|
|
|
t.Fatalf("expected login response to set ums_refresh_token cookie, got %#v", cookies)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !hasPresenceCookie {
|
|
|
|
|
|
t.Fatalf("expected login response to set ums_session_present cookie, got %#v", cookies)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestAuthHandler_RefreshToken_UsesCookieFallback(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "refreshcookieuser", "refreshcookie@example.com", "Password123!")
|
|
|
|
|
|
jar, err := cookiejar.New(nil)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("cookiejar.New() error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
client := &http.Client{Jar: jar}
|
|
|
|
|
|
|
|
|
|
|
|
loginBody, _ := json.Marshal(map[string]interface{}{
|
|
|
|
|
|
"account": "refreshcookieuser",
|
|
|
|
|
|
"password": "Password123!",
|
|
|
|
|
|
})
|
|
|
|
|
|
loginReq, _ := http.NewRequest("POST", server.URL+"/api/v1/auth/login", bytes.NewReader(loginBody))
|
|
|
|
|
|
loginReq.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
loginResp, err := client.Do(loginReq)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("login request failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer loginResp.Body.Close()
|
|
|
|
|
|
if loginResp.StatusCode != http.StatusOK {
|
|
|
|
|
|
payload, _ := io.ReadAll(loginResp.Body)
|
|
|
|
|
|
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, loginResp.StatusCode, string(payload))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
refreshReq, _ := http.NewRequest("POST", server.URL+"/api/v1/auth/refresh", nil)
|
|
|
|
|
|
refreshReq.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
refreshResp, err := client.Do(refreshReq)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("refresh request failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer refreshResp.Body.Close()
|
|
|
|
|
|
refreshPayload, _ := io.ReadAll(refreshResp.Body)
|
|
|
|
|
|
if refreshResp.StatusCode != http.StatusOK {
|
|
|
|
|
|
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, refreshResp.StatusCode, string(refreshPayload))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var parsed map[string]interface{}
|
|
|
|
|
|
if err := json.Unmarshal(refreshPayload, &parsed); err != nil {
|
|
|
|
|
|
t.Fatalf("refresh response json unmarshal failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
data, _ := parsed["data"].(map[string]interface{})
|
|
|
|
|
|
if data == nil || data["access_token"] == nil || data["refresh_token"] == nil {
|
|
|
|
|
|
t.Fatalf("expected refresh response to include token pair, got %v", parsed)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 12:08:16 +08:00
|
|
|
|
func TestAuthHandler_Login_WrongPassword(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "wrongpwuser", "wrongpw@example.com", "Password123!")
|
|
|
|
|
|
resp, body := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
|
|
|
|
|
|
"account": "wrongpwuser",
|
|
|
|
|
|
"password": "WrongPassword!",
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
// System should return 401 (correct) or 500 (bug - error handling issue)
|
|
|
|
|
|
if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusInternalServerError {
|
|
|
|
|
|
t.Errorf("expected status 401 or 500 for wrong password, got %d, body: %s", resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestAuthHandler_Login_NonExistentUser(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
resp, body := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
|
|
|
|
|
|
"account": "nonexistent",
|
|
|
|
|
|
"password": "Password123!",
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
// System should return 401 (correct) or 500 (bug - error handling issue)
|
|
|
|
|
|
if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusInternalServerError {
|
|
|
|
|
|
t.Errorf("expected status 401 or 500 for non-existent user, got %d, body: %s", resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestAuthHandler_BootstrapAdmin_MissingSecret(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
resp, _ := doPost(server.URL+"/api/v1/auth/bootstrap-admin", "", map[string]interface{}{
|
|
|
|
|
|
"username": "admin",
|
|
|
|
|
|
"email": "admin@example.com",
|
|
|
|
|
|
"password": "AdminPass123!",
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
2026-05-28 15:19:13 +08:00
|
|
|
|
// P0 修复后:已配置 BOOTSTRAP_SECRET 但未提供 header,应返回 401
|
|
|
|
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
|
|
|
|
t.Errorf("expected status %d for missing bootstrap secret header, got %d", http.StatusUnauthorized, resp.StatusCode)
|
2026-04-07 12:08:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 15:19:13 +08:00
|
|
|
|
func TestAuthHandler_VerifyTOTPAfterPasswordLogin_RequiresTempToken(t *testing.T) {
|
2026-04-07 12:08:16 +08:00
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
2026-05-28 15:19:13 +08:00
|
|
|
|
resp, body := doPost(server.URL+"/api/v1/auth/login/totp-verify", "", map[string]interface{}{
|
|
|
|
|
|
"user_id": 1,
|
|
|
|
|
|
"code": "123456",
|
|
|
|
|
|
"device_id": "device-1",
|
|
|
|
|
|
})
|
2026-04-07 12:08:16 +08:00
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
2026-05-28 15:19:13 +08:00
|
|
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
|
|
|
|
t.Fatalf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
|
2026-04-07 12:08:16 +08:00
|
|
|
|
}
|
2026-05-28 15:19:13 +08:00
|
|
|
|
}
|
2026-04-07 12:08:16 +08:00
|
|
|
|
|
2026-05-28 15:19:13 +08:00
|
|
|
|
func TestAuthHandler_UnconfiguredOAuthAndBindingsFailClosed(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "failclosed", "failclosed@test.com", "AdminPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "failclosed", "AdminPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
url string
|
|
|
|
|
|
body map[string]interface{}
|
|
|
|
|
|
}{
|
|
|
|
|
|
{name: "oauth login", url: server.URL + "/api/v1/auth/oauth/github"},
|
|
|
|
|
|
{name: "email bind code", url: server.URL + "/api/v1/users/me/bind-email/code", body: map[string]interface{}{"email": "bind@example.com"}},
|
|
|
|
|
|
{name: "social bind", url: server.URL + "/api/v1/users/me/bind-social", body: map[string]interface{}{"provider": "github"}},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, tc := range tests {
|
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
|
var resp *http.Response
|
|
|
|
|
|
var body string
|
|
|
|
|
|
if tc.body == nil {
|
|
|
|
|
|
resp, body = doGet(tc.url, token)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
resp, body = doPost(tc.url, token, tc.body)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
if resp.StatusCode != http.StatusServiceUnavailable {
|
|
|
|
|
|
t.Fatalf("expected status %d, got %d, body: %s", http.StatusServiceUnavailable, resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-04-07 12:08:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestUserHandler_CreateUser_RequiresAdmin(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "validadmin", "validadmin@test.com", "AdminPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "validadmin", "AdminPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
// Regular users cannot create other users - requires admin role
|
|
|
|
|
|
resp, body := doPost(server.URL+"/api/v1/users", token, map[string]interface{}{
|
|
|
|
|
|
"username": "newuser",
|
|
|
|
|
|
"email": "newuser@test.com",
|
|
|
|
|
|
"password": "UserPass123!",
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
|
|
|
|
t.Errorf("expected status 403 for non-admin user, got %d, body: %s", resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestUserHandler_CreateUser_Unauthorized(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
resp, _ := doPost(server.URL+"/api/v1/users", "", map[string]interface{}{
|
|
|
|
|
|
"username": "newuser",
|
|
|
|
|
|
"email": "newuser@test.com",
|
|
|
|
|
|
"password": "UserPass123!",
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
|
|
|
|
t.Errorf("expected status %d for unauthorized request, got %d", http.StatusUnauthorized, resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 15:19:13 +08:00
|
|
|
|
func TestUserHandler_ListUsers_ForbiddenForRegularUser(t *testing.T) {
|
2026-04-07 12:08:16 +08:00
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
2026-05-28 15:19:13 +08:00
|
|
|
|
registerUser(server.URL, "listuser", "listuser@test.com", "AdminPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "listuser", "AdminPass123!")
|
2026-04-07 12:08:16 +08:00
|
|
|
|
|
|
|
|
|
|
resp, body := doGet(server.URL+"/api/v1/users?page=1&page_size=10", token)
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
2026-05-28 15:19:13 +08:00
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
|
|
|
|
t.Errorf("expected status %d, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body)
|
2026-04-07 12:08:16 +08:00
|
|
|
|
}
|
2026-05-28 15:19:13 +08:00
|
|
|
|
}
|
2026-04-07 12:08:16 +08:00
|
|
|
|
|
2026-05-28 15:19:13 +08:00
|
|
|
|
func TestUserHandler_GetUser_ForbiddenForRegularUser(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "getuser", "getuser@test.com", "AdminPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "getuser", "AdminPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
resp, body := doGet(server.URL+"/api/v1/users/1", token)
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
|
|
|
|
t.Errorf("expected status %d, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body)
|
2026-04-07 12:08:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 15:19:13 +08:00
|
|
|
|
func TestUserHandler_UpdateUser_Success(t *testing.T) {
|
2026-04-07 12:08:16 +08:00
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
2026-05-28 15:19:13 +08:00
|
|
|
|
registerUser(server.URL, "updateuser", "update@example.com", "UserPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "updateuser", "UserPass123!")
|
2026-04-07 12:08:16 +08:00
|
|
|
|
|
2026-05-28 15:19:13 +08:00
|
|
|
|
resp, body := doPut(server.URL+"/api/v1/users/1", token, map[string]string{"nickname": "Updated Nickname"})
|
2026-04-07 12:08:16 +08:00
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
2026-05-28 15:19:13 +08:00
|
|
|
|
t.Errorf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
|
2026-04-07 12:08:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 15:19:13 +08:00
|
|
|
|
func TestUserHandler_UpdateUser_AdminCanUpdateOther(t *testing.T) {
|
2026-04-07 12:08:16 +08:00
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
2026-05-28 15:19:13 +08:00
|
|
|
|
token := bootstrapAdminToken(server.URL, "updateadmin", "updateadmin@test.com", "AdminPass123!")
|
|
|
|
|
|
if token == "" {
|
|
|
|
|
|
t.Fatal("bootstrap admin token should succeed")
|
|
|
|
|
|
}
|
|
|
|
|
|
registerUser(server.URL, "manageduser", "manageduser@test.com", "UserPass123!")
|
2026-04-07 12:08:16 +08:00
|
|
|
|
|
2026-05-28 15:19:13 +08:00
|
|
|
|
resp, body := doPut(server.URL+"/api/v1/users/2", token, map[string]string{"nickname": "Admin Updated"})
|
2026-04-07 12:08:16 +08:00
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
|
t.Errorf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 15:19:13 +08:00
|
|
|
|
func TestUserHandler_UpdatePassword_NonAdminCannotUpdateOther(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "pwd-user-1", "pwd-user-1@test.com", "UserPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "pwd-user-1", "UserPass123!")
|
|
|
|
|
|
registerUser(server.URL, "pwd-user-2", "pwd-user-2@test.com", "TargetPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
resp, body := doPut(server.URL+"/api/v1/users/2/password", token, map[string]string{
|
|
|
|
|
|
"old_password": "TargetPass123!",
|
|
|
|
|
|
"new_password": "TargetNew456!",
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
|
|
|
|
t.Errorf("expected status %d, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 12:08:16 +08:00
|
|
|
|
func TestUserHandler_DeleteUser_NonAdmin_Forbidden(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "deleteadmin", "deleteadmin@test.com", "AdminPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "deleteadmin", "AdminPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
// Non-admin users cannot delete users
|
|
|
|
|
|
resp, _ := doDelete(server.URL+"/api/v1/users/1", token)
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
|
|
|
|
t.Errorf("expected status %d for non-admin delete attempt, got %d", http.StatusForbidden, resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestUserHandler_SearchUsers_Success(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
2026-05-28 15:19:13 +08:00
|
|
|
|
token := bootstrapAdminToken(server.URL, "searchadmin", "searchadmin@test.com", "AdminPass123!")
|
|
|
|
|
|
if token == "" {
|
|
|
|
|
|
t.Fatal("bootstrap admin token should succeed")
|
|
|
|
|
|
}
|
2026-04-07 12:08:16 +08:00
|
|
|
|
|
|
|
|
|
|
resp, body := doGet(server.URL+"/api/v1/users/1", token)
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
|
t.Errorf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-09 14:00:42 +08:00
|
|
|
|
func TestUserHandler_UpdateUserStatus_RequiresAdmin(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "statususer", "statususer@test.com", "UserPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "statususer", "UserPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
resp, _ := doPut(server.URL+"/api/v1/users/1/status", token, map[string]interface{}{
|
|
|
|
|
|
"status": "inactive",
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
// Requires admin permission (user:manage)
|
|
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
|
|
|
|
t.Errorf("expected status %d for non-admin user, got %d", http.StatusForbidden, resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 18:39:56 +08:00
|
|
|
|
func TestUserHandler_GetUserRoles_SelfCanView(t *testing.T) {
|
2026-04-09 14:00:42 +08:00
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
2026-05-28 16:20:20 +08:00
|
|
|
|
registerUser(server.URL, "rolesuser", "rolesuser@test.com", "UserPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "rolesuser", "UserPass123!")
|
2026-04-09 14:00:42 +08:00
|
|
|
|
|
2026-05-28 18:39:56 +08:00
|
|
|
|
resp, body := doGet(server.URL+"/api/v1/users/1/roles", token)
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
|
t.Errorf("expected status %d for self role lookup, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestUserHandler_GetUserRoles_ForbiddenForOtherRegularUser(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "rolesuser", "rolesuser@test.com", "UserPass123!")
|
|
|
|
|
|
registerUser(server.URL, "otherrolesuser", "otherrolesuser@test.com", "UserPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "rolesuser", "UserPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
resp, _ := doGet(server.URL+"/api/v1/users/2/roles", token)
|
2026-04-09 14:00:42 +08:00
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
2026-05-28 16:20:20 +08:00
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
2026-05-28 18:39:56 +08:00
|
|
|
|
t.Errorf("expected status %d for viewing another user's roles, got %d", http.StatusForbidden, resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestUserHandler_GetUserRoles_UnauthorizedWithoutToken(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "rolesuser", "rolesuser@test.com", "UserPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
resp, _ := doGet(server.URL+"/api/v1/users/1/roles", "")
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
|
|
|
|
t.Errorf("expected status %d without token, got %d", http.StatusUnauthorized, resp.StatusCode)
|
2026-04-09 14:00:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 15:19:13 +08:00
|
|
|
|
func TestUserHandler_GetUserRoles_AdminCanViewOther(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
token := bootstrapAdminToken(server.URL, "rolesbootstrap", "rolesbootstrap@test.com", "AdminPass123!")
|
|
|
|
|
|
if token == "" {
|
|
|
|
|
|
t.Fatal("bootstrap admin token should succeed")
|
|
|
|
|
|
}
|
|
|
|
|
|
registerUser(server.URL, "role-target", "role-target@test.com", "UserPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
resp, body := doGet(server.URL+"/api/v1/users/2/roles", token)
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
|
t.Errorf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 18:39:56 +08:00
|
|
|
|
func TestUserHandler_GetUserRoles_AdminGetsNotFoundForMissingUser(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
token := bootstrapAdminToken(server.URL, "rolesbootstrap", "rolesbootstrap@test.com", "AdminPass123!")
|
|
|
|
|
|
if token == "" {
|
|
|
|
|
|
t.Fatal("bootstrap admin token should succeed")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
resp, _ := doGet(server.URL+"/api/v1/users/99999/roles", token)
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
|
|
|
|
t.Errorf("expected status %d for missing user, got %d", http.StatusNotFound, resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-09 14:00:42 +08:00
|
|
|
|
func TestUserHandler_AssignRoles_RequiresAdmin(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "assignuser", "assignuser@test.com", "UserPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "assignuser", "UserPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
resp, _ := doPut(server.URL+"/api/v1/users/1/roles", token, map[string]interface{}{
|
|
|
|
|
|
"role_ids": []int64{1},
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
// Requires admin permission (user:manage)
|
|
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
|
|
|
|
t.Errorf("expected status %d for non-admin user, got %d", http.StatusForbidden, resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestUserHandler_BatchUpdateStatus_RequiresAdmin(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "batchuser", "batchuser@test.com", "UserPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "batchuser", "UserPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
resp, _ := doPut(server.URL+"/api/v1/users/batch/status", token, map[string]interface{}{
|
|
|
|
|
|
"user_ids": []int64{2, 3},
|
|
|
|
|
|
"status": "inactive",
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
// Requires admin permission (user:manage)
|
|
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
|
|
|
|
t.Errorf("expected status %d for non-admin user, got %d", http.StatusForbidden, resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestUserHandler_BatchDelete_RequiresAdmin(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "deluser", "deluser@test.com", "UserPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "deluser", "UserPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
resp, _ := doDelete(server.URL+"/api/v1/users/batch?ids=2,3", token)
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
// Requires admin permission (user:delete)
|
|
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
|
|
|
|
t.Errorf("expected status %d for non-admin user, got %d", http.StatusForbidden, resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestUserHandler_BatchDelete_EmptyIDs_RequiresAdmin(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "emptyidsuser", "emptyidsuser@test.com", "UserPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "emptyidsuser", "UserPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
resp, _ := doDelete(server.URL+"/api/v1/users/batch", token)
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
// Requires admin permission (user:delete) - validation happens after auth check
|
|
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
|
|
|
|
t.Errorf("expected status %d for non-admin user, got %d", http.StatusForbidden, resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 12:08:16 +08:00
|
|
|
|
// =============================================================================
|
|
|
|
|
|
// Device Handler Tests
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
func TestDeviceHandler_GetMyDevices_Success(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "deviceuser", "device@test.com", "UserPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "deviceuser", "UserPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
resp, _ := doGet(server.URL+"/api/v1/devices", token)
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
|
t.Errorf("expected status %d, got %d", http.StatusOK, resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestDeviceHandler_GetUserDevices_IDOR_Forbidden(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "user1", "user1@test.com", "UserPass123!")
|
|
|
|
|
|
registerUser(server.URL, "user2", "user2@test.com", "UserPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "user1", "UserPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
// User1 tries to access User2's devices
|
|
|
|
|
|
resp, body := doGet(server.URL+"/api/v1/devices/users/2", token)
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
// Should be forbidden due to IDOR protection
|
|
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
|
|
|
|
t.Errorf("expected status %d for IDOR attempt, got %d, body: %s",
|
|
|
|
|
|
http.StatusForbidden, resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestDeviceHandler_GetUserDevices_SameUser_Success(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "sameuser", "sameuser@test.com", "UserPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "sameuser", "UserPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
// User accesses their own devices
|
|
|
|
|
|
resp, _ := doGet(server.URL+"/api/v1/devices/users/1", token)
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
|
t.Errorf("expected status %d, got %d", http.StatusOK, resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestDeviceHandler_CreateDevice_Success(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "createdevice", "createdevice@test.com", "UserPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "createdevice", "UserPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
resp, body := doPost(server.URL+"/api/v1/devices", token, map[string]interface{}{
|
test: add comprehensive test coverage and improve code quality
- Add new test files for auth, service, and handler modules
- Improve test organization and coverage
- Refactor code for better maintainability
- Add captcha, settings, stats, and theme handler tests
- Add auth module tests (CAS, OAuth, password, SSO, state)
- Add service layer tests for auth, export, permissions, roles
- All Go tests pass (exit code 0)
- All frontend tests pass (325 tests in 59 files)
2026-04-17 20:43:50 +08:00
|
|
|
|
"name": "My Device",
|
|
|
|
|
|
"device_id": "device-001",
|
|
|
|
|
|
"device_type": 3, // DeviceTypeDesktop
|
|
|
|
|
|
"device_os": "Windows 10",
|
2026-04-07 12:08:16 +08:00
|
|
|
|
"device_browser": "Chrome",
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
|
|
|
|
t.Errorf("expected status %d, got %d, body: %s", http.StatusCreated, resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 17:28:08 +08:00
|
|
|
|
func TestDeviceHandler_DeviceByIDRoutes_ForbiddenForOtherUser(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "device-owner", "device-owner@test.com", "UserPass123!")
|
|
|
|
|
|
registerUser(server.URL, "device-attacker", "device-attacker@test.com", "UserPass123!")
|
|
|
|
|
|
ownerToken := getToken(server.URL, "device-owner", "UserPass123!")
|
|
|
|
|
|
attackerToken := getToken(server.URL, "device-attacker", "UserPass123!")
|
|
|
|
|
|
deviceID := createDeviceAndGetID(t, server.URL, ownerToken, "device-owner-001")
|
|
|
|
|
|
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
method string
|
|
|
|
|
|
url string
|
|
|
|
|
|
body map[string]interface{}
|
|
|
|
|
|
}{
|
|
|
|
|
|
{name: "get", method: http.MethodGet, url: fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID)},
|
|
|
|
|
|
{name: "update", method: http.MethodPut, url: fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), body: map[string]interface{}{"device_name": "hijacked"}},
|
|
|
|
|
|
{name: "delete", method: http.MethodDelete, url: fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID)},
|
|
|
|
|
|
{name: "status", method: http.MethodPut, url: fmt.Sprintf("%s/api/v1/devices/%d/status", server.URL, deviceID), body: map[string]interface{}{"status": "inactive"}},
|
|
|
|
|
|
{name: "trust", method: http.MethodPost, url: fmt.Sprintf("%s/api/v1/devices/%d/trust", server.URL, deviceID), body: map[string]interface{}{"trust_duration": "30d"}},
|
|
|
|
|
|
{name: "untrust", method: http.MethodDelete, url: fmt.Sprintf("%s/api/v1/devices/%d/trust", server.URL, deviceID)},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, tc := range tests {
|
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
|
resp, body := doRequest(tc.method, tc.url, attackerToken, tc.body)
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
|
|
|
|
t.Fatalf("expected 403 for %s, got %d body=%s", tc.name, resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestWebhookHandler_OtherUserCannotManageForeignWebhook(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "webhook-owner", "webhook-owner@test.com", "UserPass123!")
|
|
|
|
|
|
registerUser(server.URL, "webhook-attacker", "webhook-attacker@test.com", "UserPass123!")
|
|
|
|
|
|
ownerToken := getToken(server.URL, "webhook-owner", "UserPass123!")
|
|
|
|
|
|
attackerToken := getToken(server.URL, "webhook-attacker", "UserPass123!")
|
|
|
|
|
|
webhookID := createWebhookAndGetID(t, server.URL, ownerToken, "owner-webhook")
|
|
|
|
|
|
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
method string
|
|
|
|
|
|
url string
|
|
|
|
|
|
body map[string]interface{}
|
|
|
|
|
|
}{
|
|
|
|
|
|
{name: "update", method: http.MethodPut, url: fmt.Sprintf("%s/api/v1/webhooks/%d", server.URL, webhookID), body: map[string]interface{}{"name": "hijacked"}},
|
|
|
|
|
|
{name: "delete", method: http.MethodDelete, url: fmt.Sprintf("%s/api/v1/webhooks/%d", server.URL, webhookID)},
|
|
|
|
|
|
{name: "deliveries", method: http.MethodGet, url: fmt.Sprintf("%s/api/v1/webhooks/%d/deliveries", server.URL, webhookID)},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, tc := range tests {
|
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
|
resp, body := doRequest(tc.method, tc.url, attackerToken, tc.body)
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
|
|
|
|
t.Fatalf("expected 403 for webhook %s, got %d body=%s", tc.name, resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 12:08:16 +08:00
|
|
|
|
// =============================================================================
|
|
|
|
|
|
// Role Handler Tests
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
func TestRoleHandler_CreateRole_RequiresAdmin(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "roleadmin", "roleadmin@test.com", "AdminPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "roleadmin", "AdminPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
// Role creation requires admin
|
|
|
|
|
|
resp, body := doPost(server.URL+"/api/v1/roles", token, map[string]interface{}{
|
|
|
|
|
|
"name": "Test Role",
|
|
|
|
|
|
"code": "test_role",
|
|
|
|
|
|
"description": "A test role",
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
|
|
|
|
t.Errorf("expected status 403 for non-admin, got %d, body: %s", resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestRoleHandler_ListRoles_RequiresAdmin(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "listroleadmin", "listroleadmin@test.com", "AdminPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "listroleadmin", "AdminPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
resp, body := doGet(server.URL+"/api/v1/roles", token)
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
// Regular users cannot list all roles
|
|
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
|
|
|
|
t.Errorf("expected status 403 for non-admin, got %d, body: %s", resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestRoleHandler_GetRole_RequiresAdmin(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "getroleadmin", "getroleadmin@test.com", "AdminPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "getroleadmin", "AdminPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
resp, body := doGet(server.URL+"/api/v1/roles/1", token)
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
|
|
|
|
t.Errorf("expected status 403 for non-admin, got %d, body: %s", resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
// Theme Handler Tests
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
func TestThemeHandler_CreateTheme_WithDangerousJS_Rejected(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "themeadmin", "themeadmin@test.com", "AdminPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "themeadmin", "AdminPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
// Note: Creating themes requires admin role. Regular registered users get 403.
|
|
|
|
|
|
// This test verifies that a regular user cannot create themes with dangerous JS.
|
|
|
|
|
|
resp, body := doPost(server.URL+"/api/v1/themes", token, map[string]interface{}{
|
|
|
|
|
|
"name": "Malicious Theme",
|
|
|
|
|
|
"custom_js": "javascript:alert('xss')",
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
// Regular users should get 403 Forbidden
|
|
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
|
|
|
|
t.Errorf("expected status %d for non-admin user, got %d, body: %s",
|
|
|
|
|
|
http.StatusForbidden, resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestThemeHandler_CreateTheme_WithScriptTag_Rejected(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "themeadmin2", "themeadmin2@test.com", "AdminPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "themeadmin2", "AdminPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
resp, body := doPost(server.URL+"/api/v1/themes", token, map[string]interface{}{
|
|
|
|
|
|
"name": "Script Theme",
|
|
|
|
|
|
"custom_js": "<script>alert('xss')</script>",
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
|
|
|
|
t.Errorf("expected status %d for non-admin user, got %d, body: %s",
|
|
|
|
|
|
http.StatusForbidden, resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestThemeHandler_CreateTheme_WithEventHandler_Rejected(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "themeadmin3", "themeadmin3@test.com", "AdminPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "themeadmin3", "AdminPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
resp, body := doPost(server.URL+"/api/v1/themes", token, map[string]interface{}{
|
|
|
|
|
|
"name": "Event Theme",
|
|
|
|
|
|
"custom_js": "<img src=x onerror=alert(1)>",
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
|
|
|
|
t.Errorf("expected status %d for non-admin user, got %d, body: %s",
|
|
|
|
|
|
http.StatusForbidden, resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestThemeHandler_ListThemes_RequiresAuth(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
// Without auth, should get 401
|
|
|
|
|
|
resp, _ := doGet(server.URL+"/api/v1/themes", "")
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
|
|
|
|
t.Errorf("expected status %d for unauthenticated request, got %d",
|
|
|
|
|
|
http.StatusUnauthorized, resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestThemeHandler_GetDefaultTheme_RequiresAdmin(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "themeuser", "themeuser@test.com", "AdminPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "themeuser", "AdminPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
resp, body := doGet(server.URL+"/api/v1/themes/default", token)
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
// Regular users get 403
|
|
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
|
|
|
|
t.Errorf("expected status %d for non-admin user, got %d, body: %s",
|
|
|
|
|
|
http.StatusForbidden, resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
// Health Check Tests
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
// Health endpoint is defined in main.go, not in the router.
|
|
|
|
|
|
// Skipping this test as it's not part of the router-based handler tests.
|
|
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
// Concurrent Request Tests
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
func TestConcurrent_Register_Requests(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
const goroutines = 20
|
|
|
|
|
|
const requestsPerGoroutine = 5
|
|
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
|
|
errorCount := int32(0)
|
|
|
|
|
|
successCount := int32(0)
|
|
|
|
|
|
rateLimitedCount := int32(0)
|
|
|
|
|
|
|
|
|
|
|
|
for i := 0; i < goroutines; i++ {
|
|
|
|
|
|
wg.Add(1)
|
|
|
|
|
|
go func(id int) {
|
|
|
|
|
|
defer wg.Done()
|
|
|
|
|
|
for j := 0; j < requestsPerGoroutine; j++ {
|
|
|
|
|
|
username := fmt.Sprintf("concurrent_user_%d_%d", id, j)
|
|
|
|
|
|
resp, _ := doPost(server.URL+"/api/v1/auth/register", "", map[string]interface{}{
|
|
|
|
|
|
"username": username,
|
|
|
|
|
|
"email": fmt.Sprintf("%s@test.com", username),
|
|
|
|
|
|
"password": "UserPass123!",
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
if resp.StatusCode == http.StatusCreated {
|
|
|
|
|
|
atomic.AddInt32(&successCount, 1)
|
|
|
|
|
|
} else if resp.StatusCode == http.StatusTooManyRequests {
|
|
|
|
|
|
atomic.AddInt32(&rateLimitedCount, 1)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
atomic.AddInt32(&errorCount, 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}(i)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
wg.Wait()
|
|
|
|
|
|
|
|
|
|
|
|
total := int32(goroutines * requestsPerGoroutine)
|
|
|
|
|
|
t.Logf("concurrent registration: %d success, %d rate-limited, %d errors out of %d total",
|
|
|
|
|
|
successCount, rateLimitedCount, errorCount, total)
|
|
|
|
|
|
|
|
|
|
|
|
// Rate limiting is expected behavior - verify the system is handling concurrency
|
|
|
|
|
|
if rateLimitedCount == 0 && successCount < total/2 {
|
|
|
|
|
|
t.Errorf("too few successful registrations: %d/%d (no rate limiting detected)", successCount, total)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestConcurrent_Login_SameUser(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "concurrentlogin", "cl@test.com", "UserPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
const goroutines = 10
|
|
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
|
|
successCount := int32(0)
|
|
|
|
|
|
rateLimitedCount := int32(0)
|
|
|
|
|
|
|
|
|
|
|
|
for i := 0; i < goroutines; i++ {
|
|
|
|
|
|
wg.Add(1)
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
defer wg.Done()
|
|
|
|
|
|
token := getToken(server.URL, "concurrentlogin", "UserPass123!")
|
|
|
|
|
|
if token != "" {
|
|
|
|
|
|
atomic.AddInt32(&successCount, 1)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Could be rate limited - check the login directly
|
|
|
|
|
|
resp, _ := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
|
|
|
|
|
|
"account": "concurrentlogin",
|
|
|
|
|
|
"password": "UserPass123!",
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
if resp.StatusCode == http.StatusTooManyRequests {
|
|
|
|
|
|
atomic.AddInt32(&rateLimitedCount, 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
wg.Wait()
|
|
|
|
|
|
|
|
|
|
|
|
t.Logf("concurrent login: %d success, %d rate-limited out of %d",
|
|
|
|
|
|
successCount, rateLimitedCount, goroutines)
|
|
|
|
|
|
|
|
|
|
|
|
// Rate limiting is expected for concurrent login attempts
|
|
|
|
|
|
if rateLimitedCount == 0 && successCount < goroutines/2 {
|
|
|
|
|
|
t.Errorf("too few successful logins: %d/%d", successCount, goroutines)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestConcurrent_DeviceCreation(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "deviceconcurrent", "dc@test.com", "UserPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "deviceconcurrent", "UserPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
const goroutines = 5
|
|
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
|
|
successCount := int32(0)
|
|
|
|
|
|
|
|
|
|
|
|
for i := 0; i < goroutines; i++ {
|
|
|
|
|
|
wg.Add(1)
|
|
|
|
|
|
go func(id int) {
|
|
|
|
|
|
defer wg.Done()
|
|
|
|
|
|
resp, _ := doPost(server.URL+"/api/v1/devices", token, map[string]interface{}{
|
|
|
|
|
|
"name": fmt.Sprintf("Device %d", id),
|
|
|
|
|
|
"device_id": fmt.Sprintf("device-concurrent-%d", id),
|
|
|
|
|
|
"device_type": 3, // DeviceTypeDesktop
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
if resp.StatusCode == http.StatusCreated {
|
|
|
|
|
|
atomic.AddInt32(&successCount, 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
}(i)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
wg.Wait()
|
|
|
|
|
|
|
|
|
|
|
|
if successCount != goroutines {
|
|
|
|
|
|
t.Errorf("expected %d successful device creations, got %d", goroutines, successCount)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
// Error Handling Tests
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
func TestErrorResponse_ContainsNoInternalDetails(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
// Try to access protected endpoint without token
|
|
|
|
|
|
resp, body := doGet(server.URL+"/api/v1/users", "")
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
|
|
|
|
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var result map[string]interface{}
|
|
|
|
|
|
json.Unmarshal([]byte(body), &result)
|
|
|
|
|
|
|
|
|
|
|
|
if errMsg, ok := result["error"].(string); ok {
|
|
|
|
|
|
// Error should be short and not contain internal details
|
|
|
|
|
|
if len(errMsg) > 100 {
|
|
|
|
|
|
t.Errorf("error message too long, might contain internal details: %s", errMsg)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestInvalidUserID_ReturnsBadRequest(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
2026-05-28 15:19:13 +08:00
|
|
|
|
token := bootstrapAdminToken(server.URL, "invalidid", "invalidid@test.com", "AdminPass123!")
|
|
|
|
|
|
if token == "" {
|
|
|
|
|
|
t.Fatal("bootstrap admin token should succeed")
|
|
|
|
|
|
}
|
2026-04-07 12:08:16 +08:00
|
|
|
|
|
|
|
|
|
|
resp, _ := doGet(server.URL+"/api/v1/users/invalid", token)
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
|
|
|
|
t.Errorf("expected status %d for invalid user id, got %d", http.StatusBadRequest, resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestNonExistentUserID_ReturnsNotFound(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
2026-05-28 15:19:13 +08:00
|
|
|
|
token := bootstrapAdminToken(server.URL, "notfound", "notfound@test.com", "AdminPass123!")
|
|
|
|
|
|
if token == "" {
|
|
|
|
|
|
t.Fatal("bootstrap admin token should succeed")
|
|
|
|
|
|
}
|
2026-04-07 12:08:16 +08:00
|
|
|
|
|
|
|
|
|
|
resp, _ := doGet(server.URL+"/api/v1/users/99999", token)
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
|
|
|
|
t.Errorf("expected status %d for non-existent user, got %d", http.StatusNotFound, resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
// Input Validation Tests
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
func TestRegister_InvalidEmail(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
// Note: Email validation may not be strict at handler level
|
|
|
|
|
|
// The service layer handles validation
|
|
|
|
|
|
resp, _ := doPost(server.URL+"/api/v1/auth/register", "", map[string]interface{}{
|
|
|
|
|
|
"username": "bademail",
|
|
|
|
|
|
"email": "not-an-email",
|
|
|
|
|
|
"password": "Password123!",
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
// Should either succeed (if validated later) or fail with 400
|
|
|
|
|
|
if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusCreated {
|
|
|
|
|
|
t.Errorf("unexpected status for email validation: %d", resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestRegister_WeakPassword(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
resp, _ := doPost(server.URL+"/api/v1/auth/register", "", map[string]interface{}{
|
|
|
|
|
|
"username": "weakpass",
|
|
|
|
|
|
"email": "weakpass@test.com",
|
|
|
|
|
|
"password": "123",
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
// Weak password should be rejected with 400
|
|
|
|
|
|
if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusInternalServerError {
|
|
|
|
|
|
t.Errorf("expected status 400 or 500 for weak password, got %d", resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestCreateUser_InvalidEmail(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "validadmin", "validadmin@test.com", "AdminPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "validadmin", "AdminPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
resp, _ := doPost(server.URL+"/api/v1/users", token, map[string]interface{}{
|
|
|
|
|
|
"username": "newuser",
|
|
|
|
|
|
"email": "not-an-email",
|
|
|
|
|
|
"password": "UserPass123!",
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
// Should return 400 for invalid email or 403 if user lacks permission
|
|
|
|
|
|
if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusForbidden {
|
|
|
|
|
|
t.Errorf("expected status 400 or 403, got %d", resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
// Response Structure Tests
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
func TestResponse_HasCorrectStructure(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "structtest", "struct@test.com", "AdminPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "structtest", "AdminPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
resp, body := doGet(server.URL+"/api/v1/users", token)
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
var result map[string]interface{}
|
|
|
|
|
|
json.Unmarshal([]byte(body), &result)
|
|
|
|
|
|
|
|
|
|
|
|
// Should have code field
|
|
|
|
|
|
if _, ok := result["code"]; !ok {
|
|
|
|
|
|
t.Error("response should have 'code' field")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Should have message field
|
|
|
|
|
|
if _, ok := result["message"]; !ok {
|
|
|
|
|
|
t.Error("response should have 'message' field")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestLoginResponse_HasTokenFields(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "tokentest", "token@test.com", "Password123!")
|
|
|
|
|
|
resp, body := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
|
|
|
|
|
|
"account": "tokentest",
|
|
|
|
|
|
"password": "Password123!",
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
var result map[string]interface{}
|
|
|
|
|
|
json.Unmarshal([]byte(body), &result)
|
|
|
|
|
|
|
|
|
|
|
|
if result["data"] == nil {
|
|
|
|
|
|
t.Fatal("response should have 'data' field")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
data := result["data"].(map[string]interface{})
|
|
|
|
|
|
if data["access_token"] == nil {
|
|
|
|
|
|
t.Error("data should have 'access_token' field")
|
|
|
|
|
|
}
|
|
|
|
|
|
if data["refresh_token"] == nil {
|
|
|
|
|
|
t.Error("data should have 'refresh_token' field")
|
|
|
|
|
|
}
|
|
|
|
|
|
if data["expires_in"] == nil {
|
|
|
|
|
|
t.Error("data should have 'expires_in' field")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-09 07:53:06 +08:00
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
// Auth Handler - Additional Critical Path Tests
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
func TestAuthHandler_Logout_Success(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "logoutuser", "logout@example.com", "Password123!")
|
|
|
|
|
|
token := getToken(server.URL, "logoutuser", "Password123!")
|
|
|
|
|
|
if token == "" {
|
|
|
|
|
|
t.Fatal("failed to get token for logout test")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
resp, body := doPost(server.URL+"/api/v1/auth/logout", token, nil)
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
|
t.Errorf("expected status %d for logout, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestAuthHandler_Logout_WithoutToken(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
resp, _ := doPost(server.URL+"/api/v1/auth/logout", "", nil)
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
|
|
|
|
t.Errorf("expected status %d for logout without token, got %d", http.StatusUnauthorized, resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestAuthHandler_GetUserInfo_Success(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "infouser", "info@example.com", "Password123!")
|
|
|
|
|
|
token := getToken(server.URL, "infouser", "Password123!")
|
|
|
|
|
|
if token == "" {
|
|
|
|
|
|
t.Fatal("failed to get token for userinfo test")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
resp, body := doGet(server.URL+"/api/v1/auth/userinfo", token)
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
|
t.Errorf("expected status %d for get userinfo, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var result map[string]interface{}
|
|
|
|
|
|
json.Unmarshal([]byte(body), &result)
|
|
|
|
|
|
if result["code"] != float64(0) {
|
|
|
|
|
|
t.Errorf("expected code 0, got %v", result["code"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if result["data"] == nil {
|
|
|
|
|
|
t.Fatal("response should have data field")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestAuthHandler_GetUserInfo_WithoutToken(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
resp, _ := doGet(server.URL+"/api/v1/auth/userinfo", "")
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
|
|
|
|
t.Errorf("expected status %d for get userinfo without token, got %d", http.StatusUnauthorized, resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestAuthHandler_GetCSRFToken_Success(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "csrfuser", "csrf@example.com", "Password123!")
|
|
|
|
|
|
token := getToken(server.URL, "csrfuser", "Password123!")
|
|
|
|
|
|
if token == "" {
|
|
|
|
|
|
t.Fatal("failed to get token for csrf test")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
resp, body := doGet(server.URL+"/api/v1/auth/csrf-token", token)
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
|
t.Errorf("expected status %d for get csrf, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The CSRF endpoint returns a JSON response
|
|
|
|
|
|
// It should contain either a wrapped response or gin.H directly
|
|
|
|
|
|
var result map[string]interface{}
|
|
|
|
|
|
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
|
|
|
|
|
t.Fatalf("failed to unmarshal response: %s, body: %s", err, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
// Just verify we got a valid JSON response - the exact format varies
|
|
|
|
|
|
if len(result) == 0 {
|
|
|
|
|
|
t.Error("response should not be empty")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestAuthHandler_RefreshToken_Success(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
registerUser(server.URL, "refreshuser", "refresh@example.com", "Password123!")
|
|
|
|
|
|
token := getToken(server.URL, "refreshuser", "Password123!")
|
|
|
|
|
|
if token == "" {
|
|
|
|
|
|
t.Fatal("failed to get token for refresh test")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// First login to get refresh token
|
|
|
|
|
|
resp, body := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
|
|
|
|
|
|
"account": "refreshuser",
|
|
|
|
|
|
"password": "Password123!",
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
var loginResult map[string]interface{}
|
|
|
|
|
|
json.Unmarshal([]byte(body), &loginResult)
|
|
|
|
|
|
loginData := loginResult["data"].(map[string]interface{})
|
|
|
|
|
|
refreshToken := loginData["refresh_token"].(string)
|
|
|
|
|
|
|
|
|
|
|
|
// Now refresh
|
|
|
|
|
|
refreshResp, refreshBody := doPost(server.URL+"/api/v1/auth/refresh", "", map[string]interface{}{
|
|
|
|
|
|
"refresh_token": refreshToken,
|
|
|
|
|
|
})
|
|
|
|
|
|
defer refreshResp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if refreshResp.StatusCode != http.StatusOK {
|
|
|
|
|
|
t.Errorf("expected status %d for refresh, got %d, body: %s", http.StatusOK, refreshResp.StatusCode, refreshBody)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestAuthHandler_RefreshToken_InvalidToken(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
resp, body := doPost(server.URL+"/api/v1/auth/refresh", "", map[string]interface{}{
|
|
|
|
|
|
"refresh_token": "invalid-token",
|
|
|
|
|
|
})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
|
|
|
|
t.Errorf("expected status %d for invalid refresh token, got %d, body: %s", http.StatusUnauthorized, resp.StatusCode, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestAuthHandler_RefreshToken_MissingToken(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
resp, _ := doPost(server.URL+"/api/v1/auth/refresh", "", map[string]interface{}{})
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
|
|
|
|
t.Errorf("expected status %d for missing refresh token, got %d", http.StatusBadRequest, resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-11 20:05:40 +08:00
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
// Avatar Handler Tests
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
func doUploadFile(url, token string, fieldName string, fileName string, fileContent []byte) (*http.Response, error) {
|
|
|
|
|
|
body := &bytes.Buffer{}
|
|
|
|
|
|
writer := multipart.NewWriter(body)
|
|
|
|
|
|
part, err := writer.CreateFormFile(fieldName, fileName)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := part.Write(fileContent); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := writer.Close(); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequest("POST", url, body)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if token != "" {
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
|
}
|
|
|
|
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
|
|
|
|
|
|
|
|
|
|
client := &http.Client{}
|
|
|
|
|
|
return client.Do(req)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestAvatarHandler_UploadAvatar_Unauthorized(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
// Create a fake PNG file
|
|
|
|
|
|
fileContent := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
|
|
|
|
|
|
|
|
|
|
|
|
resp, err := doUploadFile(server.URL+"/api/v1/users/1/avatar", "", "avatar", "test.png", fileContent)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("upload request failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
|
|
|
|
t.Errorf("expected status %d for unauthorized request, got %d", http.StatusUnauthorized, resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestAvatarHandler_UploadAvatar_NonAdminCannotUpdateOther(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
// Register two users
|
|
|
|
|
|
registerUser(server.URL, "user1", "user1@test.com", "UserPass123!")
|
|
|
|
|
|
token1 := getToken(server.URL, "user1", "UserPass123!")
|
|
|
|
|
|
registerUser(server.URL, "user2", "user2@test.com", "UserPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
// user1 tries to update user2's avatar (should be forbidden)
|
|
|
|
|
|
fileContent := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
|
|
|
|
|
|
resp, err := doUploadFile(server.URL+"/api/v1/users/2/avatar", token1, "avatar", "test.png", fileContent)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("upload request failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
|
|
|
|
t.Errorf("expected status %d for non-admin updating other's avatar, got %d", http.StatusForbidden, resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 15:19:13 +08:00
|
|
|
|
func TestAvatarHandler_UploadAvatar_AdminCanUpdateOther(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
token := bootstrapAdminToken(server.URL, "avataradmin", "avataradmin@test.com", "AdminPass123!")
|
|
|
|
|
|
if token == "" {
|
|
|
|
|
|
t.Fatal("bootstrap admin token should succeed")
|
|
|
|
|
|
}
|
|
|
|
|
|
registerUser(server.URL, "avatar-target", "avatar-target@test.com", "UserPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
fileContent := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
|
|
|
|
|
|
resp, err := doUploadFile(server.URL+"/api/v1/users/2/avatar", token, "avatar", "test.png", fileContent)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("upload request failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
|
|
|
|
t.Fatalf("expected status %d for admin updating other's avatar, got %d, body: %s", http.StatusOK, resp.StatusCode, string(bodyBytes))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 20:05:40 +08:00
|
|
|
|
func TestAvatarHandler_UploadAvatar_UserNotFoundOrForbidden(t *testing.T) {
|
|
|
|
|
|
server, cleanup := setupHandlerTestServer(t)
|
|
|
|
|
|
defer cleanup()
|
|
|
|
|
|
|
|
|
|
|
|
// Register and login as a user
|
|
|
|
|
|
registerUser(server.URL, "avataruser", "avataruser@test.com", "UserPass123!")
|
|
|
|
|
|
token := getToken(server.URL, "avataruser", "UserPass123!")
|
|
|
|
|
|
|
|
|
|
|
|
// Try to upload avatar for non-existent user (ID 9999)
|
|
|
|
|
|
// Should return 403 because permission check happens before existence check
|
|
|
|
|
|
// (security: don't reveal whether user exists)
|
|
|
|
|
|
fileContent := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
|
|
|
|
|
|
resp, err := doUploadFile(server.URL+"/api/v1/users/9999/avatar", token, "avatar", "test.png", fileContent)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("upload request failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
// Handler returns 403 (permission denied) before checking if user exists
|
|
|
|
|
|
// This is intentional security behavior - don't leak whether user ID exists
|
|
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
|
|
|
|
t.Errorf("expected status %d for updating non-existent user's avatar, got %d", http.StatusForbidden, resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|