单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。 Co-authored-by: Cursor <cursoragent@cursor.com>
270 lines
7.9 KiB
Go
270 lines
7.9 KiB
Go
// 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 引用(部分测试用)
|