// response.Fail 单测 —— 统一错误出口的三条对外契约。 // // 为什么本文件是新建的:`internal/infra/response` 此前**零测试**。 // 这解释了一个长期存在的缺陷为什么没人发现——`Fail` 的注释写着 // "原始错误仅用于服务端日志,不进入响应体",而实现里**根本没有日志语句**; // 注释与实现不一致,且没有任何用例覆盖这条路径。 // // 本文件锁住四件事(每条都能在回滚实现后变红): // // ① 对外文案优先级:PublicMsg > Msg > "系统繁忙"; // ② Cause **永不**进响应体(可能含 SQL / 内部分层名 / gorm 原始错误); // ③ 非 *AppError 一律降级 5001,不泄漏原始错误文本; // ④ code >= 5000 必须留日志(否则服务端故障彻底静默);1xxx~3xxx 不记(否则淹没真问题)。 package response import ( "bytes" "encoding/json" stdErrors "errors" "log" "net/http/httptest" "strings" "testing" "github.com/gofiber/fiber/v2" infraerrors "yuansiwei/unified-backend/internal/infra/errors" ) // failOnce 用给定 error 走一次 Fail,返回解析后的 (code, message) 与原始 body。 func failOnce(t *testing.T, err error) (code int, message, body string) { t.Helper() app := fiber.New(fiber.Config{DisableStartupMessage: true}) app.Get("/x", func(c *fiber.Ctx) error { return Fail(c, err) }) resp, terr := app.Test(httptest.NewRequest("GET", "/x", nil), 5000) if terr != nil { t.Fatalf("请求失败: %v", terr) } defer func() { _ = resp.Body.Close() }() var buf bytes.Buffer if _, cerr := buf.ReadFrom(resp.Body); cerr != nil { t.Fatalf("读 body 失败: %v", cerr) } body = buf.String() var envelope struct { Code int `json:"code"` Message string `json:"message"` } if uerr := json.Unmarshal([]byte(body), &envelope); uerr != nil { t.Fatalf("解码失败: %v body=%s", uerr, body) } return envelope.Code, envelope.Message, body } // TestFailPrefersPublicMsg ① PublicMsg 优先于 sentinel 的 Msg。 // // 回滚方式:把 `msg := e.PublicMsg` 改回 `msg := e.Msg` ⇒ 本用例红。 func TestFailPrefersPublicMsg(t *testing.T) { err := infraerrors.WithPublicMsg(infraerrors.ErrNotFound, stdErrors.New("debug: row 42"), "找不到该学员(编号 42)") code, msg, _ := failOnce(t, err) if code != infraerrors.CodeNotFound { t.Fatalf("code 应为 3001,实得 %d", code) } if msg != "找不到该学员(编号 42)" { t.Fatalf("应优先使用 PublicMsg,实得 %q", msg) } } // TestFailFallsBackToMsg ② 未给 PublicMsg 时回落到 sentinel 的 Msg。 func TestFailFallsBackToMsg(t *testing.T) { _, msg, _ := failOnce(t, infraerrors.WithCause(infraerrors.ErrNotFound, stdErrors.New("debug: row 42"))) if msg != infraerrors.ErrNotFound.Msg { t.Fatalf("应回落到 Msg %q,实得 %q", infraerrors.ErrNotFound.Msg, msg) } } // TestFailCauseNeverLeaksToBody ③ Cause 不得出现在响应体(即使 PublicMsg 与 Msg 都为空)。 // // 这是安全判据:Cause 里常有 SQL 片段、表名、gorm 原始错误。 // 回滚方式:让 Fail 改用 `e.Error()` 作为文案 ⇒ 本用例立刻红。 func TestFailCauseNeverLeaksToBody(t *testing.T) { secret := "SELECT * FROM t_user WHERE password_hash = 'abc'" err := infraerrors.WithCause(infraerrors.ErrInternal, stdErrors.New(secret)) code, msg, body := failOnce(t, err) if code != infraerrors.CodeInternal { t.Fatalf("code 应为 5001,实得 %d", code) } if msg != "系统繁忙" { t.Fatalf("5001 对外文案应为「系统繁忙」,实得 %q", msg) } if strings.Contains(body, "SELECT") || strings.Contains(body, "password_hash") { t.Fatalf("响应体泄漏了 Cause 内容:%s", body) } } // TestFailNonAppErrorDegradesTo5001 ④ 非 *AppError 降级 5001,且原始文本不外泄。 func TestFailNonAppErrorDegradesTo5001(t *testing.T) { code, msg, body := failOnce(t, stdErrors.New("dial tcp 10.0.0.5:3306: connect refused")) if code != infraerrors.CodeInternal { t.Fatalf("非 AppError 应降级 5001,实得 %d", code) } if msg != "系统繁忙" { t.Fatalf("降级文案应为「系统繁忙」,实得 %q", msg) } if strings.Contains(body, "3306") || strings.Contains(body, "10.0.0.5") { t.Fatalf("响应体泄漏了内部地址:%s", body) } } // TestFailLogsServerFaultsOnly ⑤ code>=5000 记日志,1xxx~3xxx 不记。 // // 为什么这条重要:5001 对用户只有"系统繁忙",若不留日志,数据异常(如分账树自环) // 会彻底静默——用户报障时既看不到码也看不到原因。反过来,业务错误(3001 等) // 是**预期内**的,逐个记日志会把真问题淹没。 func TestFailLogsServerFaultsOnly(t *testing.T) { var buf bytes.Buffer oldOut, oldFlags := log.Writer(), log.Flags() log.SetOutput(&buf) log.SetFlags(0) defer func() { log.SetOutput(oldOut); log.SetFlags(oldFlags) }() // 5001:必须留痕,且日志里要能看到 Cause buf.Reset() failOnce(t, infraerrors.WithCause(infraerrors.ErrInternal, stdErrors.New("分账树自环(需人工修树)"))) logged := buf.String() if logged == "" { t.Fatal("5001 未记日志:服务端故障会彻底静默,排查时无从下手") } if !strings.Contains(logged, "分账树自环") { t.Fatalf("日志应包含 Cause 以便定位,实得 %q", logged) } if strings.Contains(logged, "系统繁忙") { t.Logf("提示:日志同时含对外文案是正常的(便于把用户报的文案对应到日志)") } // 3001:业务错误,不得记日志 buf.Reset() failOnce(t, infraerrors.WithCause(infraerrors.ErrNotFound, stdErrors.New("no rows"))) if got := buf.String(); got != "" { t.Fatalf("业务错误(3001)不应记日志(会淹没真问题),实得 %q", got) } }