onesvm-browser-server/server/internal/gateway/gateway_test.go
chii eb972dfa93 feat: 落地 browser-server 控制面并打通 mgr1 海外订阅
单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:05:12 +08:00

270 lines
7.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 引用(部分测试用)