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