Files
user-system/internal/e2e/e2e_advanced_test.go

608 lines
20 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package e2e
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// ============================================================
// 阶段 EE2E 集成测试 — 补充覆盖
// ============================================================
// 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 %dOTP 可能因时钟偏差失败,视为非致命)", 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))
}