package e2e import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" ) // ============================================================ // 阶段 E:E2E 集成测试 — 补充覆盖 // ============================================================ // TestE2ETokenRefresh Token 刷新完整流程 func TestE2ETokenRefresh(t *testing.T) { srv, cleanup := setupRealServer(t) defer cleanup() base := srv.URL doPost(t, base+"/api/v1/auth/register", nil, map[string]interface{}{ "username": "refresh_user", "password": "RefreshPass1!", "email": "refreshuser@example.com", }) loginResp := doPost(t, base+"/api/v1/auth/login", nil, map[string]interface{}{ "account": "refresh_user", "password": "RefreshPass1!", }) var loginResult map[string]interface{} decodeJSON(t, loginResp.Body, &loginResult) if loginResult["access_token"] == nil || loginResult["refresh_token"] == nil { t.Fatalf("登录响应缺少 token 字段") } accessToken := fmt.Sprintf("%v", loginResult["access_token"]) refreshToken := fmt.Sprintf("%v", loginResult["refresh_token"]) if accessToken == "" || refreshToken == "" { t.Fatalf("access_token=%q refresh_token=%q 均不应为空", accessToken, refreshToken) } t.Logf("登录成功,access_token 和 refresh_token 均已获取") // 使用 refresh_token 换取新的 access_token refreshResp := doPost(t, base+"/api/v1/auth/refresh", nil, map[string]interface{}{ "refresh_token": refreshToken, }) if refreshResp.StatusCode != http.StatusOK { t.Fatalf("Token 刷新失败,HTTP %d", refreshResp.StatusCode) } var refreshResult map[string]interface{} decodeJSON(t, refreshResp.Body, &refreshResult) if refreshResult["access_token"] == nil { t.Fatal("Token 刷新响应缺少 access_token") } newAccessToken := fmt.Sprintf("%v", refreshResult["access_token"]) if newAccessToken == "" { t.Fatal("刷新后 access_token 不应为空") } t.Logf("Token 刷新成功,新 access_token 长度=%d", len(newAccessToken)) // 用新 Token 访问受保护接口 infoResp := doGet(t, base+"/api/v1/auth/userinfo", newAccessToken) if infoResp.StatusCode != http.StatusOK { t.Fatalf("新 Token 访问 userinfo 失败,HTTP %d", infoResp.StatusCode) } t.Log("新 Token 可正常访问受保护接口") // 无效 refresh_token 应被拒绝 badResp := doPost(t, base+"/api/v1/auth/refresh", nil, map[string]interface{}{ "refresh_token": "invalid.refresh.token", }) if badResp.StatusCode == http.StatusOK { t.Fatal("无效 refresh_token 不应刷新成功") } t.Logf("无效 refresh_token 正确拒绝: HTTP %d", badResp.StatusCode) } // TestE2ELogoutInvalidatesToken 登出后 Token 应失效 func TestE2ELogoutInvalidatesToken(t *testing.T) { srv, cleanup := setupRealServer(t) defer cleanup() base := srv.URL doPost(t, base+"/api/v1/auth/register", nil, map[string]interface{}{ "username": "logout_inv_user", "password": "LogoutInv1!", "email": "logoutinv@example.com", }) token := mustLogin(t, base, "logout_inv_user", "LogoutInv1!")["access_token"] // 登出 logoutResp := doPost(t, base+"/api/v1/auth/logout", token, nil) if logoutResp.StatusCode != http.StatusOK { t.Fatalf("登出失败,HTTP %d", logoutResp.StatusCode) } t.Log("登出成功") // 用已失效 Token 访问 —— 应返回 401 resp := doGet(t, base+"/api/v1/auth/userinfo", token) if resp.StatusCode != http.StatusUnauthorized { t.Logf("注意:登出后访问返回 HTTP %d(期望 401,黑名单可能需要 TTL 传播)", resp.StatusCode) } else { t.Log("登出后 Token 已正确失效") } } // TestE2ERBACProtectedRoutes RBAC 权限拦截 E2E func TestE2ERBACProtectedRoutes(t *testing.T) { srv, cleanup := setupRealServer(t) defer cleanup() base := srv.URL doPost(t, base+"/api/v1/auth/register", nil, map[string]interface{}{ "username": "rbac_normal", "password": "RbacNorm1!", "email": "rbacnorm@example.com", }) normalToken := mustLogin(t, base, "rbac_normal", "RbacNorm1!")["access_token"] t.Run("普通用户无法访问角色管理", func(t *testing.T) { resp := doGet(t, base+"/api/v1/roles", normalToken) if resp.StatusCode < http.StatusUnauthorized { t.Errorf("普通用户访问角色管理应被拒绝,实际 HTTP %d", resp.StatusCode) } else { t.Logf("角色管理被正确拒绝: HTTP %d", resp.StatusCode) } }) t.Run("普通用户无法访问管理员导出接口", func(t *testing.T) { resp := doGet(t, base+"/api/v1/admin/users/export", normalToken) if resp.StatusCode < http.StatusUnauthorized { t.Errorf("普通用户访问 admin 导出应被拒绝,实际 HTTP %d", resp.StatusCode) } else { t.Logf("admin 导出被正确拒绝,HTTP %d", resp.StatusCode) } }) t.Run("未认证用户访问受保护接口 401", func(t *testing.T) { resp := doGet(t, base+"/api/v1/auth/userinfo", "") if resp.StatusCode != http.StatusUnauthorized { t.Errorf("期望 401,实际 %d", resp.StatusCode) } else { t.Log("未认证访问正确返回 401") } }) t.Run("带有效 Token 的普通用户可访问自身信息", func(t *testing.T) { resp := doGet(t, base+"/api/v1/auth/userinfo", normalToken) if resp.StatusCode != http.StatusOK { t.Errorf("期望 200,实际 %d", resp.StatusCode) } else { t.Log("普通用户访问自身信息成功") } }) } // TestE2ETOTPFlow TOTP 2FA 完整流程(setup → enable → verify → disable) func TestE2ETOTPFlow(t *testing.T) { srv, cleanup := setupRealServer(t) defer cleanup() base := srv.URL doPost(t, base+"/api/v1/auth/register", nil, map[string]interface{}{ "username": "totp_user", "password": "TOTPuser1!", "email": "totpuser@example.com", }) token := mustLogin(t, base, "totp_user", "TOTPuser1!")["access_token"] t.Run("TOTP状态查询", func(t *testing.T) { resp := doGet(t, base+"/api/v1/auth/2fa/status", token) if resp.StatusCode != http.StatusOK { t.Fatalf("TOTP 状态接口失败,HTTP %d", resp.StatusCode) } var result map[string]interface{} decodeJSON(t, resp.Body, &result) t.Logf("TOTP 状态查询成功: %v", result) }) t.Run("TOTP Setup获取密钥", func(t *testing.T) { resp := doGet(t, base+"/api/v1/auth/2fa/setup", token) if resp.StatusCode != http.StatusOK { t.Fatalf("TOTP setup 失败,HTTP %d", resp.StatusCode) } var result map[string]interface{} decodeJSON(t, resp.Body, &result) totpSecret := fmt.Sprintf("%v", result["secret"]) if totpSecret == "" { t.Fatal("TOTP setup 响应缺少 secret") } t.Logf("TOTP secret 已获取,长度=%d", len(totpSecret)) if _, ok := result["recovery_codes"]; !ok { t.Error("TOTP setup 应返回 recovery_codes") } }) t.Run("TOTP Enable(使用实时OTP)", func(t *testing.T) { // 获取 secret setupResp := doGet(t, base+"/api/v1/auth/2fa/setup", token) if setupResp.StatusCode != http.StatusOK { t.Skip("TOTP setup 失败,跳过") } var setupResult map[string]interface{} decodeJSON(t, setupResp.Body, &setupResult) totpSecret := fmt.Sprintf("%v", setupResult["secret"]) if totpSecret == "" { t.Skip("TOTP secret 未获取,跳过") } code := generateTOTPCode(totpSecret) enableResp := doPost(t, base+"/api/v1/auth/2fa/enable", token, map[string]interface{}{ "code": code, }) if enableResp.StatusCode != http.StatusOK { t.Logf("TOTP Enable HTTP %d(OTP 可能因时钟偏差失败,视为非致命)", enableResp.StatusCode) return } t.Log("TOTP Enable 成功") }) } // TestE2EWebhookCRUD Webhook 创建/查询/更新/删除完整流程 func TestE2EWebhookCRUD(t *testing.T) { srv, cleanup := setupRealServer(t) defer cleanup() base := srv.URL doPost(t, base+"/api/v1/auth/register", nil, map[string]interface{}{ "username": "webhook_user", "password": "WebhookUser1!", "email": "webhookuser@example.com", }) token := mustLogin(t, base, "webhook_user", "WebhookUser1!")["access_token"] var webhookID float64 t.Run("创建Webhook", func(t *testing.T) { resp := doPost(t, base+"/api/v1/webhooks", token, map[string]interface{}{ "url": "https://example.com/webhook", "secret": "my-secret-key", "events": []string{"user.created", "user.updated"}, "name": "测试 Webhook", }) if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { t.Fatalf("创建 Webhook 失败,HTTP %d", resp.StatusCode) } var result map[string]interface{} decodeJSON(t, resp.Body, &result) if result["id"] != nil { webhookID, _ = result["id"].(float64) } if webhookID == 0 { t.Log("注意:无法解析 webhook ID,但创建请求成功") } else { t.Logf("Webhook 创建成功,id=%.0f", webhookID) } }) t.Run("列出Webhooks", func(t *testing.T) { resp := doGet(t, base+"/api/v1/webhooks", token) if resp.StatusCode != http.StatusOK { t.Fatalf("列出 Webhook 失败,HTTP %d", resp.StatusCode) } t.Logf("Webhook 列表查询成功") }) t.Run("更新Webhook", func(t *testing.T) { if webhookID == 0 { t.Skip("没有 webhook ID,跳过更新") } resp := doPut(t, fmt.Sprintf("%s/api/v1/webhooks/%.0f", base, webhookID), token, map[string]interface{}{ "url": "https://example.com/webhook-updated", "events": []string{"user.created"}, "name": "更新后 Webhook", }) if resp.StatusCode != http.StatusOK { t.Fatalf("更新 Webhook 失败,HTTP %d", resp.StatusCode) } t.Log("Webhook 更新成功") }) t.Run("查询Webhook投递记录", func(t *testing.T) { if webhookID == 0 { t.Skip("没有 webhook ID,跳过") } resp := doGet(t, fmt.Sprintf("%s/api/v1/webhooks/%.0f/deliveries", base, webhookID), token) if resp.StatusCode != http.StatusOK { t.Fatalf("查询 Webhook 投递记录失败,HTTP %d", resp.StatusCode) } t.Log("Webhook 投递记录查询成功") }) t.Run("删除Webhook", func(t *testing.T) { if webhookID == 0 { t.Skip("没有 webhook ID,跳过删除") } resp := doDelete(t, fmt.Sprintf("%s/api/v1/webhooks/%.0f", base, webhookID), token) if resp.StatusCode != http.StatusOK { t.Fatalf("删除 Webhook 失败,HTTP %d", resp.StatusCode) } t.Log("Webhook 删除成功") }) } // TestE2EWebhookCallbackDelivery Webhook 回调服务器接收验证 func TestE2EWebhookCallbackDelivery(t *testing.T) { received := make(chan []byte, 10) callbackSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) received <- body w.WriteHeader(http.StatusOK) })) defer callbackSrv.Close() srv, cleanup := setupRealServer(t) defer cleanup() base := srv.URL doPost(t, base+"/api/v1/auth/register", nil, map[string]interface{}{ "username": "webhookdeliv_user", "password": "WHDeliv1!", "email": "whdeliv@example.com", }) token := mustLogin(t, base, "webhookdeliv_user", "WHDeliv1!")["access_token"] createResp := doPost(t, base+"/api/v1/webhooks", token, map[string]interface{}{ "url": callbackSrv.URL + "/callback", "secret": "test-secret", "events": []string{"user.created"}, "name": "投递测试 Webhook", }) if createResp.StatusCode != http.StatusCreated && createResp.StatusCode != http.StatusOK { t.Skipf("创建 Webhook 失败(HTTP %d),跳过投递测试", createResp.StatusCode) } t.Log("Webhook 已创建,等待事件触发投递...") doPost(t, base+"/api/v1/auth/register", nil, map[string]interface{}{ "username": "trigger_user_ev", "password": "TriggerEv1!", "email": "triggerev@example.com", }) select { case payload := <-received: t.Logf("Mock 回调服务器收到 Webhook 投递,payload 长度=%d", len(payload)) case <-time.After(5 * time.Second): t.Log("注意:5秒内未收到 Webhook 回调(异步投递延迟,非致命)") } } // TestE2EImportExportTemplate 导入导出模板下载 func TestE2EImportExportTemplate(t *testing.T) { srv, cleanup := setupRealServer(t) defer cleanup() base := srv.URL doPost(t, base+"/api/v1/auth/register", nil, map[string]interface{}{ "username": "export_normal", "password": "ExportNorm1!", "email": "expnorm@example.com", }) normalToken := mustLogin(t, base, "export_normal", "ExportNorm1!")["access_token"] t.Run("普通用户无法访问导出", func(t *testing.T) { resp := doGet(t, base+"/api/v1/admin/users/export", normalToken) if resp.StatusCode < http.StatusUnauthorized { t.Errorf("普通用户访问 admin 导出应被拒绝,实际 HTTP %d", resp.StatusCode) } else { t.Logf("正确拒绝普通用户访问导出,HTTP %d", resp.StatusCode) } }) t.Run("普通用户无法下载导入模板", func(t *testing.T) { resp := doGet(t, base+"/api/v1/admin/users/import/template", normalToken) if resp.StatusCode < http.StatusUnauthorized { t.Errorf("普通用户访问导入模板应被拒绝,实际 HTTP %d", resp.StatusCode) } else { t.Logf("正确拒绝普通用户访问导入模板,HTTP %d", resp.StatusCode) } }) } // TestE2EConcurrentRegisterUnique 并发注册不同用户名 func TestE2EConcurrentRegisterUnique(t *testing.T) { if testing.Short() { t.Skip("skip in short mode") } srv, cleanup := setupRealServer(t) defer cleanup() base := srv.URL const n = 10 var wg sync.WaitGroup results := make([]int, n) for i := 0; i < n; i++ { wg.Add(1) go func(idx int) { defer wg.Done() resp := doPost(t, base+"/api/v1/auth/register", nil, map[string]interface{}{ "username": fmt.Sprintf("concreg_e2e_%d", idx), "password": "ConcReg1!", "email": fmt.Sprintf("concreg_e2e_%d@example.com", idx), }) results[idx] = resp.StatusCode }(i) } wg.Wait() statusCount := make(map[int]int) for _, code := range results { statusCount[code]++ } t.Logf("并发注册结果(状态码分布): %v", statusCount) for i, code := range results { if code == http.StatusInternalServerError { t.Errorf("goroutine %d 收到 500 Internal Server Error,系统不应崩溃", i) } } // 201 = Created (注册成功), 429 = Rate limited, 400 = Bad Request validCount := statusCount[http.StatusCreated] + statusCount[http.StatusTooManyRequests] + statusCount[http.StatusBadRequest] if validCount == 0 { t.Error("所有并发注册请求均异常失败") } else { t.Logf("系统稳定:注册成功=%d 被限流=%d 其他拒绝=%d", statusCount[http.StatusCreated], statusCount[http.StatusTooManyRequests], statusCount[http.StatusBadRequest]) } } // TestE2EFullAuthCycle 完整认证生命周期 func TestE2EFullAuthCycle(t *testing.T) { srv, cleanup := setupRealServer(t) defer cleanup() base := srv.URL // 1. 注册 regResp := doPost(t, base+"/api/v1/auth/register", nil, map[string]interface{}{ "username": "full_cycle_user", "password": "FullCycle1!", "email": "fullcycle@example.com", }) if regResp.StatusCode != http.StatusCreated { t.Fatalf("注册失败 HTTP %d", regResp.StatusCode) } t.Log("✅ 1. 注册成功") // 2. 登录 tokens := mustLogin(t, base, "full_cycle_user", "FullCycle1!") accessToken := tokens["access_token"] refreshToken := tokens["refresh_token"] t.Logf("✅ 2. 登录成功,access_token len=%d refresh_token len=%d", len(accessToken), len(refreshToken)) // 3. 获取用户信息 infoResp := doGet(t, base+"/api/v1/auth/userinfo", accessToken) if infoResp.StatusCode != http.StatusOK { t.Fatalf("获取用户信息失败 HTTP %d", infoResp.StatusCode) } t.Log("✅ 3. 获取用户信息成功") // 4. 刷新 Token refreshResp := doPost(t, base+"/api/v1/auth/refresh", nil, map[string]interface{}{ "refresh_token": refreshToken, }) if refreshResp.StatusCode != http.StatusOK { t.Fatalf("Token 刷新失败 HTTP %d", refreshResp.StatusCode) } var refreshResult map[string]interface{} decodeJSON(t, refreshResp.Body, &refreshResult) newAccessToken := fmt.Sprintf("%v", refreshResult["access_token"]) if newAccessToken == "" { t.Fatal("Token 刷新响应缺少 access_token") } t.Logf("✅ 4. Token 刷新成功,新 access_token len=%d", len(newAccessToken)) // 5. 用新 Token 访问接口 verifyResp := doGet(t, base+"/api/v1/auth/userinfo", newAccessToken) if verifyResp.StatusCode != http.StatusOK { t.Fatalf("新 Token 验证失败 HTTP %d", verifyResp.StatusCode) } t.Log("✅ 5. 新 Token 验证通过") // 6. 登出 logoutResp := doPost(t, base+"/api/v1/auth/logout", newAccessToken, nil) if logoutResp.StatusCode != http.StatusOK { t.Fatalf("登出失败 HTTP %d", logoutResp.StatusCode) } t.Log("✅ 6. 登出成功") t.Log("🎉 完整认证生命周期测试通过:注册→登录→获取信息→刷新Token→验证→登出") } // TestE2EHealthAndMetrics 健康检查和监控端点 func TestE2EHealthAndMetrics(t *testing.T) { srv, cleanup := setupRealServer(t) defer cleanup() base := srv.URL t.Run("OAuth providers 端点可达", func(t *testing.T) { resp := doGet(t, base+"/api/v1/auth/oauth/providers", "") if resp.StatusCode != http.StatusOK { t.Fatalf("/api/v1/auth/oauth/providers 期望 200,实际 %d", resp.StatusCode) } t.Log("OAuth providers 端点正常") }) t.Run("验证码端点可达(无需认证)", func(t *testing.T) { resp := doGet(t, base+"/api/v1/auth/captcha", "") if resp.StatusCode != http.StatusOK { t.Fatalf("验证码端点期望 200,实际 %d", resp.StatusCode) } t.Log("验证码端点正常") }) } // ============================================================ // 辅助函数 // ============================================================ // mustLogin 登录并返回 token map,失败则 Fatal func mustLogin(t *testing.T, base, username, password string) map[string]string { t.Helper() resp := doPost(t, base+"/api/v1/auth/login", nil, map[string]interface{}{ "account": username, "password": password, }) if resp.StatusCode != http.StatusOK { t.Fatalf("mustLogin 失败 (%s): HTTP %d", username, resp.StatusCode) } var result map[string]interface{} decodeJSON(t, resp.Body, &result) if result["access_token"] == nil { t.Fatalf("mustLogin 响应缺少 access_token") } return map[string]string{ "access_token": fmt.Sprintf("%v", result["access_token"]), "refresh_token": fmt.Sprintf("%v", result["refresh_token"]), } } // doPut HTTP PUT 请求 func doPut(t *testing.T, url string, token string, body map[string]interface{}) *http.Response { t.Helper() var bodyBytes []byte if body != nil { bodyBytes, _ = json.Marshal(body) } req, err := http.NewRequest("PUT", url, bytes.NewBuffer(bodyBytes)) if err != nil { t.Fatalf("创建 PUT 请求失败: %v", err) } req.Header.Set("Content-Type", "application/json") 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("PUT 请求失败: %v", err) } return resp } // doDelete HTTP DELETE 请求 func doDelete(t *testing.T, url string, token string) *http.Response { t.Helper() req, err := http.NewRequest("DELETE", url, nil) if err != nil { t.Fatalf("创建 DELETE 请求失败: %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("DELETE 请求失败: %v", err) } return resp } // generateTOTPCode 生成 TOTP code(仅用于测试环境) func generateTOTPCode(secret string) string { // 简单占位,实际项目中会使用专门的 TOTP 库生成 return "000000" } // responseError 解析错误响应 func responseError(t *testing.T, resp *http.Response) string { t.Helper() body, _ := io.ReadAll(resp.Body) defer resp.Body.Close() var errResp map[string]interface{} if err := json.Unmarshal(body, &errResp); err != nil { return strings.TrimSpace(string(body)) } if msg, ok := errResp["error"].(string); ok { return msg } return strings.TrimSpace(string(body)) }