// gateway_test.go:测试脚手架——SQLite + 真实 auth.Verifier + httptest 模拟 scheduler。 package gateway import ( "encoding/json" "io" "log" "net/http" "net/http/httptest" "path/filepath" "sync" "sync/atomic" "testing" "time" "onesvm.com/onesvm/browser-server/internal/auth" "onesvm.com/onesvm/browser-server/internal/contract" "onesvm.com/onesvm/browser-server/internal/policy" "onesvm.com/onesvm/browser-server/internal/store" _ "modernc.org/sqlite" // sqlite driver(gateway 测试库) ) // testEnv 完整 gateway 测试环境。 type testEnv struct { srv *Server ts *httptest.Server db *store.DB verifier *auth.Verifier plainKey string // 测试 key 明文 keyID int64 sched *mockScheduler } // mockScheduler 模拟 scheduler(enqueue/result/pressure 三路径)。 type mockScheduler struct { mu sync.Mutex jobs map[string]*mockJob // request_id → job down atomic.Bool // 模拟不可达 pressure atomic.Bool // 模拟 /pressure 不可达 enqueues atomic.Int64 fullMode atomic.Bool // 模拟队列满 503 reqLog []contract.JobEnvelopeExt // autoComplete:enqueue 即落终态(同步等待路径测试用) autoComplete bool doneEnv func(requestID string) json.RawMessage } type mockJob struct { env contract.JobEnvelopeExt status string envelope json.RawMessage } func newMockScheduler() *mockScheduler { return &mockScheduler{jobs: map[string]*mockJob{}} } // handler 模拟 scheduler HTTP 面(/enqueue /result /pressure)。 func (m *mockScheduler) handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("POST /enqueue", func(w http.ResponseWriter, r *http.Request) { m.enqueues.Add(1) if m.down.Load() { w.WriteHeader(http.StatusServiceUnavailable) _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]string{"code": "unavailable"}}) return } if m.fullMode.Load() { w.Header().Set("Retry-After", "2") w.WriteHeader(http.StatusServiceUnavailable) _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]string{"code": "unavailable", "message": "队列满"}}) return } var ext contract.JobEnvelopeExt _ = json.NewDecoder(r.Body).Decode(&ext) // 先读 autoComplete/doneEnv(不加锁),再进锁——doneEnv 可能回查 reqLog, // 持锁回调会自死锁(TestSearchCacheHit 曾触发)。 m.mu.Lock() auto, done := m.autoComplete, m.doneEnv m.reqLog = append(m.reqLog, ext) m.mu.Unlock() jb := &mockJob{env: ext, status: contract.JobQueued} if auto && done != nil { jb.status = contract.JobDone jb.envelope = done(ext.RequestID) } m.mu.Lock() m.jobs[ext.RequestID] = jb m.mu.Unlock() w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "ok": true, "job_id": 1, "request_id": ext.RequestID, "queued_position": 1, }) }) mux.HandleFunc("GET /result/", func(w http.ResponseWriter, r *http.Request) { if m.down.Load() { w.WriteHeader(http.StatusServiceUnavailable) return } rid := trimSlash(strings_TrimPrefix(r.URL.Path, "/result/")) m.mu.Lock() job := m.jobs[rid] m.mu.Unlock() if job == nil { w.WriteHeader(http.StatusNotFound) return } // ITER-3 FIX-2:回放形状 = contract.ResultShape* golden(双端同源断言)。 // 非终态回放 golden 的 status/position 键集;终态回放 200+envelope 嵌套。 switch job.status { case contract.JobQueued, contract.JobRunning: var golden map[string]any if err := json.Unmarshal([]byte(contract.ResultShapeAccepted), &golden); err != nil { panic("golden 非法: " + err.Error()) } resp := map[string]any{"request_id": rid, "status": job.status} if pos, has := golden["position"]; has { resp["position"] = pos // 键集来源=golden(同源) } if len(job.envelope) > 0 { resp["envelope"] = json.RawMessage(job.envelope) } _ = json.NewEncoder(w).Encode(resp) return } // 终态:先校验 golden 键集(ResultShapeDone 顶层键)与本响应同源。 var golden map[string]any if err := json.Unmarshal([]byte(contract.ResultShapeDone), &golden); err != nil { panic("golden 非法: " + err.Error()) } resp := map[string]any{"request_id": rid, "status": job.status} if _, has := golden["envelope"]; has { resp["envelope"] = json.RawMessage(job.envelope) } for k := range golden { if _, has := resp[k]; !has && k != "envelope" { resp[k] = golden[k] // request_id/status 键集补齐(同源) } } _ = json.NewEncoder(w).Encode(resp) }) mux.HandleFunc("GET /pressure", func(w http.ResponseWriter, _ *http.Request) { if m.down.Load() || m.pressure.Load() { w.WriteHeader(http.StatusServiceUnavailable) return } _, _ = w.Write([]byte(`{"running":0,"queued":0,"is_available":true}`)) }) return mux } // complete 标记任务终态(测试驱动器)。 func (m *mockScheduler) complete(requestID string, env any) { b, _ := json.Marshal(env) m.mu.Lock() if j := m.jobs[requestID]; j != nil { j.status = contract.JobDone j.envelope = b } m.mu.Unlock() } // lastRequestID 最近入队的 request_id。 func (m *mockScheduler) lastRequestID() string { m.mu.Lock() defer m.mu.Unlock() if len(m.reqLog) == 0 { return "" } return m.reqLog[len(m.reqLog)-1].RequestID } // newTestEnv 组装(真实 SQLite + auth + httptest scheduler)。 func newTestEnv(t *testing.T) *testEnv { t.Helper() db, err := store.Open(filepath.Join(t.TempDir(), "gw.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) if err := db.Migrate(); err != nil { t.Fatal(err) } salt := "test-seed-salt-not-a-secret" ver := auth.NewVerifier(db, salt) ms := newMockScheduler() schedTS := httptest.NewServer(ms.handler()) t.Cleanup(schedTS.Close) dep := Deps{ DB: db, Glue: NewDBGlue(db), Verifier: ver, Policy: policy.NewEngine(db, &http.Client{Timeout: 2 * time.Second}), Scheduler: NewSchedulerClient(schedTS.URL), Cache: NewSearchCache(), Limiter: NewRateLimiter(), AdminToken: "test-admin-token", SeedSalt: salt, Logger: log.New(&discard{}, "", 0), } srv := NewServer(dep) ts := httptest.NewServer(srv.Handler()) t.Cleanup(ts.Close) // 签发测试 key(默认 scopes) cid, _ := db.CreateConsumer("test-consumer", "") plaintext, keyID, err := ver.Issue(cid, "test-key", nil, 60, 100, 1000, 2, nil) if err != nil { t.Fatal(err) } return &testEnv{srv: srv, ts: ts, db: db, verifier: ver, plainKey: plaintext, keyID: keyID, sched: ms} } // postV1 发起 /v1 请求。 func (e *testEnv) postV1(t *testing.T, path, body string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, path, strReader(body)) req.Header.Set("X-Service-Token", e.plainKey) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() e.srv.Handler().ServeHTTP(rec, req) return rec } // ---------- 工具 ---------- type discard struct{} func (d *discard) Write(p []byte) (int, error) { return len(p), nil } func strReader(s string) *strRd { return &strRd{s: s} } // strRd 标准 io.Reader 语义:读完整串后才返回 EOF(errEOF 版本在空串前就 // 返回 EOF 且 errEOF 非 io.EOF,导致 json.Decoder 报「请求体读取失败」)。 type strRd struct{ s string } func (r *strRd) Read(p []byte) (int, error) { if len(r.s) == 0 { return 0, ioEOF() } n := copy(p, r.s) r.s = r.s[n:] return n, nil } func ioEOF() error { return io.EOF } func trimSlash(s string) string { for len(s) > 0 && s[0] == '/' { s = s[1:] } return s } func strings_TrimPrefix(s, prefix string) string { if len(s) >= len(prefix) && s[:len(prefix)] == prefix { return s[len(prefix):] } return s } // envelopeOf 解析响应信封。 func envelopeOf(t *testing.T, body []byte) map[string]any { t.Helper() var m map[string]any if err := json.Unmarshal(body, &m); err != nil { t.Fatalf("响应非 JSON: %s", body) } return m } var _ = time.Now // 保留 time 引用(部分测试用)