feat: backend core - auth, user, role, permission, device, webhook, monitoring, cache, repository, service, middleware, API handlers

This commit is contained in:
2026-04-02 11:19:50 +08:00
parent e59a77bc49
commit dcc1f186f8
298 changed files with 62603 additions and 0 deletions

View File

@@ -0,0 +1,843 @@
//go:build e2e
package integration
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"testing"
"time"
)
var (
baseURL = getEnv("BASE_URL", "http://localhost:8080")
// ENDPOINT_PREFIX: 端点前缀,支持混合模式和非混合模式测试
// - "" (默认): 使用 /v1/messages, /v1beta/models混合模式可调度 antigravity 账户)
// - "/antigravity": 使用 /antigravity/v1/messages, /antigravity/v1beta/models非混合模式仅 antigravity 账户)
endpointPrefix = getEnv("ENDPOINT_PREFIX", "")
testInterval = 1 * time.Second // 测试间隔,防止限流
)
const (
// 注意E2E 测试请使用环境变量注入密钥,避免任何凭证进入仓库历史。
// 例如:
// export CLAUDE_API_KEY="sk-..."
// export GEMINI_API_KEY="sk-..."
claudeAPIKeyEnv = "CLAUDE_API_KEY"
geminiAPIKeyEnv = "GEMINI_API_KEY"
)
func getEnv(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultVal
}
// Claude 模型列表
var claudeModels = []string{
// Opus 系列
"claude-opus-4-5-thinking", // 直接支持
"claude-opus-4", // 映射到 claude-opus-4-5-thinking
"claude-opus-4-5-20251101", // 映射到 claude-opus-4-5-thinking
// Sonnet 系列
"claude-sonnet-4-5", // 直接支持
"claude-sonnet-4-5-thinking", // 直接支持
"claude-sonnet-4-5-20250929", // 映射到 claude-sonnet-4-5-thinking
"claude-3-5-sonnet-20241022", // 映射到 claude-sonnet-4-5
// Haiku 系列(映射到 gemini-3-flash
"claude-haiku-4",
"claude-haiku-4-5",
"claude-haiku-4-5-20251001",
"claude-3-haiku-20240307",
}
// Gemini 模型列表
var geminiModels = []string{
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-3-flash",
"gemini-3-pro-low",
"gemini-3-pro-high",
}
func TestMain(m *testing.M) {
mode := "混合模式"
if endpointPrefix != "" {
mode = "Antigravity 模式"
}
claudeKeySet := strings.TrimSpace(os.Getenv(claudeAPIKeyEnv)) != ""
geminiKeySet := strings.TrimSpace(os.Getenv(geminiAPIKeyEnv)) != ""
fmt.Printf("\n🚀 E2E Gateway Tests - %s (prefix=%q, %s, %s=%v, %s=%v)\n\n",
baseURL,
endpointPrefix,
mode,
claudeAPIKeyEnv,
claudeKeySet,
geminiAPIKeyEnv,
geminiKeySet,
)
os.Exit(m.Run())
}
func requireClaudeAPIKey(t *testing.T) string {
t.Helper()
key := strings.TrimSpace(os.Getenv(claudeAPIKeyEnv))
if key == "" {
t.Skipf("未设置 %s跳过 Claude 相关 E2E 测试", claudeAPIKeyEnv)
}
return key
}
func requireGeminiAPIKey(t *testing.T) string {
t.Helper()
key := strings.TrimSpace(os.Getenv(geminiAPIKeyEnv))
if key == "" {
t.Skipf("未设置 %s跳过 Gemini 相关 E2E 测试", geminiAPIKeyEnv)
}
return key
}
// TestClaudeModelsList 测试 GET /v1/models
func TestClaudeModelsList(t *testing.T) {
claudeKey := requireClaudeAPIKey(t)
url := baseURL + endpointPrefix + "/v1/models"
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+claudeKey)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
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))
}
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if result["object"] != "list" {
t.Errorf("期望 object=list, 得到 %v", result["object"])
}
data, ok := result["data"].([]any)
if !ok {
t.Fatal("响应缺少 data 数组")
}
t.Logf("✅ 返回 %d 个模型", len(data))
}
// TestGeminiModelsList 测试 GET /v1beta/models
func TestGeminiModelsList(t *testing.T) {
geminiKey := requireGeminiAPIKey(t)
url := baseURL + endpointPrefix + "/v1beta/models"
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+geminiKey)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
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))
}
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
models, ok := result["models"].([]any)
if !ok {
t.Fatal("响应缺少 models 数组")
}
t.Logf("✅ 返回 %d 个模型", len(models))
}
// TestClaudeMessages 测试 Claude /v1/messages 接口
func TestClaudeMessages(t *testing.T) {
claudeKey := requireClaudeAPIKey(t)
for i, model := range claudeModels {
if i > 0 {
time.Sleep(testInterval)
}
t.Run(model+"_非流式", func(t *testing.T) {
testClaudeMessage(t, claudeKey, model, false)
})
time.Sleep(testInterval)
t.Run(model+"_流式", func(t *testing.T) {
testClaudeMessage(t, claudeKey, model, true)
})
}
}
func testClaudeMessage(t *testing.T, claudeKey string, model string, stream bool) {
url := baseURL + endpointPrefix + "/v1/messages"
payload := map[string]any{
"model": model,
"max_tokens": 50,
"stream": stream,
"messages": []map[string]string{
{"role": "user", "content": "Say 'hello' in one word."},
},
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+claudeKey)
req.Header.Set("anthropic-version", "2023-06-01")
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
if stream {
// 流式:读取 SSE 事件
scanner := bufio.NewScanner(resp.Body)
eventCount := 0
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "data:") {
eventCount++
if eventCount >= 3 {
break
}
}
}
if eventCount == 0 {
t.Fatal("未收到任何 SSE 事件")
}
t.Logf("✅ 收到 %d+ 个 SSE 事件", eventCount)
} else {
// 非流式:解析 JSON 响应
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if result["type"] != "message" {
t.Errorf("期望 type=message, 得到 %v", result["type"])
}
t.Logf("✅ 收到消息响应 id=%v", result["id"])
}
}
// TestGeminiGenerateContent 测试 Gemini /v1beta/models/:model 接口
func TestGeminiGenerateContent(t *testing.T) {
geminiKey := requireGeminiAPIKey(t)
for i, model := range geminiModels {
if i > 0 {
time.Sleep(testInterval)
}
t.Run(model+"_非流式", func(t *testing.T) {
testGeminiGenerate(t, geminiKey, model, false)
})
time.Sleep(testInterval)
t.Run(model+"_流式", func(t *testing.T) {
testGeminiGenerate(t, geminiKey, model, true)
})
}
}
func testGeminiGenerate(t *testing.T, geminiKey string, model string, stream bool) {
action := "generateContent"
if stream {
action = "streamGenerateContent"
}
url := fmt.Sprintf("%s%s/v1beta/models/%s:%s", baseURL, endpointPrefix, model, action)
if stream {
url += "?alt=sse"
}
payload := map[string]any{
"contents": []map[string]any{
{
"role": "user",
"parts": []map[string]string{
{"text": "Say 'hello' in one word."},
},
},
},
"generationConfig": map[string]int{
"maxOutputTokens": 50,
},
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+geminiKey)
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
if stream {
// 流式:读取 SSE 事件
scanner := bufio.NewScanner(resp.Body)
eventCount := 0
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "data:") {
eventCount++
if eventCount >= 3 {
break
}
}
}
if eventCount == 0 {
t.Fatal("未收到任何 SSE 事件")
}
t.Logf("✅ 收到 %d+ 个 SSE 事件", eventCount)
} else {
// 非流式:解析 JSON 响应
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if _, ok := result["candidates"]; !ok {
t.Error("响应缺少 candidates 字段")
}
t.Log("✅ 收到 candidates 响应")
}
}
// TestClaudeMessagesWithComplexTools 测试带复杂工具 schema 的请求
// 模拟 Claude Code 发送的请求,包含需要清理的 JSON Schema 字段
func TestClaudeMessagesWithComplexTools(t *testing.T) {
claudeKey := requireClaudeAPIKey(t)
// 测试模型列表(只测试几个代表性模型)
models := []string{
"claude-opus-4-5-20251101", // Claude 模型
"claude-haiku-4-5-20251001", // 映射到 Gemini
}
for i, model := range models {
if i > 0 {
time.Sleep(testInterval)
}
t.Run(model+"_复杂工具", func(t *testing.T) {
testClaudeMessageWithTools(t, claudeKey, model)
})
}
}
func testClaudeMessageWithTools(t *testing.T, claudeKey string, model string) {
url := baseURL + endpointPrefix + "/v1/messages"
// 构造包含复杂 schema 的工具定义(模拟 Claude Code 的工具)
// 这些字段需要被 cleanJSONSchema 清理
tools := []map[string]any{
{
"name": "read_file",
"description": "Read file contents",
"input_schema": map[string]any{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "File path",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[^\\x00]+$",
},
"encoding": map[string]any{
"type": []string{"string", "null"},
"default": "utf-8",
"enum": []string{"utf-8", "ascii", "latin-1"},
},
},
"required": []string{"path"},
"additionalProperties": false,
},
},
{
"name": "write_file",
"description": "Write content to file",
"input_schema": map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"minLength": 1,
},
"content": map[string]any{
"type": "string",
"maxLength": 1048576,
},
},
"required": []string{"path", "content"},
"additionalProperties": false,
"strict": true,
},
},
{
"name": "list_files",
"description": "List files in directory",
"input_schema": map[string]any{
"$id": "https://example.com/list-files.schema.json",
"type": "object",
"properties": map[string]any{
"directory": map[string]any{
"type": "string",
},
"patterns": map[string]any{
"type": "array",
"items": map[string]any{
"type": "string",
"minLength": 1,
},
"minItems": 1,
"maxItems": 100,
"uniqueItems": true,
},
"recursive": map[string]any{
"type": "boolean",
"default": false,
},
},
"required": []string{"directory"},
"additionalProperties": false,
},
},
{
"name": "search_code",
"description": "Search code in files",
"input_schema": map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{
"type": "string",
"minLength": 1,
"format": "regex",
},
"max_results": map[string]any{
"type": "integer",
"minimum": 1,
"maximum": 1000,
"exclusiveMinimum": 0,
"default": 100,
},
},
"required": []string{"query"},
"additionalProperties": false,
"examples": []map[string]any{
{"query": "function.*test", "max_results": 50},
},
},
},
// 测试 required 引用不存在的属性(应被自动过滤)
{
"name": "invalid_required_tool",
"description": "Tool with invalid required field",
"input_schema": map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{
"type": "string",
},
},
// "nonexistent_field" 不存在于 properties 中,应被过滤掉
"required": []string{"name", "nonexistent_field"},
},
},
// 测试没有 properties 的 schema应自动添加空 properties
{
"name": "no_properties_tool",
"description": "Tool without properties",
"input_schema": map[string]any{
"type": "object",
"required": []string{"should_be_removed"},
},
},
// 测试没有 type 的 schema应自动添加 type: OBJECT
{
"name": "no_type_tool",
"description": "Tool without type",
"input_schema": map[string]any{
"properties": map[string]any{
"value": map[string]any{
"type": "string",
},
},
},
},
}
payload := map[string]any{
"model": model,
"max_tokens": 100,
"stream": false,
"messages": []map[string]string{
{"role": "user", "content": "List files in the current directory"},
},
"tools": tools,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+claudeKey)
req.Header.Set("anthropic-version", "2023-06-01")
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
// 400 错误说明 schema 清理不完整
if resp.StatusCode == 400 {
t.Fatalf("Schema 清理失败,收到 400 错误: %s", string(respBody))
}
// 503 可能是账号限流,不算测试失败
if resp.StatusCode == 503 {
t.Skipf("账号暂时不可用 (503): %s", string(respBody))
}
// 429 是限流
if resp.StatusCode == 429 {
t.Skipf("请求被限流 (429): %s", string(respBody))
}
if resp.StatusCode != 200 {
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
var result map[string]any
if err := json.Unmarshal(respBody, &result); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if result["type"] != "message" {
t.Errorf("期望 type=message, 得到 %v", result["type"])
}
t.Logf("✅ 复杂工具 schema 测试通过, id=%v", result["id"])
}
// TestClaudeMessagesWithThinkingAndTools 测试 thinking 模式下带工具调用的场景
// 验证:当历史 assistant 消息包含 tool_use 但没有 signature 时,
// 系统应自动添加 dummy thought_signature 避免 Gemini 400 错误
func TestClaudeMessagesWithThinkingAndTools(t *testing.T) {
claudeKey := requireClaudeAPIKey(t)
models := []string{
"claude-haiku-4-5-20251001", // gemini-3-flash
}
for i, model := range models {
if i > 0 {
time.Sleep(testInterval)
}
t.Run(model+"_thinking模式工具调用", func(t *testing.T) {
testClaudeThinkingWithToolHistory(t, claudeKey, model)
})
}
}
func testClaudeThinkingWithToolHistory(t *testing.T, claudeKey string, model string) {
url := baseURL + endpointPrefix + "/v1/messages"
// 模拟历史对话:用户请求 → assistant 调用工具 → 工具返回 → 继续对话
// 注意tool_use 块故意不包含 signature测试系统是否能正确添加 dummy signature
payload := map[string]any{
"model": model,
"max_tokens": 200,
"stream": false,
// 开启 thinking 模式
"thinking": map[string]any{
"type": "enabled",
"budget_tokens": 1024,
},
"messages": []any{
map[string]any{
"role": "user",
"content": "List files in the current directory",
},
// assistant 消息包含 tool_use 但没有 signature
map[string]any{
"role": "assistant",
"content": []map[string]any{
{
"type": "text",
"text": "I'll list the files for you.",
},
{
"type": "tool_use",
"id": "toolu_01XGmNv",
"name": "Bash",
"input": map[string]any{"command": "ls -la"},
// 故意不包含 signature
},
},
},
// 工具结果
map[string]any{
"role": "user",
"content": []map[string]any{
{
"type": "tool_result",
"tool_use_id": "toolu_01XGmNv",
"content": "file1.txt\nfile2.txt\ndir1/",
},
},
},
},
"tools": []map[string]any{
{
"name": "Bash",
"description": "Execute bash commands",
"input_schema": map[string]any{
"type": "object",
"properties": map[string]any{
"command": map[string]any{
"type": "string",
},
},
"required": []string{"command"},
},
},
},
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+claudeKey)
req.Header.Set("anthropic-version", "2023-06-01")
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
// 400 错误说明 thought_signature 处理失败
if resp.StatusCode == 400 {
t.Fatalf("thought_signature 处理失败,收到 400 错误: %s", string(respBody))
}
// 503 可能是账号限流,不算测试失败
if resp.StatusCode == 503 {
t.Skipf("账号暂时不可用 (503): %s", string(respBody))
}
// 429 是限流
if resp.StatusCode == 429 {
t.Skipf("请求被限流 (429): %s", string(respBody))
}
if resp.StatusCode != 200 {
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
var result map[string]any
if err := json.Unmarshal(respBody, &result); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if result["type"] != "message" {
t.Errorf("期望 type=message, 得到 %v", result["type"])
}
t.Logf("✅ thinking 模式工具调用测试通过, id=%v", result["id"])
}
// TestClaudeMessagesWithGeminiModel 测试在 Claude 端点使用 Gemini 模型
// 验证:通过 /v1/messages 端点传入 gemini 模型名的场景(含前缀映射)
// 仅在 Antigravity 模式下运行ENDPOINT_PREFIX="/antigravity"
func TestClaudeMessagesWithGeminiModel(t *testing.T) {
if endpointPrefix != "/antigravity" {
t.Skip("仅在 Antigravity 模式下运行")
}
claudeKey := requireClaudeAPIKey(t)
// 测试通过 Claude 端点调用 Gemini 模型
geminiViaClaude := []string{
"gemini-3-flash", // 直接支持
"gemini-3-pro-low", // 直接支持
"gemini-3-pro-high", // 直接支持
"gemini-3-pro", // 前缀映射 -> gemini-3-pro-high
"gemini-3-pro-preview", // 前缀映射 -> gemini-3-pro-high
}
for i, model := range geminiViaClaude {
if i > 0 {
time.Sleep(testInterval)
}
t.Run(model+"_通过Claude端点", func(t *testing.T) {
testClaudeMessage(t, claudeKey, model, false)
})
time.Sleep(testInterval)
t.Run(model+"_通过Claude端点_流式", func(t *testing.T) {
testClaudeMessage(t, claudeKey, model, true)
})
}
}
// TestClaudeMessagesWithNoSignature 测试历史 thinking block 不带 signature 的场景
// 验证Gemini 模型接受没有 signature 的 thinking block
func TestClaudeMessagesWithNoSignature(t *testing.T) {
claudeKey := requireClaudeAPIKey(t)
models := []string{
"claude-haiku-4-5-20251001", // gemini-3-flash - 支持无 signature
}
for i, model := range models {
if i > 0 {
time.Sleep(testInterval)
}
t.Run(model+"_无signature", func(t *testing.T) {
testClaudeWithNoSignature(t, claudeKey, model)
})
}
}
func testClaudeWithNoSignature(t *testing.T, claudeKey string, model string) {
url := baseURL + endpointPrefix + "/v1/messages"
// 模拟历史对话包含 thinking block 但没有 signature
payload := map[string]any{
"model": model,
"max_tokens": 200,
"stream": false,
// 开启 thinking 模式
"thinking": map[string]any{
"type": "enabled",
"budget_tokens": 1024,
},
"messages": []any{
map[string]any{
"role": "user",
"content": "What is 2+2?",
},
// assistant 消息包含 thinking block 但没有 signature
map[string]any{
"role": "assistant",
"content": []map[string]any{
{
"type": "thinking",
"thinking": "Let me calculate 2+2...",
// 故意不包含 signature
},
{
"type": "text",
"text": "2+2 equals 4.",
},
},
},
map[string]any{
"role": "user",
"content": "What is 3+3?",
},
},
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+claudeKey)
req.Header.Set("anthropic-version", "2023-06-01")
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode == 400 {
t.Fatalf("无 signature thinking 处理失败,收到 400 错误: %s", string(respBody))
}
if resp.StatusCode == 503 {
t.Skipf("账号暂时不可用 (503): %s", string(respBody))
}
if resp.StatusCode == 429 {
t.Skipf("请求被限流 (429): %s", string(respBody))
}
if resp.StatusCode != 200 {
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
var result map[string]any
if err := json.Unmarshal(respBody, &result); err != nil {
t.Fatalf("解析响应失败: %v", err)
}
if result["type"] != "message" {
t.Errorf("期望 type=message, 得到 %v", result["type"])
}
t.Logf("✅ 无 signature thinking 处理测试通过, id=%v", result["id"])
}
// TestGeminiEndpointWithClaudeModel 测试通过 Gemini 端点调用 Claude 模型
// 仅在 Antigravity 模式下运行ENDPOINT_PREFIX="/antigravity"
func TestGeminiEndpointWithClaudeModel(t *testing.T) {
if endpointPrefix != "/antigravity" {
t.Skip("仅在 Antigravity 模式下运行")
}
geminiKey := requireGeminiAPIKey(t)
// 测试通过 Gemini 端点调用 Claude 模型
claudeViaGemini := []string{
"claude-sonnet-4-5",
"claude-opus-4-5-thinking",
}
for i, model := range claudeViaGemini {
if i > 0 {
time.Sleep(testInterval)
}
t.Run(model+"_通过Gemini端点", func(t *testing.T) {
testGeminiGenerate(t, geminiKey, model, false)
})
time.Sleep(testInterval)
t.Run(model+"_通过Gemini端点_流式", func(t *testing.T) {
testGeminiGenerate(t, geminiKey, model, true)
})
}
}

View File

@@ -0,0 +1,48 @@
//go:build e2e
package integration
import (
"os"
"strings"
"testing"
)
// =============================================================================
// E2E Mock 模式支持
// =============================================================================
// 当 E2E_MOCK=true 时,使用本地 Mock 响应替代真实 API 调用。
// 这允许在没有真实 API Key 的环境(如 CI中验证基本的请求/响应流程。
// isMockMode 检查是否启用 Mock 模式
func isMockMode() bool {
return strings.EqualFold(os.Getenv("E2E_MOCK"), "true")
}
// skipIfNoRealAPI 如果未配置真实 API Key 且不在 Mock 模式,则跳过测试
func skipIfNoRealAPI(t *testing.T) {
t.Helper()
if isMockMode() {
return // Mock 模式下不跳过
}
claudeKey := strings.TrimSpace(os.Getenv(claudeAPIKeyEnv))
geminiKey := strings.TrimSpace(os.Getenv(geminiAPIKeyEnv))
if claudeKey == "" && geminiKey == "" {
t.Skip("未设置 API Key 且未启用 Mock 模式,跳过测试")
}
}
// =============================================================================
// API Key 脱敏Task 6.10
// =============================================================================
// safeLogKey 安全地记录 API Key仅显示前 8 位)
func safeLogKey(t *testing.T, prefix string, key string) {
t.Helper()
key = strings.TrimSpace(key)
if len(key) <= 8 {
t.Logf("%s: ***(长度: %d", prefix, len(key))
return
}
t.Logf("%s: %s...(长度: %d", prefix, key[:8], len(key))
}

View File

@@ -0,0 +1,317 @@
//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] + "..."
}

View File

@@ -0,0 +1,222 @@
package integration
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
_ "modernc.org/sqlite" // 纯 Go SQLite注册 "sqlite" 驱动
gormsqlite "gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/user-management-system/internal/domain"
"github.com/user-management-system/internal/repository"
)
var integDBCounter int64
func setupTestDB(t *testing.T) *gorm.DB {
t.Helper()
id := atomic.AddInt64(&integDBCounter, 1)
dsn := fmt.Sprintf("file:integtestdb%d?mode=memory&cache=private", id)
db, err := gorm.Open(gormsqlite.New(gormsqlite.Config{
DriverName: "sqlite",
DSN: dsn,
}), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("打开测试数据库失败: %v", err)
}
if err := db.AutoMigrate(&domain.User{}, &domain.Role{}, &domain.Permission{}, &domain.Device{}); err != nil {
t.Fatalf("数据库迁移失败: %v", err)
}
return db
}
func cleanupTestDB(t *testing.T, db *gorm.DB) {
t.Helper()
sqlDB, _ := db.DB()
sqlDB.Close()
}
// setupTestServer 测试服务器
func setupTestServer(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/auth/register", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"code":0,"message":"success","data":{"user_id":1}}`))
})
mux.HandleFunc("/api/v1/auth/login", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"test-token"}}`))
})
mux.HandleFunc("/api/v1/users/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"username":"testuser"}}`))
})
return httptest.NewServer(mux)
}
// TestDatabaseIntegration 测试数据库集成
func TestDatabaseIntegration(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
repo := repository.NewUserRepository(db)
ctx := context.Background()
t.Run("CreateUser", func(t *testing.T) {
user := &domain.User{
Phone: domain.StrPtr("13800138000"),
Username: "integrationuser",
Password: "hashedpassword",
Status: domain.UserStatusActive,
}
if err := repo.Create(ctx, user); err != nil {
t.Fatalf("创建用户失败: %v", err)
}
if user.ID == 0 {
t.Error("用户ID不应为0")
}
})
t.Run("FindUser", func(t *testing.T) {
user, err := repo.GetByUsername(ctx, "integrationuser")
if err != nil {
t.Fatalf("查询用户失败: %v", err)
}
if domain.DerefStr(user.Phone) != "13800138000" {
t.Errorf("Phone = %v, want 13800138000", domain.DerefStr(user.Phone))
}
})
t.Run("UpdateUser", func(t *testing.T) {
user, _ := repo.GetByUsername(ctx, "integrationuser")
user.Nickname = "已更新"
if err := repo.Update(ctx, user); err != nil {
t.Fatalf("更新用户失败: %v", err)
}
found, _ := repo.GetByID(ctx, user.ID)
if found.Nickname != "已更新" {
t.Errorf("Nickname = %v, want 已更新", found.Nickname)
}
})
t.Run("DeleteUser", func(t *testing.T) {
user, _ := repo.GetByUsername(ctx, "integrationuser")
if err := repo.Delete(ctx, user.ID); err != nil {
t.Fatalf("删除用户失败: %v", err)
}
_, err := repo.GetByUsername(ctx, "integrationuser")
if err == nil {
t.Error("删除后查询应返回错误")
}
})
}
// TestTransactionIntegration 测试事务集成
func TestTransactionIntegration(t *testing.T) {
db := setupTestDB(t)
defer cleanupTestDB(t, db)
t.Run("TransactionRollback", func(t *testing.T) {
err := db.Transaction(func(tx *gorm.DB) error {
user := &domain.User{
Phone: domain.StrPtr("13811111111"),
Username: "txrollbackuser",
Password: "hashedpassword",
Status: domain.UserStatusActive,
}
if err := tx.Create(user).Error; err != nil {
return err
}
return errors.New("模拟错误,触发回滚")
})
if err == nil {
t.Error("事务应该失败")
}
var count int64
db.Model(&domain.User{}).Where("username = ?", "txrollbackuser").Count(&count)
if count > 0 {
t.Error("事务回滚后用户不应存在")
}
})
t.Run("TransactionCommit", func(t *testing.T) {
err := db.Transaction(func(tx *gorm.DB) error {
user := &domain.User{
Phone: domain.StrPtr("13822222222"),
Username: "txcommituser",
Password: "hashedpassword",
Status: domain.UserStatusActive,
}
return tx.Create(user).Error
})
if err != nil {
t.Fatalf("事务失败: %v", err)
}
var count int64
db.Model(&domain.User{}).Where("username = ?", "txcommituser").Count(&count)
if count != 1 {
t.Error("事务提交后用户应存在")
}
})
}
// TestAPIIntegration 测试HTTP API集成
func TestAPIIntegration(t *testing.T) {
server := setupTestServer(t)
defer server.Close()
t.Run("RegisterEndpoint", func(t *testing.T) {
resp, err := http.Post(server.URL+"/api/v1/auth/register", "application/json", nil)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("StatusCode = %d, want 200", resp.StatusCode)
}
})
t.Run("LoginEndpoint", func(t *testing.T) {
resp, err := http.Post(server.URL+"/api/v1/auth/login", "application/json", nil)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("StatusCode = %d, want 200", resp.StatusCode)
}
})
t.Run("GetUserEndpoint", func(t *testing.T) {
resp, err := http.Get(server.URL + "/api/v1/users/1")
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("StatusCode = %d, want 200", resp.StatusCode)
}
})
}