- 新增统一错误码定义和管理 (pkg/errors/codes.go) - 新增全局错误处理器和中间件 (pkg/errors/handler.go, internal/middleware/error_handler.go) - 新增错误上下文管理 (pkg/errors/context.go) - 增强 Panic 恢复中间件 (internal/middleware/recover.go) - 新增完整的单元测试和集成测试 - 新增功能文档 (docs/003-error-handling/) - 新增功能规范 (specs/003-error-handling/) - 更新 CLAUDE.md 和 README.md
1086 lines
33 KiB
Go
1086 lines
33 KiB
Go
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 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 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 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 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 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 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 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 个成功(偶数 ID),5 个 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 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 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 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 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 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 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 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 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 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 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 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 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 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 个步骤的完整日志记录")
|
||
}
|