Files
junhong_cmp_fiber/tests/integration/error_handler_test.go
huang eaa70ac255 feat: 实现 RBAC 权限系统和数据权限控制 (004-rbac-data-permission)
主要功能:
- 实现完整的 RBAC 权限系统(账号、角色、权限的多对多关联)
- 基于 owner_id + shop_id 的自动数据权限过滤
- 使用 PostgreSQL WITH RECURSIVE 查询下级账号
- Redis 缓存优化下级账号查询性能(30分钟过期)
- 支持多租户数据隔离和层级权限管理

技术实现:
- 新增 Account、Role、Permission 模型及关联关系表
- 实现 GORM Scopes 自动应用数据权限过滤
- 添加数据库迁移脚本(000002_rbac_data_permission、000003_add_owner_id_shop_id)
- 完善错误码定义(1010-1027 为 RBAC 相关错误)
- 重构 main.go 采用函数拆分提高可读性

测试覆盖:
- 添加 Account、Role、Permission 的集成测试
- 添加数据权限过滤的单元测试和集成测试
- 添加下级账号查询和缓存的单元测试
- 添加 API 回归测试确保向后兼容

文档更新:
- 更新 README.md 添加 RBAC 功能说明
- 更新 CLAUDE.md 添加技术栈和开发原则
- 添加 docs/004-rbac-data-permission/ 功能总结和使用指南

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 16:44:06 +08:00

