Files
Cyrene_v2/internal/services/gateway/observability_logs_test.go
T
AskaEth 074ffc4ebf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): gateway 剩余文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批:
已推库 16 个文件 + 本批剩余文件)。
校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0);
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
2026-10-03 22:28:25 +08:00

222 lines
7.3 KiB
Go

package gateway
import (
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// logEnvelope 解码日志尾部端点的 {data:[...]} 信封。
type logEnvelope struct {
Data []AdminLogEntry `json:"data"`
}
// logsServer 构建一个网关,注入了已由真实 logger 喂入的日志
// 环形缓冲,因此该端点是针对生产捕获路径演练的。
func logsServer(t *testing.T) (*Server, string, string) {
t.Helper()
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
ring := logger.NewRingBuffer(16)
log := logger.New(logger.Options{Level: "debug", Format: "text", Out: io.Discard, Ring: ring})
log.Info("boot ok")
logger.WithSource(log, "gateway").Warn("slow request")
logger.WithSource(log, "runtime/loop").Error("loop crashed")
srv.SetLogBuffer(ring)
return srv, ts.URL, signAny(t, jwt, "acct", auth.RoleOwner)
}
func TestAdminObservabilityLogs_NewestFirstWithFields(t *testing.T) {
t.Parallel()
_, base, tok := logsServer(t)
resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/observability/logs", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var env logEnvelope
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v (body=%s)", err, body)
}
want := []AdminLogEntry{
{Level: "error", Source: "runtime/loop", Msg: "loop crashed"},
{Level: "warn", Source: "gateway", Msg: "slow request"},
{Level: "info", Source: "", Msg: "boot ok"},
}
if len(env.Data) != len(want) {
t.Fatalf("got %d entries, want %d (%+v)", len(env.Data), len(want), env.Data)
}
for i, w := range want {
got := env.Data[i]
if got.Level != w.Level || got.Source != w.Source || got.Msg != w.Msg {
t.Fatalf("data[%d] = %+v, want level=%s source=%q msg=%q", i, got, w.Level, w.Source, w.Msg)
}
if _, err := time.Parse(time.RFC3339, got.Time); err != nil {
t.Fatalf("data[%d].Time = %q, want RFC3339: %v", i, got.Time, err)
}
}
}
func TestAdminObservabilityLogs_LevelFilter(t *testing.T) {
t.Parallel()
_, base, tok := logsServer(t)
cases := []struct {
level string
want []string
}{
{"debug", []string{"error", "warn", "info"}},
{"info", []string{"error", "warn", "info"}},
{"warn", []string{"error", "warn"}},
{"error", []string{"error"}},
{"ERROR", []string{"error"}}, // 大小写不敏感
{"", []string{"error", "warn", "info"}},
}
for _, tc := range cases {
tc := tc
t.Run(tc.level, func(t *testing.T) {
t.Parallel()
resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/observability/logs?level="+tc.level, nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var env logEnvelope
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v (body=%s)", err, body)
}
if len(env.Data) != len(tc.want) {
t.Fatalf("level=%q got %d entries, want %d (%+v)", tc.level, len(env.Data), len(tc.want), env.Data)
}
for i, lvl := range tc.want {
if env.Data[i].Level != lvl {
t.Fatalf("level=%q data[%d].Level = %q, want %q", tc.level, i, env.Data[i].Level, lvl)
}
}
})
}
}
func TestAdminObservabilityLogs_UnknownLevelIs400(t *testing.T) {
t.Parallel()
_, base, tok := logsServer(t)
for _, bad := range []string{"trace", "warning", "verbose"} {
resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/observability/logs?level="+bad, nil, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("level=%q status = %d, want 400 (body=%s)", bad, resp.StatusCode, body)
}
wantErrorShape(t, body)
if !strings.Contains(string(body), "invalid_level") {
t.Fatalf("level=%q body = %s, want invalid_level code", bad, body)
}
}
}
func TestAdminObservabilityLogs_LimitClamped(t *testing.T) {
t.Parallel()
_, base, tok := logsServer(t)
resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/observability/logs?limit=2", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var env logEnvelope
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v", err)
}
if len(env.Data) != 2 || env.Data[0].Msg != "loop crashed" || env.Data[1].Msg != "slow request" {
t.Fatalf("limit=2 data = %+v, want the two newest", env.Data)
}
// 非法/缺失的 limit 回退到默认值(这里全部 3 条 < 200)。
resp, body = doReq(t, http.MethodGet, base+"/api/v1/admin/observability/logs?limit=abc", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("bad limit status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var env2 logEnvelope
if err := json.Unmarshal(body, &env2); err != nil {
t.Fatalf("decode: %v", err)
}
if len(env2.Data) != 3 {
t.Fatalf("bad limit returned %d entries, want the default (3)", len(env2.Data))
}
}
func TestParseLogLimit_ClampBounds(t *testing.T) {
t.Parallel()
mk := func(raw string) int {
r, err := http.NewRequest(http.MethodGet, "/?limit="+raw, nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
return parseLogLimit(r)
}
if got := mk("99999"); got != maxLogLimit {
t.Fatalf("parseLogLimit(99999) = %d, want max %d", got, maxLogLimit)
}
if got := mk("0"); got != defaultLogLimit {
t.Fatalf("parseLogLimit(0) = %d, want default %d", got, defaultLogLimit)
}
if got := mk(""); got != defaultLogLimit {
t.Fatalf("parseLogLimit(empty) = %d, want default %d", got, defaultLogLimit)
}
if got := mk("7"); got != 7 {
t.Fatalf("parseLogLimit(7) = %d, want 7", got)
}
}
func TestAdminObservabilityLogs_NoBufferIs501(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/observability/logs", nil, tok)
if resp.StatusCode != http.StatusNotImplemented {
t.Fatalf("status = %d, want 501 (body=%s)", resp.StatusCode, body)
}
wantErrorShape(t, body)
if !strings.Contains(string(body), "not_implemented") {
t.Fatalf("body = %s, want not_implemented code", body)
}
}
func TestAdminObservabilityLogs_EmptyBufferIs200Empty(t *testing.T) {
t.Parallel()
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
srv.SetLogBuffer(logger.NewRingBuffer(4))
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/observability/logs", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if !strings.Contains(string(body), `"data":[]`) {
t.Fatalf("body = %s, want an empty data list", body)
}
}
// TestAdminObservabilityLogs_ViewerReadsTail 锁定 viewer 门:日志尾部
// 属于 viewer 角色存在的那个只读监控表面,因此
// viewer 读它就像 owner 一样(载荷是日志记录,不是
// 凭据)。
func TestAdminObservabilityLogs_ViewerReadsTail(t *testing.T) {
t.Parallel()
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
srv.SetLogBuffer(logger.NewRingBuffer(4))
tok := signAny(t, jwt, "acct", auth.RoleViewer)
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/observability/logs", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("viewer status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if !strings.Contains(string(body), `"data":[]`) {
t.Fatalf("body = %s, want an empty data list", body)
}
}