package gateway import ( "encoding/json" "fmt" "io" "net/http" "sync" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" ) // 这些测试钉住生产中坏掉的那个契约(2026-10-03 21:00:28): // MetricsCollector.Snapshot 必须交回脱离的计数器 map,因为 // 管理 handler 在收集器锁释放后对快照做 JSON 编码。 // 返回收集器自己的 map 会让并发的 Record 在编码器遍历它时 // 写该 map → 不可恢复的 // "fatal error: concurrent map iteration and map write" → 整个后端死亡, // 随之所有管理页面也死(上报的「全链路流程功能无效」)。 // TestMetricsSnapshotDetachesMaps 是确定性的回归:从快照读出的 // 值不得在之后对收集器计数时改变,且写入 // 快照不得触达收集器。 func TestMetricsSnapshotDetachesMaps(t *testing.T) { t.Parallel() m := newMetricsCollector() m.Record(http.MethodGet, "/api/v1/a", http.StatusOK, 1) snap := m.Snapshot(time.Now()) if got := snap.PerPath["/api/v1/a"]; got != 1 { t.Fatalf("PerPath[/api/v1/a] = %d, want 1", got) } if got := snap.PerMethod[http.MethodGet]; got != 1 { t.Fatalf("PerMethod[GET] = %d, want 1", got) } // 快照之后记录的计数不得出现在较旧的快照中。 m.Record(http.MethodPost, "/api/v1/b", http.StatusTeapot, 1) if _, ok := snap.PerPath["/api/v1/b"]; ok { t.Fatalf("snapshot shares the collector's perPath map: later path leaked into it (%v)", snap.PerPath) } if _, ok := snap.PerMethod[http.MethodPost]; ok { t.Fatalf("snapshot shares the collector's perMethod map: later method leaked into it (%v)", snap.PerMethod) } if _, ok := snap.StatusCounts["418"]; ok { t.Fatalf("snapshot shares the collector's status map: later status leaked into it (%v)", snap.StatusCounts) } assertSnapshotWriteIsolation(t, m, snap) assertCollectorNotMutatedBySnapshot(t, m) } // assertSnapshotWriteIsolation 写入快照的 map 并检查 // 之后收集器未被触碰。 func assertSnapshotWriteIsolation(t *testing.T, m *MetricsCollector, snap AdminMetrics) { t.Helper() snap.PerPath["/api/v1/evil"] = 999 snap.PerMethod["DELETE"] = 999 snap.StatusCounts["500"] = 999 after := m.Snapshot(time.Now()) if _, ok := after.PerPath["/api/v1/evil"]; ok { t.Fatalf("collector's perPath is the snapshot's map: %v", after.PerPath) } if _, ok := after.PerMethod["DELETE"]; ok { t.Fatalf("collector's perMethod is the snapshot's map: %v", after.PerMethod) } if _, ok := after.StatusCounts["500"]; ok { t.Fatalf("collector's status map is the snapshot's map: %v", after.StatusCounts) } if after.TotalRequests != 2 { t.Fatalf("TotalRequests = %d, want 2", after.TotalRequests) } if after.PerPath["/api/v1/b"] != 1 { t.Fatalf("PerPath[/api/v1/b] = %d, want 1 (real count lost)", after.PerPath["/api/v1/b"]) } } // assertCollectorNotMutatedBySnapshot 重新读取收集器在隔离写入之前 // 记录的一个计数器,以证明没有任何计数值被扰动。 func assertCollectorNotMutatedBySnapshot(t *testing.T, m *MetricsCollector) { t.Helper() final := m.Snapshot(time.Now()) if final.PerPath["/api/v1/a"] != 1 { t.Fatalf("PerPath[/api/v1/a] = %d, want 1", final.PerPath["/api/v1/a"]) } if final.PerMethod[http.MethodGet] != 1 { t.Fatalf("PerMethod[GET] = %d, want 1", final.PerMethod[http.MethodGet]) } } // TestMetricsSnapshotEncodedConcurrently 运行让生产崩溃的那个确切 // 交错:一个 goroutine 计请求(logging 中间件对每个请求都 // 这么做),另一个编码快照(管理 handler 在锁外 // 这么做)。在 -race 下共享活 map 会被报告为数据竞争;在 // 未打补丁的构建上它还可能直接把进程搞崩。 func TestMetricsSnapshotEncodedConcurrently(t *testing.T) { t.Parallel() m := newMetricsCollector() stop := make(chan struct{}) var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() for { select { case <-stop: return default: } m.Record(http.MethodGet, "/api/v1/x", http.StatusOK, 1) } }() deadline := time.Now().Add(300 * time.Millisecond) for time.Now().Before(deadline) { if _, err := json.Marshal(m.Snapshot(time.Now())); err != nil { close(stop) wg.Wait() t.Fatalf("marshal snapshot: %v", err) } } close(stop) wg.Wait() } // TestAdminMetricsEndpointConcurrentPoll 驱动真实 HTTP 路径 —— 管理 // 面板并行轮询每个模块,因此一个请求可能在编码指标 // JSON,而另一个请求的 logging 中间件正在往收集器里记录。这 // 是生产崩溃经中间件 + handler 端到端复现。 func TestAdminMetricsEndpointConcurrentPoll(t *testing.T) { env := newEnv(t, nil, false) tok := env.token(t, auth.RoleOwner) metricsURL := env.ts.URL + "/api/v1/admin/observability/metrics" healthURL := env.ts.URL + "/api/v1/health" // 两个任务并发运行:面板的指标读与持续往收集器 // 记录的负载请求相互竞争。 for _, err := range runConcurrent( func() error { return driveRequests(healthURL, "", 4, 25) }, func() error { return driveRequests(metricsURL, tok, 6, 25) }, ) { if err != nil { t.Fatalf("concurrent metrics poll: %v", err) } } resp, body := doReq(t, http.MethodGet, metricsURL, nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } var payload struct { Data struct { TotalRequests int64 `json:"totalRequests"` PerPath map[string]int64 `json:"perPath"` } `json:"data"` } if err := json.Unmarshal(body, &payload); err != nil { t.Fatalf("decode metrics: %v (body=%s)", err, body) } if payload.Data.TotalRequests == 0 { t.Fatal("totalRequests = 0, want the load above to be counted") } if payload.Data.PerPath["/api/v1/health"] == 0 { t.Fatalf("perPath[/api/v1/health] = 0, want the health load counted (%v)", payload.Data.PerPath) } } // runConcurrent 在各自 goroutine 中运行每个任务并返回非 nil 错误。 func runConcurrent(jobs ...func() error) []error { var wg sync.WaitGroup errs := make([]error, len(jobs)) for i, job := range jobs { wg.Add(1) go func(i int, job func() error) { defer wg.Done() errs[i] = job() }(i, job) } wg.Wait() return errs } // driveRequests 对 url 发起 n 次 GET,其中 workers 个并行,token 非空 // 时作为 bearer 凭据。它返回首个失败(状态或 I/O)。 func driveRequests(url, token string, workers, n int) error { var wg sync.WaitGroup fail := make(chan error, workers) for w := 0; w < workers; w++ { wg.Add(1) go func() { defer wg.Done() for i := 0; i < n; i++ { req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { select { case fail <- err: default: } return } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := http.DefaultClient.Do(req) if err != nil { select { case fail <- err: default: } return } body, err := io.ReadAll(resp.Body) _ = resp.Body.Close() if err != nil { select { case fail <- err: default: } return } if resp.StatusCode != http.StatusOK { select { case fail <- fmt.Errorf("GET %s status = %d (body=%s)", url, resp.StatusCode, body): default: } return } } }() } wg.Wait() close(fail) for err := range fail { return err } return nil }