1086 lines
33 KiB
Go
Raw 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 integration
import (
"encoding/json"
"io"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/requestid"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
)
// setupTestApp 创建用于测试的 Fiber 应用
func setupTestApp() *fiber.App {
// 初始化日志器
_ = logger.InitLoggers(
"debug",
true,
logger.LogRotationConfig{
Filename: "tests/integration/logs/error_handler_test.log",
MaxSize: 10,
MaxBackups: 3,
MaxAge: 7,
Compress: false,
},
logger.LogRotationConfig{
Filename: "tests/integration/logs/access_test.log",
MaxSize: 10,
MaxBackups: 3,
MaxAge: 7,
Compress: false,
},
)
appLogger := logger.GetAppLogger()
app := fiber.New(fiber.Config{
ErrorHandler: errors.SafeErrorHandler(appLogger),
})
// 注册中间件
app.Use(middleware.Recover(appLogger))
app.Use(requestid.New(requestid.Config{
Generator: func() string {
return uuid.NewString()
},
}))
return app
}
// ErrorResponse 定义统一的错误响应结构
type ErrorResponse struct {
Code interface{} `json:"code"`
Data interface{} `json:"data"`
Msg string `json:"msg"`
Timestamp interface{} `json:"timestamp"`
}
// T029: 测试参数验证失败 -> 400 错误响应
func TestErrorHandler_ValidationError_Returns400(t *testing.T) {
app := setupTestApp()
// 创建测试路由:触发参数验证失败
app.Post("/api/test/validation", func(c *fiber.Ctx) error {
// 模拟参数验证失败场景
return errors.New(
errors.CodeInvalidParam, // 参数验证失败
"用户名长度必须在 3-20 个字符之间",
)
})
// 发起请求
req := httptest.NewRequest("POST", "/api/test/validation", nil)
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, -1)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
// 验证 HTTP 状态码
assert.Equal(t, 400, resp.StatusCode, "参数验证失败应返回 400 状态码")
// 解析响应 body
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var errResp ErrorResponse
err = json.Unmarshal(body, &errResp)
require.NoError(t, err, "响应应为有效的 JSON 格式")
// 验证响应字段
assert.NotEmpty(t, errResp.Code, "错误码不应为空")
assert.Contains(t, errResp.Msg, "用户名长度", "错误消息应包含验证失败信息")
assert.NotEmpty(t, errResp.Timestamp, "时间戳不应为空")
// 验证响应头包含 Request ID
requestID := resp.Header.Get("X-Request-ID")
assert.NotEmpty(t, requestID, "响应头应包含 X-Request-ID")
t.Logf("✓ 验证失败测试通过 - Code: %s, Msg: %s, RequestID: %s",
errResp.Code, errResp.Msg, requestID)
}
// T030: 测试资源未找到 -> 404 错误响应
func TestErrorHandler_ResourceNotFound_Returns404(t *testing.T) {
app := setupTestApp()
// 创建测试路由:触发资源未找到
app.Get("/api/test/users/:id", func(c *fiber.Ctx) error {
userID := c.Params("id")
// 模拟资源不存在场景
return errors.New(
errors.CodeNotFound, // 资源不存在
"用户 ID "+userID+" 不存在",
)
})
// 发起请求
req := httptest.NewRequest("GET", "/api/test/users/99999", nil)
resp, err := app.Test(req, -1)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
// 验证 HTTP 状态码
assert.Equal(t, 404, resp.StatusCode, "资源未找到应返回 404 状态码")
// 解析响应 body
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var errResp ErrorResponse
err = json.Unmarshal(body, &errResp)
require.NoError(t, err, "响应应为有效的 JSON 格式")
// 验证响应字段
assert.NotEmpty(t, errResp.Code, "错误码不应为空")
assert.Contains(t, errResp.Msg, "不存在", "错误消息应包含资源不存在信息")
assert.NotEmpty(t, errResp.Timestamp, "时间戳不应为空")
// 验证响应头包含 Request ID
requestID := resp.Header.Get("X-Request-ID")
assert.NotEmpty(t, requestID, "响应头应包含 X-Request-ID")
t.Logf("✓ 资源未找到测试通过 - Code: %s, Msg: %s, RequestID: %s",
errResp.Code, errResp.Msg, requestID)
}
// T031: 测试认证失败 -> 401 错误响应
func TestErrorHandler_AuthenticationFailed_Returns401(t *testing.T) {
app := setupTestApp()
// 创建测试路由:触发认证失败
app.Get("/api/test/protected", func(c *fiber.Ctx) error {
// 模拟 Token 无效场景
return errors.New(
errors.CodeUnauthorized, // 认证失败
"Token 已过期或无效",
)
})
// 发起请求(无 Token
req := httptest.NewRequest("GET", "/api/test/protected", nil)
resp, err := app.Test(req, -1)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
// 验证 HTTP 状态码
assert.Equal(t, 401, resp.StatusCode, "认证失败应返回 401 状态码")
// 解析响应 body
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var errResp ErrorResponse
err = json.Unmarshal(body, &errResp)
require.NoError(t, err, "响应应为有效的 JSON 格式")
// 验证响应字段
assert.NotEmpty(t, errResp.Code, "错误码不应为空")
assert.Contains(t, errResp.Msg, "Token", "错误消息应包含认证失败信息")
assert.NotEmpty(t, errResp.Timestamp, "时间戳不应为空")
// 验证响应头包含 Request ID
requestID := resp.Header.Get("X-Request-ID")
assert.NotEmpty(t, requestID, "响应头应包含 X-Request-ID")
t.Logf("✓ 认证失败测试通过 - Code: %s, Msg: %s, RequestID: %s",
errResp.Code, errResp.Msg, requestID)
}
// T032: 验证所有错误响应格式一致性
func TestErrorHandler_ResponseFormatConsistency(t *testing.T) {
app := setupTestApp()
// 注册多种错误场景的测试路由
testCases := []struct {
name string
path string
method string
errorCode int
errorMsg string
expectedHTTP int
}{
{
name: "参数验证失败",
path: "/api/test/validation-error",
method: "POST",
errorCode: errors.CodeInvalidParam,
errorMsg: "参数验证失败",
expectedHTTP: 400,
},
{
name: "数据格式错误",
path: "/api/test/format-error",
method: "POST",
errorCode: errors.CodeInvalidParam,
errorMsg: "JSON 格式错误",
expectedHTTP: 400,
},
{
name: "资源不存在",
path: "/api/test/not-found",
method: "GET",
errorCode: errors.CodeNotFound,
errorMsg: "资源不存在",
expectedHTTP: 404,
},
{
name: "认证失败",
path: "/api/test/auth-error",
method: "GET",
errorCode: errors.CodeUnauthorized,
errorMsg: "认证失败",
expectedHTTP: 401,
},
{
name: "权限不足",
path: "/api/test/permission-error",
method: "GET",
errorCode: errors.CodeForbidden,
errorMsg: "权限不足",
expectedHTTP: 403,
},
{
name: "数据库错误",
path: "/api/test/db-error",
method: "GET",
errorCode: errors.CodeDatabaseError,
errorMsg: "数据库连接失败",
expectedHTTP: 500,
},
{
name: "外部服务错误",
path: "/api/test/external-error",
method: "POST",
errorCode: errors.CodeServiceUnavailable,
errorMsg: "外部 API 调用失败",
expectedHTTP: 503,
},
}
// 为每个测试场景注册路由
for _, tc := range testCases {
tc := tc // 捕获循环变量
switch tc.method {
case "GET":
app.Get(tc.path, func(c *fiber.Ctx) error {
return errors.New(tc.errorCode, tc.errorMsg)
})
case "POST":
app.Post(tc.path, func(c *fiber.Ctx) error {
return errors.New(tc.errorCode, tc.errorMsg)
})
}
}
// 测试每个场景的响应格式
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// 发起请求
req := httptest.NewRequest(tc.method, tc.path, nil)
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, -1)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
// 验证 HTTP 状态码
assert.Equal(t, tc.expectedHTTP, resp.StatusCode,
"%s 应返回 %d 状态码", tc.name, tc.expectedHTTP)
// 解析响应 body
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var errResp ErrorResponse
err = json.Unmarshal(body, &errResp)
require.NoError(t, err, "响应应为有效的 JSON 格式")
// 验证响应字段完整性
assert.NotEmpty(t, errResp.Code, "响应必须包含 code 字段")
assert.NotEmpty(t, errResp.Msg, "响应必须包含 msg 字段")
assert.NotEmpty(t, errResp.Timestamp, "响应必须包含 timestamp 字段")
// 验证时间戳格式RFC3339
_, err = time.Parse(time.RFC3339, errResp.Timestamp.(string))
assert.NoError(t, err, "时间戳应为有效的 RFC3339 格式")
// 验证响应头
requestID := resp.Header.Get("X-Request-ID")
assert.NotEmpty(t, requestID, "响应头应包含 X-Request-ID")
contentType := resp.Header.Get("Content-Type")
assert.Contains(t, contentType, "application/json",
"Content-Type 应为 application/json")
// 5xx 错误应该脱敏 - 不暴露原始错误消息,返回错误码对应的标准消息
if tc.expectedHTTP >= 500 {
// 验证不包含自定义错误消息(如 "数据库连接失败", "外部 API 调用失败")
assert.NotContains(t, errResp.Msg, tc.errorMsg,
"5xx 错误不应暴露自定义错误详情,应返回标准消息")
// 验证返回的是标准错误消息(从 GetMessage 获取)
assert.NotEmpty(t, errResp.Msg, "5xx 错误应返回标准消息")
}
t.Logf("✓ %s - 格式一致性验证通过 - Code: %s, Status: %d, RequestID: %s",
tc.name, errResp.Code, resp.StatusCode, requestID)
})
}
t.Log("✓ 所有错误响应格式一致性测试通过")
}
// T039: 创建测试端点触发 panic
func TestPanic_BasicPanicRecovery(t *testing.T) {
app := setupTestApp()
// 创建会触发 panic 的路由
app.Get("/api/test/panic", func(c *fiber.Ctx) error {
panic("模拟业务逻辑 panic")
})
// 发起请求
req := httptest.NewRequest("GET", "/api/test/panic", nil)
resp, err := app.Test(req, -1)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
// 验证 panic 被捕获并转换为 500 错误
assert.Equal(t, 500, resp.StatusCode, "panic 应返回 500 状态码")
// 解析响应 body
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var errResp ErrorResponse
err = json.Unmarshal(body, &errResp)
require.NoError(t, err, "panic 响应应为有效的 JSON 格式")
// 验证响应字段
assert.NotEmpty(t, errResp.Code, "错误码不应为空")
assert.NotEmpty(t, errResp.Msg, "错误消息不应为空")
assert.NotEmpty(t, errResp.Timestamp, "时间戳不应为空")
// 验证响应头包含 Request ID
requestID := resp.Header.Get("X-Request-ID")
assert.NotEmpty(t, requestID, "响应头应包含 X-Request-ID")
t.Logf("✓ Panic 恢复测试通过 - Code: %s, Msg: %s, RequestID: %s",
errResp.Code, errResp.Msg, requestID)
}
// T040: 测试 panic 恢复后服务继续运行
func TestPanic_ServiceContinuesAfterRecovery(t *testing.T) {
app := setupTestApp()
// 创建会触发 panic 的路由
app.Get("/api/test/panic-endpoint", func(c *fiber.Ctx) error {
panic("触发 panic")
})
// 创建正常的路由
app.Get("/api/test/normal-endpoint", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"status": "ok",
"message": "服务正常运行",
})
})
// 第一次请求:触发 panic
panicReq := httptest.NewRequest("GET", "/api/test/panic-endpoint", nil)
panicResp, err := app.Test(panicReq, -1)
require.NoError(t, err)
_ = panicResp.Body.Close()
assert.Equal(t, 500, panicResp.StatusCode, "panic 应返回 500")
// 第二次请求:验证服务仍然正常运行
normalReq := httptest.NewRequest("GET", "/api/test/normal-endpoint", nil)
normalResp, err := app.Test(normalReq, -1)
require.NoError(t, err)
defer func() { _ = normalResp.Body.Close() }()
// 验证正常请求仍然成功
assert.Equal(t, 200, normalResp.StatusCode, "panic 后正常请求应成功")
// 验证响应内容
body, err := io.ReadAll(normalResp.Body)
require.NoError(t, err)
var response map[string]interface{}
err = json.Unmarshal(body, &response)
require.NoError(t, err)
assert.Equal(t, "ok", response["status"], "服务应正常运行")
t.Log("✓ Panic 后服务继续运行测试通过")
}
// T041: 测试并发场景下的 panic 处理
func TestPanic_ConcurrentPanicHandling(t *testing.T) {
app := setupTestApp()
// 创建会随机 panic 的路由
app.Get("/api/test/concurrent-panic/:id", func(c *fiber.Ctx) error {
id := c.Params("id")
// 奇数 ID 触发 panic偶数 ID 正常返回
if id == "1" || id == "3" || id == "5" || id == "7" || id == "9" {
panic("并发 panic 测试")
}
return c.JSON(fiber.Map{"id": id, "status": "ok"})
})
// 并发发送 10 个请求
const numRequests = 10
results := make(chan int, numRequests)
for i := 1; i <= numRequests; i++ {
go func(id int) {
req := httptest.NewRequest("GET", "/api/test/concurrent-panic/"+string(rune(id+'0')), nil)
resp, err := app.Test(req, -1)
if err != nil {
results <- 0
return
}
defer func() { _ = resp.Body.Close() }()
results <- resp.StatusCode
}(i)
}
// 收集结果
var successCount, errorCount int
for i := 0; i < numRequests; i++ {
statusCode := <-results
if statusCode == 200 {
successCount++
} else if statusCode == 500 {
errorCount++
}
}
// 验证结果5 个成功(偶数 ID5 个 panic 恢复(奇数 ID
assert.Equal(t, 5, successCount, "应有 5 个请求成功")
assert.Equal(t, 5, errorCount, "应有 5 个 panic 被恢复")
t.Logf("✓ 并发 Panic 处理测试通过 - 成功: %d, 错误: %d", successCount, errorCount)
}
// T042: 验证 panic 时的堆栈跟踪记录
func TestPanic_StackTraceLogging(t *testing.T) {
app := setupTestApp()
// 创建会触发 panic 的路由(在特定函数中)
app.Get("/api/test/panic-with-stack", func(c *fiber.Ctx) error {
// 调用一个会 panic 的函数以生成堆栈跟踪
triggerPanicInNestedFunction()
return nil
})
// 发起请求
req := httptest.NewRequest("GET", "/api/test/panic-with-stack", nil)
resp, err := app.Test(req, -1)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
// 验证 panic 被捕获
assert.Equal(t, 500, resp.StatusCode, "panic 应返回 500 状态码")
// 验证响应格式
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var errResp ErrorResponse
err = json.Unmarshal(body, &errResp)
require.NoError(t, err)
assert.NotEmpty(t, errResp.Code, "错误码不应为空")
assert.NotEmpty(t, errResp.Msg, "错误消息不应为空")
// 注意:堆栈跟踪会被记录到日志中,而不是返回给客户端
// 这里我们验证错误消息已被脱敏,不包含内部实现细节
assert.NotContains(t, errResp.Msg, "triggerPanicInNestedFunction",
"5xx 错误消息不应暴露内部函数名")
t.Log("✓ 堆栈跟踪记录测试通过 - 错误已脱敏,堆栈已记录到日志")
}
// triggerPanicInNestedFunction 是一个辅助函数,用于生成嵌套的堆栈跟踪
func triggerPanicInNestedFunction() {
anotherNestedFunction()
}
func anotherNestedFunction() {
panic("嵌套函数中的 panic")
}
// TestPanic_NilPointerDereference 测试空指针解引用 panic
func TestPanic_NilPointerDereference(t *testing.T) {
app := setupTestApp()
app.Get("/api/test/nil-pointer", func(c *fiber.Ctx) error {
var ptr *string
_ = *ptr // 触发空指针 panic
return nil
})
req := httptest.NewRequest("GET", "/api/test/nil-pointer", nil)
resp, err := app.Test(req, -1)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, 500, resp.StatusCode, "空指针 panic 应返回 500")
t.Log("✓ 空指针 Panic 测试通过")
}
// TestPanic_ArrayOutOfBounds 测试数组越界 panic
func TestPanic_ArrayOutOfBounds(t *testing.T) {
app := setupTestApp()
app.Get("/api/test/out-of-bounds", func(c *fiber.Ctx) error {
arr := []int{1, 2, 3}
_ = arr[10] // 触发数组越界 panic
return nil
})
req := httptest.NewRequest("GET", "/api/test/out-of-bounds", nil)
resp, err := app.Test(req, -1)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, 500, resp.StatusCode, "数组越界 panic 应返回 500")
t.Log("✓ 数组越界 Panic 测试通过")
}
// T045: 测试参数验证失败 -> Warn 级别日志
func TestErrorClassification_ValidationError_WarnLevel(t *testing.T) {
app := setupTestApp()
app.Post("/api/test/validation-warn", func(c *fiber.Ctx) error {
// 模拟参数验证失败 (客户端错误 1xxx -> Warn 级别)
return errors.New(
errors.CodeInvalidParam,
"用户名格式不正确",
)
})
req := httptest.NewRequest("POST", "/api/test/validation-warn", nil)
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, -1)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
// 验证 HTTP 状态码
assert.Equal(t, 400, resp.StatusCode, "参数验证失败应返回 400")
// 验证响应格式
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var errResp ErrorResponse
err = json.Unmarshal(body, &errResp)
require.NoError(t, err)
assert.NotEmpty(t, errResp.Code, "错误码不应为空")
assert.NotEmpty(t, errResp.Msg, "错误消息不应为空")
t.Logf("✓ 参数验证失败测试通过 (Warn 级别) - Code: %s, Msg: %s", errResp.Code, errResp.Msg)
}
// T046: 测试权限不足 -> Warn 级别日志
func TestErrorClassification_PermissionDenied_WarnLevel(t *testing.T) {
app := setupTestApp()
app.Get("/api/test/permission-warn", func(c *fiber.Ctx) error {
// 模拟权限不足 (客户端错误 1xxx -> Warn 级别)
return errors.New(
errors.CodeForbidden,
"您没有权限访问此资源",
)
})
req := httptest.NewRequest("GET", "/api/test/permission-warn", nil)
resp, err := app.Test(req, -1)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
// 验证 HTTP 状态码
assert.Equal(t, 403, resp.StatusCode, "权限不足应返回 403")
// 验证响应格式
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var errResp ErrorResponse
err = json.Unmarshal(body, &errResp)
require.NoError(t, err)
assert.NotEmpty(t, errResp.Code, "错误码不应为空")
assert.NotEmpty(t, errResp.Msg, "错误消息不应为空")
// 客户端错误可以保留自定义消息,验证包含权限相关提示
assert.Contains(t, errResp.Msg, "权限", "错误消息应包含权限相关提示")
t.Logf("✓ 权限不足测试通过 (Warn 级别) - Code: %s, Msg: %s", errResp.Code, errResp.Msg)
}
// T047: 测试数据库错误 -> Error 级别日志
func TestErrorClassification_DatabaseError_ErrorLevel(t *testing.T) {
app := setupTestApp()
app.Get("/api/test/database-error", func(c *fiber.Ctx) error {
// 模拟数据库错误 (服务端错误 2xxx -> Error 级别)
return errors.New(
errors.CodeDatabaseError,
"pq: relation 'users' does not exist", // 敏感的数据库错误信息
)
})
req := httptest.NewRequest("GET", "/api/test/database-error", nil)
resp, err := app.Test(req, -1)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
// 验证 HTTP 状态码
assert.Equal(t, 500, resp.StatusCode, "数据库错误应返回 500")
// 验证响应格式
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var errResp ErrorResponse
err = json.Unmarshal(body, &errResp)
require.NoError(t, err)
assert.NotEmpty(t, errResp.Code, "错误码不应为空")
assert.Equal(t, "数据库错误", errResp.Msg, "5xx 错误应返回标准消息")
// 验证敏感信息已被隐藏
assert.NotContains(t, errResp.Msg, "pq:", "不应暴露数据库驱动信息")
assert.NotContains(t, errResp.Msg, "relation", "不应暴露数据库表结构")
t.Logf("✓ 数据库错误测试通过 (Error 级别) - Code: %s, Msg: %s", errResp.Code, errResp.Msg)
}
// T048: 验证敏感信息隐藏 (数据库错误不暴露 SQL)
func TestErrorClassification_SensitiveInfoHidden(t *testing.T) {
app := setupTestApp()
testCases := []struct {
name string
path string
errorCode int
sensitiveMsg string
expectedStatus int
expectedMsg string
shouldNotContain []string
}{
{
name: "数据库连接错误",
path: "/api/test/db-connection",
errorCode: errors.CodeDatabaseError,
sensitiveMsg: "connection refused: tcp 192.168.1.100:5432",
expectedStatus: 500,
expectedMsg: "数据库错误",
shouldNotContain: []string{"192.168.1.100", "5432", "connection refused"},
},
{
name: "SQL 语法错误",
path: "/api/test/sql-syntax",
errorCode: errors.CodeDatabaseError,
sensitiveMsg: "syntax error at or near 'SELECT * FROM users WHERE id='",
expectedStatus: 500,
expectedMsg: "数据库错误",
shouldNotContain: []string{"SELECT", "FROM users", "syntax error"},
},
{
name: "Redis 连接错误",
path: "/api/test/redis-error",
errorCode: errors.CodeRedisError,
sensitiveMsg: "dial tcp 127.0.0.1:6379: connect: connection refused",
expectedStatus: 500,
expectedMsg: "缓存服务错误",
shouldNotContain: []string{"127.0.0.1", "6379", "dial tcp"},
},
{
name: "任务队列错误",
path: "/api/test/queue-error",
errorCode: errors.CodeTaskQueueError,
sensitiveMsg: "failed to enqueue task: redis: nil",
expectedStatus: 500,
expectedMsg: "任务队列错误",
shouldNotContain: []string{"enqueue", "redis: nil"},
},
}
for _, tc := range testCases {
tc := tc // 捕获循环变量
app.Get(tc.path, func(c *fiber.Ctx) error {
return errors.New(tc.errorCode, tc.sensitiveMsg)
})
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("GET", tc.path, nil)
resp, err := app.Test(req, -1)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, tc.expectedStatus, resp.StatusCode, "HTTP 状态码应正确")
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var errResp ErrorResponse
err = json.Unmarshal(body, &errResp)
require.NoError(t, err)
// 验证返回的是标准消息,不是自定义的敏感消息
assert.Equal(t, tc.expectedMsg, errResp.Msg, "应返回标准错误消息")
// 验证敏感信息已被隐藏
for _, sensitive := range tc.shouldNotContain {
assert.NotContains(t, errResp.Msg, sensitive,
"错误消息不应包含敏感信息: %s", sensitive)
}
t.Logf("✓ %s - 敏感信息已隐藏", tc.name)
})
}
t.Log("✓ 所有敏感信息隐藏测试通过")
}
// T049: 测试限流错误 -> 429 响应
func TestErrorClassification_RateLimitExceeded_Returns429(t *testing.T) {
app := setupTestApp()
app.Get("/api/test/rate-limit", func(c *fiber.Ctx) error {
// 模拟触发限流
return errors.New(
errors.CodeTooManyRequests,
"您的请求过于频繁,请稍后重试",
)
})
req := httptest.NewRequest("GET", "/api/test/rate-limit", nil)
resp, err := app.Test(req, -1)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
// 验证 HTTP 状态码
assert.Equal(t, 429, resp.StatusCode, "限流应返回 429 状态码")
// 验证响应格式
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var errResp ErrorResponse
err = json.Unmarshal(body, &errResp)
require.NoError(t, err)
assert.NotEmpty(t, errResp.Code, "错误码不应为空")
// 客户端错误可以保留自定义消息,验证包含限流相关提示
assert.True(t,
contains(errResp.Msg, "请求过多") || contains(errResp.Msg, "请求过于频繁"),
"错误消息应包含限流提示")
// 验证响应头
requestID := resp.Header.Get("X-Request-ID")
assert.NotEmpty(t, requestID, "响应头应包含 X-Request-ID")
t.Logf("✓ 限流错误测试通过 - Code: %s, Status: %d, Msg: %s",
errResp.Code, resp.StatusCode, errResp.Msg)
}
// contains 辅助函数用于字符串包含检查
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && len(substr) > 0 && stringContains(s, substr)))
}
func stringContains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// T050: 测试服务不可用 -> 503 响应
func TestErrorClassification_ServiceUnavailable_Returns503(t *testing.T) {
app := setupTestApp()
app.Get("/api/test/service-unavailable", func(c *fiber.Ctx) error {
// 模拟服务不可用 (如外部 API 不可用)
return errors.New(
errors.CodeServiceUnavailable,
"外部认证服务暂时不可用",
)
})
req := httptest.NewRequest("GET", "/api/test/service-unavailable", nil)
resp, err := app.Test(req, -1)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
// 验证 HTTP 状态码
assert.Equal(t, 503, resp.StatusCode, "服务不可用应返回 503 状态码")
// 验证响应格式
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var errResp ErrorResponse
err = json.Unmarshal(body, &errResp)
require.NoError(t, err)
assert.NotEmpty(t, errResp.Code, "错误码不应为空")
// 5xx 错误应返回标准消息,而不是自定义消息
assert.Equal(t, "服务暂时不可用", errResp.Msg, "应返回标准错误消息")
assert.NotContains(t, errResp.Msg, "外部认证服务", "不应暴露内部服务细节")
t.Logf("✓ 服务不可用错误测试通过 - Code: %s, Status: %d, Msg: %s",
errResp.Code, resp.StatusCode, errResp.Msg)
}
// ===== Phase 6: User Story 4 - 错误追踪和调试支持 =====
// T054: 测试错误日志完整性(包含 Request ID
func TestErrorTracking_LogCompleteness_IncludesRequestID(t *testing.T) {
app := setupTestApp()
app.Post("/api/test/error-log-completeness", func(c *fiber.Ctx) error {
// 触发一个错误,验证日志包含 Request ID
return errors.New(
errors.CodeInvalidParam,
"测试错误日志完整性",
)
})
// 发起请求(带自定义 Request ID
req := httptest.NewRequest("POST", "/api/test/error-log-completeness", nil)
customRequestID := "test-request-id-12345"
req.Header.Set("X-Request-ID", customRequestID)
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, -1)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
// 验证响应包含 Request ID
responseRequestID := resp.Header.Get("X-Request-ID")
assert.NotEmpty(t, responseRequestID, "响应头应包含 X-Request-ID")
// 验证响应成功
assert.Equal(t, 400, resp.StatusCode, "应返回 400 错误")
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var errResp ErrorResponse
err = json.Unmarshal(body, &errResp)
require.NoError(t, err)
assert.NotEmpty(t, errResp.Code, "错误码不应为空")
assert.NotEmpty(t, errResp.Msg, "错误消息不应为空")
t.Logf("✓ 错误日志完整性测试通过 - RequestID: %s", responseRequestID)
t.Log(" 注意:实际的日志完整性需要检查日志文件,确认包含 request_id, method, path, ip 等字段")
}
// T055: 测试请求上下文记录(路径、方法、参数)
func TestErrorTracking_RequestContext_AllFields(t *testing.T) {
app := setupTestApp()
app.Post("/api/test/context-logging", func(c *fiber.Ctx) error {
// 触发一个错误,验证日志包含完整的请求上下文
return errors.New(
errors.CodeInvalidParam,
"测试请求上下文记录",
)
})
// 构造带有 Query 参数和 Body 的请求
requestBody := `{"username": "testuser", "email": "test@example.com"}`
req := httptest.NewRequest("POST", "/api/test/context-logging?page=1&size=10",
strings.NewReader(requestBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "TestClient/1.0")
resp, err := app.Test(req, -1)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
// 验证响应
assert.Equal(t, 400, resp.StatusCode, "应返回 400 错误")
requestID := resp.Header.Get("X-Request-ID")
assert.NotEmpty(t, requestID, "响应头应包含 X-Request-ID")
t.Logf("✓ 请求上下文记录测试通过 - RequestID: %s", requestID)
t.Log(" 注意:需要检查日志文件,确认包含:")
t.Log(" - method: POST")
t.Log(" - path: /api/test/context-logging")
t.Log(" - query: page=1&size=10")
t.Log(" - body: 请求体内容(限制 50KB")
t.Log(" - user_agent: TestClient/1.0")
t.Log(" - ip: 客户端 IP")
}
// T056: 测试 panic 堆栈跟踪记录(指明 panic 位置)
func TestErrorTracking_PanicStackTrace_IncludesLocation(t *testing.T) {
app := setupTestApp()
app.Get("/api/test/panic-stack-trace", func(c *fiber.Ctx) error {
// 在一个可识别的函数中触发 panic
panicInSpecificLocation()
return nil
})
req := httptest.NewRequest("GET", "/api/test/panic-stack-trace", nil)
resp, err := app.Test(req, -1)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
// 验证 panic 被捕获
assert.Equal(t, 500, resp.StatusCode, "panic 应返回 500 状态码")
// 验证响应格式
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var errResp ErrorResponse
err = json.Unmarshal(body, &errResp)
require.NoError(t, err)
assert.NotEmpty(t, errResp.Code, "错误码不应为空")
assert.NotEmpty(t, errResp.Msg, "错误消息不应为空")
// 验证响应中不包含堆栈跟踪(敏感信息已脱敏)
assert.NotContains(t, errResp.Msg, "panicInSpecificLocation",
"5xx 错误消息不应暴露内部函数名")
requestID := resp.Header.Get("X-Request-ID")
assert.NotEmpty(t, requestID, "响应头应包含 X-Request-ID")
t.Logf("✓ Panic 堆栈跟踪测试通过 - RequestID: %s", requestID)
t.Log(" 注意:需要检查日志文件,确认包含:")
t.Log(" - 完整的堆栈跟踪runtime/debug.Stack()")
t.Log(" - 文件名和行号")
t.Log(" - 函数名panicInSpecificLocation")
t.Log(" - 从 panic 发生点到 recover 捕获点的完整调用链")
}
// panicInSpecificLocation 辅助函数,用于触发可追踪的 panic
func panicInSpecificLocation() {
panic("这是一个特定位置的 panic用于测试堆栈跟踪")
}
// T057: 测试使用 Request ID 追踪请求流程
func TestErrorTracking_RequestIDTracing_EndToEnd(t *testing.T) {
app := setupTestApp()
// 创建多个端点模拟完整的请求流程
app.Post("/api/test/trace/step1", func(c *fiber.Ctx) error {
// 第一步:参数验证失败
return errors.New(
errors.CodeInvalidParam,
"步骤 1: 参数验证失败",
)
})
app.Post("/api/test/trace/step2", func(c *fiber.Ctx) error {
// 第二步:业务逻辑错误
return errors.New(
errors.CodeDatabaseError,
"步骤 2: 数据库操作失败",
)
})
app.Post("/api/test/trace/step3", func(c *fiber.Ctx) error {
// 第三步:触发 panic
panic("步骤 3: 发生 panic")
})
// 使用相同的 Request ID 追踪整个流程
traceID := "trace-test-" + time.Now().Format("20060102-150405")
testCases := []struct {
name string
path string
expectedStatus int
stepDesc string
}{
{
name: "步骤1-参数验证",
path: "/api/test/trace/step1",
expectedStatus: 400,
stepDesc: "参数验证失败应记录 Warn 级别日志",
},
{
name: "步骤2-数据库错误",
path: "/api/test/trace/step2",
expectedStatus: 500,
stepDesc: "数据库错误应记录 Error 级别日志",
},
{
name: "步骤3-Panic恢复",
path: "/api/test/trace/step3",
expectedStatus: 500,
stepDesc: "Panic 应记录 Error 级别日志和堆栈跟踪",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("POST", tc.path, nil)
req.Header.Set("X-Request-ID", traceID)
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, -1)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
// 验证 HTTP 状态码
assert.Equal(t, tc.expectedStatus, resp.StatusCode,
"%s 应返回 %d 状态码", tc.name, tc.expectedStatus)
// 验证响应包含相同的 Request ID
responseRequestID := resp.Header.Get("X-Request-ID")
assert.NotEmpty(t, responseRequestID, "响应头应包含 X-Request-ID")
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var errResp ErrorResponse
err = json.Unmarshal(body, &errResp)
require.NoError(t, err)
assert.NotEmpty(t, errResp.Code, "错误码不应为空")
assert.NotEmpty(t, errResp.Msg, "错误消息不应为空")
t.Logf(" ✓ %s 完成 - RequestID: %s, Status: %d",
tc.name, responseRequestID, resp.StatusCode)
t.Logf(" %s", tc.stepDesc)
})
}
t.Logf("✓ Request ID 追踪测试通过 - TraceID: %s", traceID)
t.Log(" 注意:可以在日志文件中搜索 TraceID追踪完整的请求流程")
t.Logf(" grep '%s' tests/integration/logs/error_handler_test.log", traceID)
t.Log(" 应该能看到 3 个步骤的完整日志记录")
}