package middleware import ( "bytes" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "git.yeij.top/AskaEth/Scoreboard/server/internal/logging" ) // panicOnce 第一次请求 panic,之后正常应答:这样一条链既能验「panic 被兜住」, // 又能验「兜住之后同一条链照常服务」(panic 没有被吞成静默死循环、也没有把进程带走)。 func panicOnce(secret string) http.Handler { var hits int return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ if hits == 1 { panic(secret) } w.Header().Set("Content-Type", "application/json; charset=utf-8") _, _ = w.Write([]byte(`{"ok":true}`)) }) } // internalErrorBody 是 500 响应的形状:与 httpapi 的统一错误体同构 // (writeError 与 httpapi.writeAPIError 是两份实现,形状必须对得上,前端只按一种解)。 type internalErrorBody struct { OK bool `json:"ok"` Error struct { Code string `json:"code"` Message string `json:"message"` Detail json.RawMessage `json:"detail"` } `json:"error"` } // 全仓此前没有任何「让 handler panic」的用例:Recover 写错状态码、写成 200、 // 把 panic 原文(含栈、含内部数据)回给客户端,都没有东西会红。 func TestRecoverReturnsInternalErrorAndKeepsServing(t *testing.T) { const secret = "重算名次时下标越界:athleteID=8848" chain := RequestID(Recover(slog.New(slog.NewTextHandler(io.Discard, nil)))(panicOnce(secret))) rec := httptest.NewRecorder() chain.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/standings", nil)) if rec.Code != http.StatusInternalServerError { t.Fatalf("panic 应回 500,得到 %d(body %s)", rec.Code, rec.Body.String()) } if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" { t.Fatalf("500 的 Content-Type 应为 application/json; charset=utf-8,得到 %q", ct) } // 响应体必须是一份完整、可解析的统一错误体:半截 JSON 会让前端把它当网络错误。 var body internalErrorBody if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("500 响应体应为完整 JSON: %v\n%s", err, rec.Body.String()) } if body.OK { t.Fatalf("500 不该是 ok:true,得到 %s", rec.Body.String()) } if body.Error.Code != "INTERNAL" || body.Error.Message != "服务端异常" { t.Fatalf("500 形状应为 code=INTERNAL / message=服务端异常,得到 %+v", body.Error) } if body.Error.Detail != nil { t.Fatalf("panic 不该回 detail(内部细节不外泄),得到 %s", body.Error.Detail) } // panic 原文与栈一律不回客户端:里面常有 id、SQL、路径。 for _, leak := range []string{secret, "recover_test.go", "goroutine "} { if strings.Contains(rec.Body.String(), leak) { t.Fatalf("500 响应体泄露了 %q:%s", leak, rec.Body.String()) } } // 同一条链的下一个请求照常:panic 只结束这一个请求。 rec2 := httptest.NewRecorder() chain.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/api/standings", nil)) if rec2.Code != http.StatusOK || !strings.Contains(rec2.Body.String(), `"ok":true`) { t.Fatalf("panic 之后同一条链应照常服务,得到 %d %s", rec2.Code, rec2.Body.String()) } } // 日志侧:handler panic 是排查类事件,必须落 interceptions/(不是 operations/), // 且要带 panic 值、路径、request_id 与真实栈——只写一句「服务端异常」等于什么都没留。 func TestRecoverLogsPanicToInterceptions(t *testing.T) { const secret = "重算名次时下标越界:athleteID=8848" dir := t.TempDir() console := &bytes.Buffer{} handler, closer := logging.New(logging.Options{ Dir: dir, Level: slog.LevelInfo, Console: console, MaxSizeMB: 20, KeepFiles: 10, }) t.Cleanup(func() { _ = closer.Close() }) chain := RequestID(Recover(slog.New(handler))(panicOnce(secret))) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/recompute", nil) chain.ServeHTTP(rec, req) inter := readLogFile(t, dir, logging.DirInterceptions) for _, want := range []string{ `"level":"ERROR"`, "请求处理 panic", secret, `"path":"/api/recompute"`, `"requestId":"`, // 请求 id 必须能对上现场那次请求 `"stack":"`, "recover_test.go", // 栈是这次 panic 的真实栈,不是一句占位文案 } { if !strings.Contains(inter, want) { t.Fatalf("interceptions 日志应含 %q,得到:\n%s", want, inter) } } // request_id 不能是空串:空串等于没有追踪键。 if strings.Contains(inter, `"requestId":""`) { t.Fatalf("panic 日志的 requestId 不该为空:\n%s", inter) } // 拦截类不落 operations/:两类日志混在一起时,「有没有被拦过」就查不出来了。 if ops := readLogFile(t, dir, logging.DirOperations); strings.Contains(ops, "请求处理 panic") { t.Fatalf("panic 日志不该落 operations/,得到:\n%s", ops) } // 控制台仍要能看到(现场直接跑二进制时只有控制台)。 if !strings.Contains(console.String(), "请求处理 panic") { t.Fatalf("控制台应看到 panic 日志,得到:\n%s", console.String()) } } // readLogFile 读某类日志今天的文件内容;文件不存在返回空串。 func readLogFile(t *testing.T, dir, category string) string { t.Helper() path := filepath.Join(dir, category, category+"-"+time.Now().Format("20060102")+".log") raw, err := os.ReadFile(path) if os.IsNotExist(err) { return "" } if err != nil { t.Fatalf("读日志 %s: %v", path, err) } return string(raw) }