318 lines
8.0 KiB
Go
318 lines
8.0 KiB
Go
|
|
//go:build e2e
|
|||
|
|
|
|||
|
|
package integration
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"bytes"
|
|||
|
|
"encoding/json"
|
|||
|
|
"fmt"
|
|||
|
|
"io"
|
|||
|
|
"net/http"
|
|||
|
|
"strings"
|
|||
|
|
"testing"
|
|||
|
|
"time"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// E2E 用户流程测试
|
|||
|
|
// 测试完整的用户操作链路:注册 → 登录 → 创建 API Key → 调用网关 → 查询用量
|
|||
|
|
|
|||
|
|
var (
|
|||
|
|
testUserEmail = "e2e-test-" + fmt.Sprintf("%d", time.Now().UnixMilli()) + "@test.local"
|
|||
|
|
testUserPassword = "E2eTest@12345"
|
|||
|
|
testUserName = "e2e-test-user"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// TestUserRegistrationAndLogin 测试用户注册和登录流程
|
|||
|
|
func TestUserRegistrationAndLogin(t *testing.T) {
|
|||
|
|
// 步骤 1: 注册新用户
|
|||
|
|
t.Run("注册新用户", func(t *testing.T) {
|
|||
|
|
payload := map[string]string{
|
|||
|
|
"email": testUserEmail,
|
|||
|
|
"password": testUserPassword,
|
|||
|
|
"username": testUserName,
|
|||
|
|
}
|
|||
|
|
body, _ := json.Marshal(payload)
|
|||
|
|
|
|||
|
|
resp, err := doRequest(t, "POST", "/api/auth/register", body, "")
|
|||
|
|
if err != nil {
|
|||
|
|
t.Skipf("注册接口不可用,跳过用户流程测试: %v", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
defer resp.Body.Close()
|
|||
|
|
|
|||
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|||
|
|
|
|||
|
|
// 注册可能返回 200(成功)或 400(邮箱已存在)或 403(注册已关闭)
|
|||
|
|
switch resp.StatusCode {
|
|||
|
|
case 200:
|
|||
|
|
t.Logf("✅ 用户注册成功: %s", testUserEmail)
|
|||
|
|
case 400:
|
|||
|
|
t.Logf("⚠️ 用户可能已存在: %s", string(respBody))
|
|||
|
|
case 403:
|
|||
|
|
t.Skipf("注册功能已关闭: %s", string(respBody))
|
|||
|
|
default:
|
|||
|
|
t.Logf("⚠️ 注册返回 HTTP %d: %s(继续尝试登录)", resp.StatusCode, string(respBody))
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 步骤 2: 登录获取 JWT
|
|||
|
|
var accessToken string
|
|||
|
|
t.Run("用户登录获取JWT", func(t *testing.T) {
|
|||
|
|
payload := map[string]string{
|
|||
|
|
"email": testUserEmail,
|
|||
|
|
"password": testUserPassword,
|
|||
|
|
}
|
|||
|
|
body, _ := json.Marshal(payload)
|
|||
|
|
|
|||
|
|
resp, err := doRequest(t, "POST", "/api/auth/login", body, "")
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("登录请求失败: %v", err)
|
|||
|
|
}
|
|||
|
|
defer resp.Body.Close()
|
|||
|
|
|
|||
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|||
|
|
|
|||
|
|
if resp.StatusCode != 200 {
|
|||
|
|
t.Skipf("登录失败 HTTP %d: %s(可能需要先注册用户)", resp.StatusCode, string(respBody))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var result map[string]any
|
|||
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|||
|
|
t.Fatalf("解析登录响应失败: %v", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 尝试从标准响应格式获取 token
|
|||
|
|
if token, ok := result["access_token"].(string); ok && token != "" {
|
|||
|
|
accessToken = token
|
|||
|
|
} else if data, ok := result["data"].(map[string]any); ok {
|
|||
|
|
if token, ok := data["access_token"].(string); ok {
|
|||
|
|
accessToken = token
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if accessToken == "" {
|
|||
|
|
t.Skipf("未获取到 access_token,响应: %s", string(respBody))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 验证 token 不为空且格式基本正确
|
|||
|
|
if len(accessToken) < 10 {
|
|||
|
|
t.Fatalf("access_token 格式异常: %s", accessToken)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
t.Logf("✅ 登录成功,获取 JWT(长度: %d)", len(accessToken))
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if accessToken == "" {
|
|||
|
|
t.Skip("未获取到 JWT,跳过后续测试")
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 步骤 3: 使用 JWT 获取当前用户信息
|
|||
|
|
t.Run("获取当前用户信息", func(t *testing.T) {
|
|||
|
|
resp, err := doRequest(t, "GET", "/api/user/me", nil, accessToken)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("请求失败: %v", err)
|
|||
|
|
}
|
|||
|
|
defer resp.Body.Close()
|
|||
|
|
|
|||
|
|
if resp.StatusCode != 200 {
|
|||
|
|
body, _ := io.ReadAll(resp.Body)
|
|||
|
|
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(body))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
t.Logf("✅ 成功获取用户信息")
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TestAPIKeyLifecycle 测试 API Key 的创建和使用
|
|||
|
|
func TestAPIKeyLifecycle(t *testing.T) {
|
|||
|
|
// 先登录获取 JWT
|
|||
|
|
accessToken := loginTestUser(t)
|
|||
|
|
if accessToken == "" {
|
|||
|
|
t.Skip("无法登录,跳过 API Key 生命周期测试")
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var apiKey string
|
|||
|
|
|
|||
|
|
// 步骤 1: 创建 API Key
|
|||
|
|
t.Run("创建API_Key", func(t *testing.T) {
|
|||
|
|
payload := map[string]string{
|
|||
|
|
"name": "e2e-test-key-" + fmt.Sprintf("%d", time.Now().UnixMilli()),
|
|||
|
|
}
|
|||
|
|
body, _ := json.Marshal(payload)
|
|||
|
|
|
|||
|
|
resp, err := doRequest(t, "POST", "/api/keys", body, accessToken)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("创建 API Key 请求失败: %v", err)
|
|||
|
|
}
|
|||
|
|
defer resp.Body.Close()
|
|||
|
|
|
|||
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|||
|
|
|
|||
|
|
if resp.StatusCode != 200 {
|
|||
|
|
t.Skipf("创建 API Key 失败 HTTP %d: %s", resp.StatusCode, string(respBody))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var result map[string]any
|
|||
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|||
|
|
t.Fatalf("解析响应失败: %v", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 从响应中提取 key
|
|||
|
|
if key, ok := result["key"].(string); ok {
|
|||
|
|
apiKey = key
|
|||
|
|
} else if data, ok := result["data"].(map[string]any); ok {
|
|||
|
|
if key, ok := data["key"].(string); ok {
|
|||
|
|
apiKey = key
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if apiKey == "" {
|
|||
|
|
t.Skipf("未获取到 API Key,响应: %s", string(respBody))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 验证 API Key 脱敏日志(只显示前 8 位)
|
|||
|
|
masked := apiKey
|
|||
|
|
if len(masked) > 8 {
|
|||
|
|
masked = masked[:8] + "..."
|
|||
|
|
}
|
|||
|
|
t.Logf("✅ API Key 创建成功: %s", masked)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if apiKey == "" {
|
|||
|
|
t.Skip("未创建 API Key,跳过后续测试")
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 步骤 2: 使用 API Key 调用网关(需要 Claude 或 Gemini 可用)
|
|||
|
|
t.Run("使用API_Key调用网关", func(t *testing.T) {
|
|||
|
|
// 尝试调用 models 列表(最轻量的 API 调用)
|
|||
|
|
resp, err := doRequest(t, "GET", "/v1/models", nil, apiKey)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("网关请求失败: %v", err)
|
|||
|
|
}
|
|||
|
|
defer resp.Body.Close()
|
|||
|
|
|
|||
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|||
|
|
|
|||
|
|
// 可能返回 200(成功)或 402(余额不足)或 403(无可用账户)
|
|||
|
|
switch {
|
|||
|
|
case resp.StatusCode == 200:
|
|||
|
|
t.Logf("✅ API Key 网关调用成功")
|
|||
|
|
case resp.StatusCode == 402:
|
|||
|
|
t.Logf("⚠️ 余额不足,但 API Key 认证通过")
|
|||
|
|
case resp.StatusCode == 403:
|
|||
|
|
t.Logf("⚠️ 无可用账户,但 API Key 认证通过")
|
|||
|
|
default:
|
|||
|
|
t.Logf("⚠️ 网关返回 HTTP %d: %s", resp.StatusCode, string(respBody))
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 步骤 3: 查询用量记录
|
|||
|
|
t.Run("查询用量记录", func(t *testing.T) {
|
|||
|
|
resp, err := doRequest(t, "GET", "/api/usage/dashboard", nil, accessToken)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("用量查询请求失败: %v", err)
|
|||
|
|
}
|
|||
|
|
defer resp.Body.Close()
|
|||
|
|
|
|||
|
|
if resp.StatusCode != 200 {
|
|||
|
|
body, _ := io.ReadAll(resp.Body)
|
|||
|
|
t.Logf("⚠️ 用量查询返回 HTTP %d: %s", resp.StatusCode, string(body))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
t.Logf("✅ 用量查询成功")
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// =============================================================================
|
|||
|
|
// 辅助函数
|
|||
|
|
// =============================================================================
|
|||
|
|
|
|||
|
|
func doRequest(t *testing.T, method, path string, body []byte, token string) (*http.Response, error) {
|
|||
|
|
t.Helper()
|
|||
|
|
|
|||
|
|
url := baseURL + path
|
|||
|
|
var bodyReader io.Reader
|
|||
|
|
if body != nil {
|
|||
|
|
bodyReader = bytes.NewReader(body)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
req, err := http.NewRequest(method, url, bodyReader)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("创建请求失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if body != nil {
|
|||
|
|
req.Header.Set("Content-Type", "application/json")
|
|||
|
|
}
|
|||
|
|
if token != "" {
|
|||
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|||
|
|
return client.Do(req)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func loginTestUser(t *testing.T) string {
|
|||
|
|
t.Helper()
|
|||
|
|
|
|||
|
|
// 先尝试用管理员账户登录
|
|||
|
|
adminEmail := getEnv("ADMIN_EMAIL", "admin@sub2api.local")
|
|||
|
|
adminPassword := getEnv("ADMIN_PASSWORD", "")
|
|||
|
|
|
|||
|
|
if adminPassword == "" {
|
|||
|
|
// 尝试用测试用户
|
|||
|
|
adminEmail = testUserEmail
|
|||
|
|
adminPassword = testUserPassword
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
payload := map[string]string{
|
|||
|
|
"email": adminEmail,
|
|||
|
|
"password": adminPassword,
|
|||
|
|
}
|
|||
|
|
body, _ := json.Marshal(payload)
|
|||
|
|
|
|||
|
|
resp, err := doRequest(t, "POST", "/api/auth/login", body, "")
|
|||
|
|
if err != nil {
|
|||
|
|
return ""
|
|||
|
|
}
|
|||
|
|
defer resp.Body.Close()
|
|||
|
|
|
|||
|
|
if resp.StatusCode != 200 {
|
|||
|
|
return ""
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|||
|
|
var result map[string]any
|
|||
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|||
|
|
return ""
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if token, ok := result["access_token"].(string); ok {
|
|||
|
|
return token
|
|||
|
|
}
|
|||
|
|
if data, ok := result["data"].(map[string]any); ok {
|
|||
|
|
if token, ok := data["access_token"].(string); ok {
|
|||
|
|
return token
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return ""
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// redactAPIKey API Key 脱敏,只显示前 8 位
|
|||
|
|
func redactAPIKey(key string) string {
|
|||
|
|
key = strings.TrimSpace(key)
|
|||
|
|
if len(key) <= 8 {
|
|||
|
|
return "***"
|
|||
|
|
}
|
|||
|
|
return key[:8] + "..."
|
|||
|
|
}
|