response_test.go 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. // response.Fail 单测 —— 统一错误出口的三条对外契约。
  2. //
  3. // 为什么本文件是新建的:`internal/infra/response` 此前**零测试**。
  4. // 这解释了一个长期存在的缺陷为什么没人发现——`Fail` 的注释写着
  5. // "原始错误仅用于服务端日志,不进入响应体",而实现里**根本没有日志语句**;
  6. // 注释与实现不一致,且没有任何用例覆盖这条路径。
  7. //
  8. // 本文件锁住四件事(每条都能在回滚实现后变红):
  9. //
  10. // ① 对外文案优先级:PublicMsg > Msg > "系统繁忙";
  11. // ② Cause **永不**进响应体(可能含 SQL / 内部分层名 / gorm 原始错误);
  12. // ③ 非 *AppError 一律降级 5001,不泄漏原始错误文本;
  13. // ④ code >= 5000 必须留日志(否则服务端故障彻底静默);1xxx~3xxx 不记(否则淹没真问题)。
  14. package response
  15. import (
  16. "bytes"
  17. "encoding/json"
  18. stdErrors "errors"
  19. "log"
  20. "net/http/httptest"
  21. "strings"
  22. "testing"
  23. "github.com/gofiber/fiber/v2"
  24. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  25. )
  26. // failOnce 用给定 error 走一次 Fail,返回解析后的 (code, message) 与原始 body。
  27. func failOnce(t *testing.T, err error) (code int, message, body string) {
  28. t.Helper()
  29. app := fiber.New(fiber.Config{DisableStartupMessage: true})
  30. app.Get("/x", func(c *fiber.Ctx) error { return Fail(c, err) })
  31. resp, terr := app.Test(httptest.NewRequest("GET", "/x", nil), 5000)
  32. if terr != nil {
  33. t.Fatalf("请求失败: %v", terr)
  34. }
  35. defer func() { _ = resp.Body.Close() }()
  36. var buf bytes.Buffer
  37. if _, cerr := buf.ReadFrom(resp.Body); cerr != nil {
  38. t.Fatalf("读 body 失败: %v", cerr)
  39. }
  40. body = buf.String()
  41. var envelope struct {
  42. Code int `json:"code"`
  43. Message string `json:"message"`
  44. }
  45. if uerr := json.Unmarshal([]byte(body), &envelope); uerr != nil {
  46. t.Fatalf("解码失败: %v body=%s", uerr, body)
  47. }
  48. return envelope.Code, envelope.Message, body
  49. }
  50. // TestFailPrefersPublicMsg ① PublicMsg 优先于 sentinel 的 Msg。
  51. //
  52. // 回滚方式:把 `msg := e.PublicMsg` 改回 `msg := e.Msg` ⇒ 本用例红。
  53. func TestFailPrefersPublicMsg(t *testing.T) {
  54. err := infraerrors.WithPublicMsg(infraerrors.ErrNotFound,
  55. stdErrors.New("debug: row 42"), "找不到该学员(编号 42)")
  56. code, msg, _ := failOnce(t, err)
  57. if code != infraerrors.CodeNotFound {
  58. t.Fatalf("code 应为 3001,实得 %d", code)
  59. }
  60. if msg != "找不到该学员(编号 42)" {
  61. t.Fatalf("应优先使用 PublicMsg,实得 %q", msg)
  62. }
  63. }
  64. // TestFailFallsBackToMsg ② 未给 PublicMsg 时回落到 sentinel 的 Msg。
  65. func TestFailFallsBackToMsg(t *testing.T) {
  66. _, msg, _ := failOnce(t, infraerrors.WithCause(infraerrors.ErrNotFound,
  67. stdErrors.New("debug: row 42")))
  68. if msg != infraerrors.ErrNotFound.Msg {
  69. t.Fatalf("应回落到 Msg %q,实得 %q", infraerrors.ErrNotFound.Msg, msg)
  70. }
  71. }
  72. // TestFailCauseNeverLeaksToBody ③ Cause 不得出现在响应体(即使 PublicMsg 与 Msg 都为空)。
  73. //
  74. // 这是安全判据:Cause 里常有 SQL 片段、表名、gorm 原始错误。
  75. // 回滚方式:让 Fail 改用 `e.Error()` 作为文案 ⇒ 本用例立刻红。
  76. func TestFailCauseNeverLeaksToBody(t *testing.T) {
  77. secret := "SELECT * FROM t_user WHERE password_hash = 'abc'"
  78. err := infraerrors.WithCause(infraerrors.ErrInternal, stdErrors.New(secret))
  79. code, msg, body := failOnce(t, err)
  80. if code != infraerrors.CodeInternal {
  81. t.Fatalf("code 应为 5001,实得 %d", code)
  82. }
  83. if msg != "系统繁忙" {
  84. t.Fatalf("5001 对外文案应为「系统繁忙」,实得 %q", msg)
  85. }
  86. if strings.Contains(body, "SELECT") || strings.Contains(body, "password_hash") {
  87. t.Fatalf("响应体泄漏了 Cause 内容:%s", body)
  88. }
  89. }
  90. // TestFailNonAppErrorDegradesTo5001 ④ 非 *AppError 降级 5001,且原始文本不外泄。
  91. func TestFailNonAppErrorDegradesTo5001(t *testing.T) {
  92. code, msg, body := failOnce(t, stdErrors.New("dial tcp 10.0.0.5:3306: connect refused"))
  93. if code != infraerrors.CodeInternal {
  94. t.Fatalf("非 AppError 应降级 5001,实得 %d", code)
  95. }
  96. if msg != "系统繁忙" {
  97. t.Fatalf("降级文案应为「系统繁忙」,实得 %q", msg)
  98. }
  99. if strings.Contains(body, "3306") || strings.Contains(body, "10.0.0.5") {
  100. t.Fatalf("响应体泄漏了内部地址:%s", body)
  101. }
  102. }
  103. // TestFailLogsServerFaultsOnly ⑤ code>=5000 记日志,1xxx~3xxx 不记。
  104. //
  105. // 为什么这条重要:5001 对用户只有"系统繁忙",若不留日志,数据异常(如分账树自环)
  106. // 会彻底静默——用户报障时既看不到码也看不到原因。反过来,业务错误(3001 等)
  107. // 是**预期内**的,逐个记日志会把真问题淹没。
  108. func TestFailLogsServerFaultsOnly(t *testing.T) {
  109. var buf bytes.Buffer
  110. oldOut, oldFlags := log.Writer(), log.Flags()
  111. log.SetOutput(&buf)
  112. log.SetFlags(0)
  113. defer func() { log.SetOutput(oldOut); log.SetFlags(oldFlags) }()
  114. // 5001:必须留痕,且日志里要能看到 Cause
  115. buf.Reset()
  116. failOnce(t, infraerrors.WithCause(infraerrors.ErrInternal, stdErrors.New("分账树自环(需人工修树)")))
  117. logged := buf.String()
  118. if logged == "" {
  119. t.Fatal("5001 未记日志:服务端故障会彻底静默,排查时无从下手")
  120. }
  121. if !strings.Contains(logged, "分账树自环") {
  122. t.Fatalf("日志应包含 Cause 以便定位,实得 %q", logged)
  123. }
  124. if strings.Contains(logged, "系统繁忙") {
  125. t.Logf("提示:日志同时含对外文案是正常的(便于把用户报的文案对应到日志)")
  126. }
  127. // 3001:业务错误,不得记日志
  128. buf.Reset()
  129. failOnce(t, infraerrors.WithCause(infraerrors.ErrNotFound, stdErrors.New("no rows")))
  130. if got := buf.String(); got != "" {
  131. t.Fatalf("业务错误(3001)不应记日志(会淹没真问题),实得 %q", got)
  132. }
  133. }