| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145 |
- // 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)
- }
- }
|