422 lines
13 KiB
Go
422 lines
13 KiB
Go
|
|
package e2e
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"bytes"
|
|||
|
|
"context"
|
|||
|
|
"encoding/json"
|
|||
|
|
"fmt"
|
|||
|
|
"io"
|
|||
|
|
"net/http"
|
|||
|
|
"net/http/httptest"
|
|||
|
|
"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"
|
|||
|
|
"github.com/user-management-system/internal/repository"
|
|||
|
|
"github.com/user-management-system/internal/security"
|
|||
|
|
"github.com/user-management-system/internal/service"
|
|||
|
|
gormsqlite "gorm.io/driver/sqlite"
|
|||
|
|
"gorm.io/gorm"
|
|||
|
|
"gorm.io/gorm/logger"
|
|||
|
|
_ "modernc.org/sqlite"
|
|||
|
|
|
|||
|
|
"github.com/user-management-system/internal/domain"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
var dbCounter int64
|
|||
|
|
|
|||
|
|
func setupRealServer(t *testing.T) (*httptest.Server, func()) {
|
|||
|
|
t.Helper()
|
|||
|
|
gin.SetMode(gin.TestMode)
|
|||
|
|
|
|||
|
|
id := atomic.AddInt64(&dbCounter, 1)
|
|||
|
|
dsn := fmt.Sprintf("file:e2edb_%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("跳过 E2E 测试(SQLite 不可用): %v", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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("数据库迁移失败: %v", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
jwtManager := auth.NewJWT("test-secret-key-for-e2e", 15*time.Minute, 7*24*time.Hour)
|
|||
|
|
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)
|
|||
|
|
operationLogRepo := repository.NewOperationLogRepository(db)
|
|||
|
|
passwordHistoryRepo := repository.NewPasswordHistoryRepository(db)
|
|||
|
|
|
|||
|
|
authSvc := service.NewAuthService(userRepo, nil, jwtManager, cacheManager, 6, 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(operationLogRepo)
|
|||
|
|
|
|||
|
|
pwdResetCfg := &service.PasswordResetConfig{
|
|||
|
|
TokenTTL: 15 * time.Minute,
|
|||
|
|
SiteURL: "http://localhost",
|
|||
|
|
}
|
|||
|
|
pwdResetSvc := service.NewPasswordResetService(userRepo, cacheManager, pwdResetCfg)
|
|||
|
|
captchaSvc := service.NewCaptchaService(cacheManager)
|
|||
|
|
totpSvc := service.NewTOTPService(userRepo)
|
|||
|
|
webhookSvc := service.NewWebhookService(db)
|
|||
|
|
|
|||
|
|
authH := handler.NewAuthHandler(authSvc)
|
|||
|
|
userH := handler.NewUserHandler(userSvc)
|
|||
|
|
roleH := handler.NewRoleHandler(roleSvc)
|
|||
|
|
permH := handler.NewPermissionHandler(permSvc)
|
|||
|
|
deviceH := handler.NewDeviceHandler(deviceSvc)
|
|||
|
|
logH := handler.NewLogHandler(loginLogSvc, opLogSvc)
|
|||
|
|
pwdResetH := handler.NewPasswordResetHandler(pwdResetSvc)
|
|||
|
|
captchaH := handler.NewCaptchaHandler(captchaSvc)
|
|||
|
|
totpH := handler.NewTOTPHandler(authSvc, totpSvc)
|
|||
|
|
webhookH := handler.NewWebhookHandler(webhookSvc)
|
|||
|
|
smsH := handler.NewSMSHandler()
|
|||
|
|
|
|||
|
|
rateLimitMW := middleware.NewRateLimitMiddleware(config.RateLimitConfig{})
|
|||
|
|
authMW := middleware.NewAuthMiddleware(jwtManager, userRepo, userRoleRepo, roleRepo, rolePermissionRepo, permissionRepo)
|
|||
|
|
authMW.SetCacheManager(cacheManager)
|
|||
|
|
opLogMW := middleware.NewOperationLogMiddleware(operationLogRepo)
|
|||
|
|
ipFilterMW := middleware.NewIPFilterMiddleware(security.NewIPFilter(), middleware.IPFilterConfig{})
|
|||
|
|
|
|||
|
|
r := router.NewRouter(
|
|||
|
|
authH, userH, roleH, permH, deviceH, logH,
|
|||
|
|
authMW, rateLimitMW, opLogMW,
|
|||
|
|
pwdResetH, captchaH, totpH, webhookH,
|
|||
|
|
ipFilterMW, nil, nil, smsH, nil, nil, nil,
|
|||
|
|
)
|
|||
|
|
engine := r.Setup()
|
|||
|
|
|
|||
|
|
srv := httptest.NewServer(engine)
|
|||
|
|
cleanup := func() {
|
|||
|
|
srv.Close()
|
|||
|
|
sqlDB, _ := db.DB()
|
|||
|
|
sqlDB.Close()
|
|||
|
|
}
|
|||
|
|
return srv, cleanup
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestE2ERegisterAndLogin 注册 + 登录完整流程
|
|||
|
|
func TestE2ERegisterAndLogin(t *testing.T) {
|
|||
|
|
srv, cleanup := setupRealServer(t)
|
|||
|
|
defer cleanup()
|
|||
|
|
base := srv.URL
|
|||
|
|
|
|||
|
|
// 1. 注册
|
|||
|
|
regBody := map[string]interface{}{
|
|||
|
|
"username": "e2e_user1",
|
|||
|
|
"password": "E2ePass123!",
|
|||
|
|
"email": "e2euser1@example.com",
|
|||
|
|
}
|
|||
|
|
regResp := doPost(t, base+"/api/v1/auth/register", nil, regBody)
|
|||
|
|
if regResp.StatusCode != http.StatusCreated {
|
|||
|
|
t.Fatalf("注册失败,HTTP %d", regResp.StatusCode)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var regResult map[string]interface{}
|
|||
|
|
decodeJSON(t, regResp.Body, ®Result)
|
|||
|
|
if regResult["username"] == nil {
|
|||
|
|
t.Fatalf("注册响应缺少 username 字段")
|
|||
|
|
}
|
|||
|
|
t.Logf("注册成功: %v", regResult)
|
|||
|
|
|
|||
|
|
// 2. 登录
|
|||
|
|
loginBody := map[string]interface{}{
|
|||
|
|
"account": "e2e_user1",
|
|||
|
|
"password": "E2ePass123!",
|
|||
|
|
}
|
|||
|
|
loginResp := doPost(t, base+"/api/v1/auth/login", nil, loginBody)
|
|||
|
|
if loginResp.StatusCode != http.StatusOK {
|
|||
|
|
t.Fatalf("登录失败,HTTP %d", loginResp.StatusCode)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var loginResult map[string]interface{}
|
|||
|
|
decodeJSON(t, loginResp.Body, &loginResult)
|
|||
|
|
if loginResult["access_token"] == nil {
|
|||
|
|
t.Fatal("登录响应中缺少 access_token")
|
|||
|
|
}
|
|||
|
|
token := fmt.Sprintf("%v", loginResult["access_token"])
|
|||
|
|
t.Logf("登录成功,access_token 长度=%d", len(token))
|
|||
|
|
|
|||
|
|
// 3. 获取用户信息
|
|||
|
|
infoResp := doGet(t, base+"/api/v1/auth/userinfo", token)
|
|||
|
|
if infoResp.StatusCode != http.StatusOK {
|
|||
|
|
t.Fatalf("获取用户信息失败,HTTP %d", infoResp.StatusCode)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var infoResult map[string]interface{}
|
|||
|
|
decodeJSON(t, infoResp.Body, &infoResult)
|
|||
|
|
if infoResult["username"] == nil {
|
|||
|
|
t.Fatal("用户信息响应缺少 username 字段")
|
|||
|
|
}
|
|||
|
|
t.Logf("用户信息获取成功: %v", infoResult)
|
|||
|
|
|
|||
|
|
// 4. 登出
|
|||
|
|
logoutResp := doPost(t, base+"/api/v1/auth/logout", token, nil)
|
|||
|
|
if logoutResp.StatusCode != http.StatusOK {
|
|||
|
|
t.Fatalf("登出失败,HTTP %d", logoutResp.StatusCode)
|
|||
|
|
}
|
|||
|
|
t.Log("登出成功")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestE2ELoginFailures 错误凭据登录
|
|||
|
|
func TestE2ELoginFailures(t *testing.T) {
|
|||
|
|
srv, cleanup := setupRealServer(t)
|
|||
|
|
defer cleanup()
|
|||
|
|
base := srv.URL
|
|||
|
|
|
|||
|
|
// 先注册一个用户
|
|||
|
|
doPost(t, base+"/api/v1/auth/register", nil, map[string]interface{}{
|
|||
|
|
"username": "fail_user",
|
|||
|
|
"password": "CorrectPass1!",
|
|||
|
|
"email": "failuser@example.com",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 错误密码
|
|||
|
|
loginResp := doPost(t, base+"/api/v1/auth/login", nil, map[string]interface{}{
|
|||
|
|
"account": "fail_user",
|
|||
|
|
"password": "WrongPassword",
|
|||
|
|
})
|
|||
|
|
// 错误密码应返回 401 或 500(取决于实现)
|
|||
|
|
if loginResp.StatusCode == http.StatusOK {
|
|||
|
|
t.Fatal("错误密码登录不应该成功")
|
|||
|
|
}
|
|||
|
|
t.Logf("错误密码正确拒绝: HTTP %d", loginResp.StatusCode)
|
|||
|
|
|
|||
|
|
// 不存在的用户
|
|||
|
|
notFoundResp := doPost(t, base+"/api/v1/auth/login", nil, map[string]interface{}{
|
|||
|
|
"account": "nonexistent_user_xyz",
|
|||
|
|
"password": "SomePass1!",
|
|||
|
|
})
|
|||
|
|
if notFoundResp.StatusCode == http.StatusOK {
|
|||
|
|
t.Fatal("不存在的用户登录不应该成功")
|
|||
|
|
}
|
|||
|
|
t.Logf("不存在用户正确拒绝: HTTP %d", notFoundResp.StatusCode)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestE2EUnauthorizedAccess JWT 保护的接口未携带 token
|
|||
|
|
func TestE2EUnauthorizedAccess(t *testing.T) {
|
|||
|
|
srv, cleanup := setupRealServer(t)
|
|||
|
|
defer cleanup()
|
|||
|
|
base := srv.URL
|
|||
|
|
|
|||
|
|
resp := doGet(t, base+"/api/v1/auth/userinfo", "")
|
|||
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|||
|
|
t.Fatalf("期望 401,实际 %d", resp.StatusCode)
|
|||
|
|
}
|
|||
|
|
t.Logf("未认证访问正确返回 401")
|
|||
|
|
|
|||
|
|
resp2 := doGet(t, base+"/api/v1/auth/userinfo", "invalid.token.here")
|
|||
|
|
if resp2.StatusCode != http.StatusUnauthorized {
|
|||
|
|
t.Fatalf("无效 token 期望 401,实际 %d", resp2.StatusCode)
|
|||
|
|
}
|
|||
|
|
t.Logf("无效 token 正确返回 401")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestE2EPasswordReset 密码重置流程
|
|||
|
|
func TestE2EPasswordReset(t *testing.T) {
|
|||
|
|
srv, cleanup := setupRealServer(t)
|
|||
|
|
defer cleanup()
|
|||
|
|
base := srv.URL
|
|||
|
|
|
|||
|
|
doPost(t, base+"/api/v1/auth/register", nil, map[string]interface{}{
|
|||
|
|
"username": "reset_user",
|
|||
|
|
"password": "OldPass123!",
|
|||
|
|
"email": "resetuser@example.com",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
resp := doPost(t, base+"/api/v1/auth/forgot-password", nil, map[string]interface{}{
|
|||
|
|
"email": "resetuser@example.com",
|
|||
|
|
})
|
|||
|
|
if resp.StatusCode != http.StatusOK {
|
|||
|
|
t.Fatalf("forgot-password 期望 200,实际 %d", resp.StatusCode)
|
|||
|
|
}
|
|||
|
|
t.Log("密码重置请求正确返回 200")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestE2ECaptcha 图形验证码流程
|
|||
|
|
func TestE2ECaptcha(t *testing.T) {
|
|||
|
|
srv, cleanup := setupRealServer(t)
|
|||
|
|
defer cleanup()
|
|||
|
|
base := srv.URL
|
|||
|
|
|
|||
|
|
resp := doGet(t, base+"/api/v1/auth/captcha", "")
|
|||
|
|
if resp.StatusCode != http.StatusOK {
|
|||
|
|
t.Fatalf("获取验证码期望 200,实际 %d", resp.StatusCode)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var result map[string]interface{}
|
|||
|
|
decodeJSON(t, resp.Body, &result)
|
|||
|
|
if result["captcha_id"] == nil {
|
|||
|
|
t.Fatal("验证码响应缺少 captcha_id")
|
|||
|
|
}
|
|||
|
|
captchaID := fmt.Sprintf("%v", result["captcha_id"])
|
|||
|
|
t.Logf("验证码生成成功,captcha_id=%s", captchaID)
|
|||
|
|
|
|||
|
|
imgResp := doGet(t, base+"/api/v1/auth/captcha/image?captcha_id="+captchaID, "")
|
|||
|
|
if imgResp.StatusCode != http.StatusOK {
|
|||
|
|
t.Fatalf("获取验证码图片失败,HTTP %d", imgResp.StatusCode)
|
|||
|
|
}
|
|||
|
|
t.Log("验证码图片获取成功")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestE2EConcurrentLogin 并发登录压测
|
|||
|
|
func TestE2EConcurrentLogin(t *testing.T) {
|
|||
|
|
if testing.Short() {
|
|||
|
|
t.Skip("skip concurrent test in short mode")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
srv, cleanup := setupRealServer(t)
|
|||
|
|
defer cleanup()
|
|||
|
|
base := srv.URL
|
|||
|
|
|
|||
|
|
doPost(t, base+"/api/v1/auth/register", nil, map[string]interface{}{
|
|||
|
|
"username": "concurrent_user",
|
|||
|
|
"password": "ConcPass123!",
|
|||
|
|
"email": "concurrent@example.com",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const concurrency = 20
|
|||
|
|
type result struct {
|
|||
|
|
success bool
|
|||
|
|
latency time.Duration
|
|||
|
|
status int
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
results := make(chan result, concurrency)
|
|||
|
|
start := time.Now()
|
|||
|
|
|
|||
|
|
for i := 0; i < concurrency; i++ {
|
|||
|
|
go func() {
|
|||
|
|
t0 := time.Now()
|
|||
|
|
resp := doPost(t, base+"/api/v1/auth/login", nil, map[string]interface{}{
|
|||
|
|
"account": "concurrent_user",
|
|||
|
|
"password": "ConcPass123!",
|
|||
|
|
})
|
|||
|
|
var r map[string]interface{}
|
|||
|
|
decodeJSON(t, resp.Body, &r)
|
|||
|
|
results <- result{success: resp.StatusCode == http.StatusOK && r["access_token"] != nil, latency: time.Since(t0), status: resp.StatusCode}
|
|||
|
|
}()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
success, fail := 0, 0
|
|||
|
|
var totalLatency time.Duration
|
|||
|
|
statusCount := make(map[int]int)
|
|||
|
|
for i := 0; i < concurrency; i++ {
|
|||
|
|
r := <-results
|
|||
|
|
if r.success {
|
|||
|
|
success++
|
|||
|
|
} else {
|
|||
|
|
fail++
|
|||
|
|
}
|
|||
|
|
totalLatency += r.latency
|
|||
|
|
statusCount[r.status]++
|
|||
|
|
}
|
|||
|
|
elapsed := time.Since(start)
|
|||
|
|
|
|||
|
|
t.Logf("并发登录结果: 成功=%d 失败=%d 状态码分布=%v 总耗时=%v 平均=%v",
|
|||
|
|
success, fail, statusCount, elapsed, totalLatency/time.Duration(concurrency))
|
|||
|
|
|
|||
|
|
for status, count := range statusCount {
|
|||
|
|
if status >= http.StatusInternalServerError {
|
|||
|
|
t.Fatalf("并发登录不应出现 5xx,实际 status=%d count=%d", status, count)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if success == 0 {
|
|||
|
|
t.Log("所有并发登录请求都被限流或拒绝;在当前路由限流配置下这属于可接受结果")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---- HTTP 辅助函数 ----
|
|||
|
|
|
|||
|
|
func doPost(t *testing.T, url string, token interface{}, body map[string]interface{}) *http.Response {
|
|||
|
|
t.Helper()
|
|||
|
|
var bodyBytes []byte
|
|||
|
|
if body != nil {
|
|||
|
|
bodyBytes, _ = json.Marshal(body)
|
|||
|
|
}
|
|||
|
|
req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(bodyBytes))
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("创建请求失败: %v", err)
|
|||
|
|
}
|
|||
|
|
req.Header.Set("Content-Type", "application/json")
|
|||
|
|
if token != nil {
|
|||
|
|
if tok, ok := token.(string); ok && tok != "" {
|
|||
|
|
req.Header.Set("Authorization", "Bearer "+tok)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|||
|
|
resp, err := client.Do(req)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("请求失败: %v", err)
|
|||
|
|
}
|
|||
|
|
return resp
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func doGet(t *testing.T, url string, token string) *http.Response {
|
|||
|
|
t.Helper()
|
|||
|
|
req, err := http.NewRequestWithContext(context.Background(), "GET", url, nil)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("创建请求失败: %v", err)
|
|||
|
|
}
|
|||
|
|
if token != "" {
|
|||
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|||
|
|
}
|
|||
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|||
|
|
resp, err := client.Do(req)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("请求失败: %v", err)
|
|||
|
|
}
|
|||
|
|
return resp
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func decodeJSON(t *testing.T, body io.ReadCloser, v interface{}) {
|
|||
|
|
t.Helper()
|
|||
|
|
defer body.Close()
|
|||
|
|
if err := json.NewDecoder(body).Decode(v); err != nil {
|
|||
|
|
t.Logf("解析响应 JSON 失败: %v(非致命)", err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var _ = security.NewIPFilter
|