单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。 Co-authored-by: Cursor <cursoragent@cursor.com>
542 lines
20 KiB
Go
542 lines
20 KiB
Go
// v1_test.go:/v1/search /v1/read 全错误路径 + 成功路径 + 缓存 + 信封 golden。
|
||
package gateway
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"onesvm.com/onesvm/browser-server/internal/contract"
|
||
"onesvm.com/onesvm/browser-server/internal/store"
|
||
)
|
||
|
||
// searchOKEnvelope scheduler 返回的成功 search 信封(golden 形状对齐 mcp-usage §2.1)。
|
||
func searchOKEnvelope(reqID string) map[string]any {
|
||
return map[string]any{
|
||
"ok": true, "kind": "search", "request_id": reqID, "took_ms": 12,
|
||
"usage": map[string]any{"credits": 1, "engine": "searxng-cn", "tokens_estimate": 350},
|
||
"provenance": map[string]any{"retrieved_at": "2026-09-01T11:41:15+08:00",
|
||
"adapter": "searxng-cn", "proxy_exit": "none", "cached": false},
|
||
"error": nil,
|
||
"query": "测试",
|
||
"answer": nil,
|
||
"results": []any{},
|
||
}
|
||
}
|
||
|
||
// TestV1SearchSuccess 200 + 信封透传 + X-Session-Remaining 头。
|
||
func TestV1SearchSuccess(t *testing.T) {
|
||
e := newTestEnv(t)
|
||
e.sched.autoComplete = true
|
||
e.sched.doneEnv = func(rid string) json.RawMessage {
|
||
b, _ := json.Marshal(searchOKEnvelope(rid))
|
||
return b
|
||
}
|
||
rec := e.postV1(t, "/v1/search", `{"query":"测试","region":"domestic"}`)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("want 200 got %d: %s", rec.Code, rec.Body.String())
|
||
}
|
||
m := envelopeOf(t, rec.Body.Bytes())
|
||
if m["ok"] != true || m["kind"] != "search" {
|
||
t.Fatalf("信封 ok/kind 不符: %v", m)
|
||
}
|
||
if rec.Header().Get("X-Session-Remaining") == "" {
|
||
t.Fatal("缺 X-Session-Remaining 头")
|
||
}
|
||
// scheduler 收到的 JobEnvelope 形状校验(内部契约断言)
|
||
e.sched.mu.Lock()
|
||
jobs := len(e.sched.reqLog)
|
||
var ext contract.JobEnvelopeExt
|
||
if jobs > 0 {
|
||
ext = e.sched.reqLog[0]
|
||
}
|
||
e.sched.mu.Unlock()
|
||
if jobs != 1 {
|
||
t.Fatalf("enqueue 次数 %d", jobs)
|
||
}
|
||
if ext.Intent != "search" || ext.Search == nil || ext.Search.Query != "测试" {
|
||
t.Fatalf("JobEnvelope 形状不符: %+v", ext)
|
||
}
|
||
if ext.TimeoutS != SearchTimeoutS {
|
||
t.Fatalf("timeout_s=%d want %d", ext.TimeoutS, SearchTimeoutS)
|
||
}
|
||
if ext.KeyID == "" || ext.RequestID == "" || ext.ID == "" {
|
||
t.Fatalf("JobEnvelope 标识字段缺失: %+v", ext)
|
||
}
|
||
if ext.SubmittedAt.IsZero() {
|
||
t.Fatal("JobEnvelope.submitted_at 缺失")
|
||
}
|
||
}
|
||
|
||
// TestV1SearchValidation 入参校验:region 必填、max_results≤20、formats 白名单、URL 边界。
|
||
func TestV1SearchValidation(t *testing.T) {
|
||
e := newTestEnv(t)
|
||
cases := []struct {
|
||
name, body, frag string
|
||
}{
|
||
{"query 缺失", `{"region":"domestic"}`, "query 必填"},
|
||
{"region 非法", `{"query":"x","region":"mars"}`, "region"},
|
||
{"max_results 超限", `{"query":"x","region":"domestic","max_results":99}`, "max_results"},
|
||
}
|
||
for _, c := range cases {
|
||
rec := e.postV1(t, "/v1/search", c.body)
|
||
if rec.Code != http.StatusBadRequest {
|
||
t.Errorf("%s: want 400 got %d (%s)", c.name, rec.Code, rec.Body.String())
|
||
continue
|
||
}
|
||
if !strings.Contains(rec.Body.String(), c.frag) {
|
||
t.Errorf("%s: 响应缺 %q: %s", c.name, c.frag, rec.Body.String())
|
||
}
|
||
}
|
||
// read formats 白名单
|
||
rec := e.postV1(t, "/v1/read", `{"url":"https://example.com","formats":["xml"]}`)
|
||
if rec.Code != http.StatusBadRequest {
|
||
t.Errorf("formats 非法项应 400: %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
// read URL 超长
|
||
long := "https://example.com/" + strings.Repeat("a", 2100)
|
||
rec = e.postV1(t, "/v1/read", `{"url":"`+long+`"}`)
|
||
if rec.Code != http.StatusBadRequest {
|
||
t.Errorf("url 超长应 400: %d", rec.Code)
|
||
}
|
||
// read max_chars 超限
|
||
rec = e.postV1(t, "/v1/read", `{"url":"https://example.com","max_chars":999999}`)
|
||
if rec.Code != http.StatusBadRequest {
|
||
t.Errorf("max_chars 超限应 400: %d", rec.Code)
|
||
}
|
||
}
|
||
|
||
// TestV1401 认证错误:缺头/Bearer 提示/无效 key/吊销即时生效。
|
||
func TestV1401(t *testing.T) {
|
||
e := newTestEnv(t)
|
||
h := e.srv.Handler()
|
||
// 缺头
|
||
req := httptest.NewRequest(http.MethodPost, "/v1/search", strReader(`{"query":"x","region":"domestic"}`))
|
||
rec := httptest.NewRecorder()
|
||
h.ServeHTTP(rec, req)
|
||
if rec.Code != http.StatusUnauthorized {
|
||
t.Fatalf("缺头应 401: %d", rec.Code)
|
||
}
|
||
if !strings.Contains(rec.Body.String(), `"code":"unauthorized"`) {
|
||
t.Fatalf("401 body 缺 unauthorized code: %s", rec.Body.String())
|
||
}
|
||
// Bearer 提示
|
||
req2 := httptest.NewRequest(http.MethodPost, "/v1/search", strReader(`{}`))
|
||
req2.Header.Set("Authorization", "Bearer bs_xxx")
|
||
rec2 := httptest.NewRecorder()
|
||
h.ServeHTTP(rec2, req2)
|
||
if rec2.Header().Get("WWW-Authenticate") == "" || !strings.Contains(rec2.Header().Get("WWW-Authenticate"), "X-Service-Token") {
|
||
t.Fatalf("Bearer 时应提示 X-Service-Token: %q", rec2.Header().Get("WWW-Authenticate"))
|
||
}
|
||
// 无效 key
|
||
req3 := httptest.NewRequest(http.MethodPost, "/v1/search", strReader(`{}`))
|
||
req3.Header.Set("X-Service-Token", "bs_totallyinvalidkey123")
|
||
rec3 := httptest.NewRecorder()
|
||
h.ServeHTTP(rec3, req3)
|
||
if rec3.Code != http.StatusUnauthorized {
|
||
t.Fatalf("无效 key 应 401: %d", rec3.Code)
|
||
}
|
||
// 吊销即时
|
||
if err := e.db.SetKeyStatus(e.keyID, "disabled"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
rec4 := e.postV1(t, "/v1/search", `{"query":"x","region":"domestic"}`)
|
||
if rec4.Code != http.StatusUnauthorized {
|
||
t.Fatalf("吊销后应 401: %d %s", rec4.Code, rec4.Body.String())
|
||
}
|
||
}
|
||
|
||
// TestV1402Quota 配额耗尽 402(不重试语义)。
|
||
func TestV1402Quota(t *testing.T) {
|
||
e := newTestEnv(t)
|
||
cid, _ := e.db.CreateConsumer("quota-c", "")
|
||
key, _, _ := e.verifier.Issue(cid, "quota-key", nil, 60, 1, 1000, 2, nil)
|
||
e.sched.autoComplete = true
|
||
e.sched.doneEnv = func(rid string) json.RawMessage {
|
||
b, _ := json.Marshal(searchOKEnvelope(rid))
|
||
return b
|
||
}
|
||
// 第一发成功(预扣唯一额度)
|
||
rec := e.postV1As(t, key, "/v1/search", `{"query":"x","region":"domestic"}`)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("首发的应 200: %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
// 第二发 402
|
||
rec2 := e.postV1As(t, key, "/v1/search", `{"query":"y","region":"domestic"}`)
|
||
if rec2.Code != http.StatusPaymentRequired {
|
||
t.Fatalf("配额尽应 402: %d %s", rec2.Code, rec2.Body.String())
|
||
}
|
||
if !strings.Contains(rec2.Body.String(), `"code":"quota"`) {
|
||
t.Fatalf("402 body 缺 quota code: %s", rec2.Body.String())
|
||
}
|
||
}
|
||
|
||
// TestQuotaSettleOnSuccess ITER-3 FIX-1:终态成功 → Settle(reserved→used+1)。
|
||
func TestQuotaSettleOnSuccess(t *testing.T) {
|
||
e := newTestEnv(t)
|
||
e.sched.autoComplete = true
|
||
e.sched.doneEnv = func(rid string) json.RawMessage {
|
||
b, _ := json.Marshal(searchOKEnvelope(rid))
|
||
return b
|
||
}
|
||
rec := e.postV1(t, "/v1/search", `{"query":"测试","region":"domestic"}`)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("应 200: %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
used, err := e.db.QuotaUsed(e.keyID, time.Now())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if used != 1 {
|
||
t.Fatalf("成功终态应 Settle used=1,实得 %d(配额结算半接线复发)", used)
|
||
}
|
||
}
|
||
|
||
// TestQuotaReleaseOnFailEnvelope ITER-3 FIX-1:终态失败信封 → Release(reserved 回落,可重发)。
|
||
func TestQuotaReleaseOnFailEnvelope(t *testing.T) {
|
||
e := newTestEnv(t)
|
||
e.sched.autoComplete = true
|
||
e.sched.doneEnv = func(rid string) json.RawMessage {
|
||
b, _ := json.Marshal(map[string]any{
|
||
"ok": false, "kind": "search", "request_id": rid, "took_ms": 1,
|
||
"usage": map[string]any{"credits": 0, "engine": "", "tokens_estimate": 0},
|
||
"provenance": map[string]any{"retrieved_at": nil, "adapter": "", "proxy_exit": "", "cached": false},
|
||
"error": map[string]any{"code": "upstream", "message": "引擎故障"},
|
||
})
|
||
return b
|
||
}
|
||
rec := e.postV1(t, "/v1/search", `{"query":"测试","region":"domestic"}`)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("失败信封应 200 透传: %d", rec.Code)
|
||
}
|
||
if !strings.Contains(rec.Body.String(), `"code":"upstream"`) {
|
||
t.Fatalf("应含 upstream: %s", rec.Body.String())
|
||
}
|
||
used, err := e.db.QuotaUsed(e.keyID, time.Now())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if used != 0 {
|
||
t.Fatalf("失败信封不应计 used,实得 %d", used)
|
||
}
|
||
// Release 后额度应可再预扣(reserved 回落):再发一单不再 402。
|
||
e2 := e.postV1(t, "/v1/search", `{"query":"二次","region":"domestic"}`)
|
||
if e2.Code == http.StatusPaymentRequired {
|
||
t.Fatal("失败回收后不应 402(reserved 未回落)")
|
||
}
|
||
}
|
||
|
||
// TestQuotaReleaseOnWaitTimeout ITER-3 FIX-1:120s 超时路径 → release + 审计 quota_unsettled_timeout
|
||
// (超时不计费口径)。模拟方式:scheduler /result 一直 202(job 永不终态),用极短
|
||
// WaitBudget 不可行(常量锁定),改用 ctx 取消:请求 context 提前取消 → waitResult 返
|
||
// ctx.Err → waitFail 半途不可达分支 release。timeout 分支语义同源(release+审计),
|
||
// ctx 分支已覆盖 release 断言;timeout 分支以审计 rule_id 断言补充。
|
||
func TestQuotaReleaseOnWaitTimeout(t *testing.T) {
|
||
e := newTestEnv(t)
|
||
// 不 autoComplete:/result 恒 202(queued)。
|
||
rec := e.postV1(t, "/v1/search", `{"query":"测试","region":"domestic"}`)
|
||
_ = rec // 等 120s 不现实;直接驱动 waitFail 语义:手工调用(见下)
|
||
// 直接调 waitFail 验证超时分支行为(release + 审计)。
|
||
rc := runCtx{Intent: IntentSearch, W: httptest.NewRecorder(), R: httptest.NewRequest(http.MethodPost, "/v1/search", nil)}
|
||
rc.Auth = &consumerAuth{Key: &store.ApiKey{ID: e.keyID, ConsumerID: 1}}
|
||
e.srv.waitFail(rc, errWaitTimeout)
|
||
used, err := e.db.QuotaUsed(e.keyID, time.Now())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if used != 0 {
|
||
t.Fatalf("超时不计费:used 应 0,实得 %d", used)
|
||
}
|
||
n, err := e.db.AuditCountByRule("quota_unsettled_timeout")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if n < 1 {
|
||
t.Fatalf("超时应记审计 quota_unsettled_timeout,实得 %d 条", n)
|
||
}
|
||
}
|
||
|
||
// TestV1403Denied 合规拦截 403 + 审计落库。
|
||
func TestV1403Denied(t *testing.T) {
|
||
e := newTestEnv(t)
|
||
// SSRF:私网 URL
|
||
rec := e.postV1(t, "/v1/read", `{"url":"http://127.0.0.1:80/x"}`)
|
||
if rec.Code != http.StatusForbidden {
|
||
t.Fatalf("私网应 403: %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
if !strings.Contains(rec.Body.String(), `"code":"denied"`) {
|
||
t.Fatalf("403 body 缺 denied: %s", rec.Body.String())
|
||
}
|
||
// 域名 deny 规则(写 rules 表 + 热载)
|
||
if err := e.db.RuleUpsert("suffix", ".evil.example", "deny", 10); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := e.srv.deps.Policy.Reload(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
rec2 := e.postV1(t, "/v1/read", `{"url":"https://sub.evil.example/page"}`)
|
||
if rec2.Code != http.StatusForbidden || !strings.Contains(rec2.Body.String(), "domain_deny") {
|
||
t.Fatalf("域名 deny 应 403: %d %s", rec2.Code, rec2.Body.String())
|
||
}
|
||
// 审计行数 ≥2
|
||
rows, err := e.db.AuditRecent(10)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(rows) < 2 {
|
||
t.Fatalf("审计行数 %d < 2", len(rows))
|
||
}
|
||
for _, r := range rows {
|
||
if r.RuleID == "" {
|
||
t.Fatal("审计行缺 rule_id")
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestV1429RateLimit 超 rpm 429 + X-RateLimit 头 + Retry-After。
|
||
func TestV1429RateLimit(t *testing.T) {
|
||
e := newTestEnv(t)
|
||
cid, _ := e.db.CreateConsumer("rpm-c", "")
|
||
key, _, _ := e.verifier.Issue(cid, "rpm-key", nil, 2, 100, 1000, 2, nil)
|
||
e.sched.autoComplete = true
|
||
e.sched.doneEnv = func(rid string) json.RawMessage {
|
||
b, _ := json.Marshal(searchOKEnvelope(rid))
|
||
return b
|
||
}
|
||
// rpm=2:第 3 发应 429(query 各不同,避开搜索缓存命中干扰;用 rpm-key)
|
||
for i := 0; i < 2; i++ {
|
||
rec := e.postV1As(t, key, "/v1/search", `{"query":"rpm测试`+itoa(i)+`","region":"domestic"}`)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("第 %d 发应 200: %d %s", i+1, rec.Code, rec.Body.String())
|
||
}
|
||
}
|
||
rec := e.postV1As(t, key, "/v1/search", `{"query":"rpm测试第三发","region":"domestic"}`)
|
||
if rec.Code != http.StatusTooManyRequests {
|
||
t.Fatalf("第 3 发应 429: %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
if !strings.Contains(rec.Body.String(), `"code":"rate_limited"`) {
|
||
t.Fatalf("429 body 缺 rate_limited: %s", rec.Body.String())
|
||
}
|
||
if rec.Header().Get("X-RateLimit-Limit") != "2" {
|
||
t.Fatalf("缺 X-RateLimit-Limit: %q", rec.Header().Get("X-RateLimit-Limit"))
|
||
}
|
||
if rec.Header().Get("Retry-After") == "" {
|
||
t.Fatal("429 缺 Retry-After")
|
||
}
|
||
}
|
||
|
||
// TestV1503SchedulerDown scheduler 不可达 503 + Retry-After + 零落盘。
|
||
func TestV1503SchedulerDown(t *testing.T) {
|
||
e := newTestEnv(t)
|
||
e.sched.down.Store(true)
|
||
rec := e.postV1(t, "/v1/search", `{"query":"x","region":"domestic"}`)
|
||
if rec.Code != http.StatusServiceUnavailable {
|
||
t.Fatalf("不可达应 503: %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
if rec.Header().Get("Retry-After") == "" {
|
||
t.Fatal("503 缺 Retry-After")
|
||
}
|
||
if !strings.Contains(rec.Body.String(), `"code":"unavailable"`) {
|
||
t.Fatalf("503 body 缺 unavailable: %s", rec.Body.String())
|
||
}
|
||
// 零落盘:jobs 表为空(gateway 不写队列)
|
||
var n int
|
||
if err := e.db.Raw().QueryRow(`SELECT COUNT(id) FROM jobs`).Scan(&n); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if n != 0 {
|
||
t.Fatalf("gateway 本地落盘 %d 行任务(违反 fail-closed 零落盘)", n)
|
||
}
|
||
// readyz 依赖 /pressure
|
||
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
|
||
rec2 := httptest.NewRecorder()
|
||
e.srv.Handler().ServeHTTP(rec2, req)
|
||
if rec2.Code != http.StatusInternalServerError {
|
||
t.Fatalf("down 时 readyz 应 500: %d", rec2.Code)
|
||
}
|
||
}
|
||
|
||
// TestV1503QueueFull scheduler 队列满 503。
|
||
func TestV1503QueueFull(t *testing.T) {
|
||
e := newTestEnv(t)
|
||
e.sched.fullMode.Store(true)
|
||
rec := e.postV1(t, "/v1/search", `{"query":"x","region":"domestic"}`)
|
||
if rec.Code != http.StatusServiceUnavailable {
|
||
t.Fatalf("队列满应 503: %d", rec.Code)
|
||
}
|
||
}
|
||
|
||
// TestSearchCacheHit 缓存命中:第二次不出网(enqueue 计数不增)、cached=true、credits=0。
|
||
func TestSearchCacheHit(t *testing.T) {
|
||
e := newTestEnv(t)
|
||
e.sched.autoComplete = true
|
||
e.sched.doneEnv = func(rid string) json.RawMessage {
|
||
if _, ok := jobsIntent(e.sched, rid, "read"); ok {
|
||
b, _ := json.Marshal(readOKEnvelope(rid))
|
||
return b
|
||
}
|
||
b, _ := json.Marshal(searchOKEnvelope(rid))
|
||
return b
|
||
}
|
||
rec := e.postV1(t, "/v1/search", `{"query":"缓存测试","region":"domestic"}`)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("首发应 200: %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
first := e.sched.enqueues.Load()
|
||
|
||
rec2 := e.postV1(t, "/v1/search", `{"query":"缓存测试","region":"domestic"}`)
|
||
if rec2.Code != http.StatusOK {
|
||
t.Fatalf("二发应 200: %d %s", rec2.Code, rec2.Body.String())
|
||
}
|
||
if e.sched.enqueues.Load() != first {
|
||
t.Fatal("缓存命中不应再 enqueue")
|
||
}
|
||
var env contract.Envelope
|
||
if err := json.Unmarshal(rec2.Body.Bytes(), &env); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !env.Provenance.Cached {
|
||
t.Fatal("缓存命中 provenance.cached 应为 true")
|
||
}
|
||
if env.Usage.Credits != 0 {
|
||
t.Fatalf("缓存命中 credits 应 0: %d", env.Usage.Credits)
|
||
}
|
||
// read 不缓存:同参数两次都出网
|
||
for i := 0; i < 2; i++ {
|
||
recR := e.postV1(t, "/v1/read", `{"url":"https://example.com/page"}`)
|
||
if recR.Code != http.StatusOK {
|
||
t.Fatalf("read %d 应 200: %d %s", i, recR.Code, recR.Body.String())
|
||
}
|
||
}
|
||
if e.sched.enqueues.Load() != first+2 {
|
||
t.Fatalf("read 应两次都入队: %d vs %d", e.sched.enqueues.Load(), first+2)
|
||
}
|
||
}
|
||
|
||
// jobsIntent 在 mock 的 reqLog 里按 request_id 找意图。
|
||
func jobsIntent(m *mockScheduler, rid, intent string) (bool, bool) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
for _, j := range m.reqLog {
|
||
if j.RequestID == rid {
|
||
return j.Intent == intent, true
|
||
}
|
||
}
|
||
return false, false
|
||
}
|
||
|
||
// readOKEnvelope 成功 read 信封。
|
||
func readOKEnvelope(reqID string) map[string]any {
|
||
return map[string]any{
|
||
"ok": true, "kind": "read", "request_id": reqID, "took_ms": 5,
|
||
"url": "https://example.com/page", "final_url": "https://example.com/page",
|
||
"title": "示例", "description": nil, "markdown": "# 正文", "truncated": false,
|
||
"char_count": 8,
|
||
"metadata": map[string]any{"status_code": 200, "content_type": "text/html",
|
||
"language": "zh", "retrieved_at": "2026-09-01T11:42:45+08:00"},
|
||
"links": []any{}, "images": []any{}, "html": nil, "screenshot_url": nil,
|
||
"extracted": nil, "warnings": []any{},
|
||
"usage": map[string]any{"credits": 1, "engine": "trafilatura", "tokens_estimate": 10},
|
||
"provenance": map[string]any{"retrieved_at": "2026-09-01T11:42:45+08:00", "adapter": "trafilatura-http", "proxy_exit": "none", "cached": false},
|
||
"error": nil,
|
||
}
|
||
}
|
||
|
||
// TestV1ReadWithPresetResult 预置终态后请求(同步等待路径)。
|
||
func TestV1ReadWithPresetResult(t *testing.T) {
|
||
e := newTestEnv(t)
|
||
// 预置:enqueue handler 收到请求时立即落终态
|
||
e.sched.autoComplete = true
|
||
e.sched.doneEnv = func(rid string) json.RawMessage {
|
||
b, _ := json.Marshal(readOKEnvelope(rid))
|
||
return b
|
||
}
|
||
rec := e.postV1(t, "/v1/read", `{"url":"https://example.com/page"}`)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("want 200: %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
m := envelopeOf(t, rec.Body.Bytes())
|
||
if m["kind"] != "read" || m["ok"] != true {
|
||
t.Fatalf("read 信封不符: %v", m)
|
||
}
|
||
for _, k := range []string{"url", "final_url", "title", "markdown", "truncated",
|
||
"char_count", "metadata", "links", "images", "html", "screenshot_url", "extracted", "warnings", "usage", "provenance", "error"} {
|
||
if _, ok := m[k]; !ok {
|
||
t.Fatalf("read 信封缺字段 %s(mcp-usage §2.2 golden)", k)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestV1EnvelopeSearchGolden search 200 响应与 mcp-usage §2.1 字段逐一对齐(gateway 侧 golden)。
|
||
func TestV1EnvelopeSearchGolden(t *testing.T) {
|
||
e := newTestEnv(t)
|
||
e.sched.autoComplete = true
|
||
e.sched.doneEnv = func(rid string) json.RawMessage {
|
||
b, _ := json.Marshal(searchOKEnvelope(rid))
|
||
return b
|
||
}
|
||
rec := e.postV1(t, "/v1/search", `{"query":"golden","region":"overseas"}`)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("want 200: %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
m := envelopeOf(t, rec.Body.Bytes())
|
||
for _, k := range []string{"ok", "kind", "request_id", "took_ms", "query", "answer", "results", "usage", "provenance", "error"} {
|
||
if _, ok := m[k]; !ok {
|
||
t.Fatalf("search 信封缺字段 %s", k)
|
||
}
|
||
}
|
||
if m["answer"] != nil {
|
||
t.Fatal("answer 恒 null")
|
||
}
|
||
if rs, ok := m["results"].([]any); !ok || len(rs) != 0 {
|
||
t.Fatalf("results 应为 []: %v", m["results"])
|
||
}
|
||
// provenance.cached=false 且 usage.credits=1
|
||
pv := m["provenance"].(map[string]any)
|
||
if pv["cached"] != false {
|
||
t.Fatal("首发 cached 应 false")
|
||
}
|
||
}
|
||
|
||
// TestReadPrivilegeScopes 特权 formats/extract 需对应 scope(403)。
|
||
func TestReadPrivilegeScopes(t *testing.T) {
|
||
e := newTestEnv(t)
|
||
rec := e.postV1(t, "/v1/read", `{"url":"https://example.com","formats":["html"]}`)
|
||
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "rawHtml") {
|
||
t.Fatalf("html 无 rawHtml scope 应 403: %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
rec2 := e.postV1(t, "/v1/read", `{"url":"https://example.com","formats":["screenshot"]}`)
|
||
if rec2.Code != http.StatusForbidden || !strings.Contains(rec2.Body.String(), "screenshot") {
|
||
t.Fatalf("screenshot 无 scope 应 403: %d %s", rec2.Code, rec2.Body.String())
|
||
}
|
||
rec3 := e.postV1(t, "/v1/read", `{"url":"https://example.com","extract":{"schema":{"type":"object"}}}`)
|
||
if rec3.Code != http.StatusForbidden || !strings.Contains(rec3.Body.String(), "extract") {
|
||
t.Fatalf("extract 无 scope 应 403: %d %s", rec3.Code, rec3.Body.String())
|
||
}
|
||
// 特权 key 放行
|
||
cid, _ := e.db.CreateConsumer("priv-c", "")
|
||
key, _, _ := e.verifier.Issue(cid, "priv-key", []string{"search", "read", "extract", "screenshot", "rawHtml"}, 60, 100, 1000, 2, nil)
|
||
e.sched.autoComplete = true
|
||
e.sched.doneEnv = func(rid string) json.RawMessage {
|
||
b, _ := json.Marshal(readOKEnvelope(rid))
|
||
return b
|
||
}
|
||
rec4 := e.postV1As(t, key, "/v1/read", `{"url":"https://example.com","formats":["html"]}`)
|
||
if rec4.Code != http.StatusOK {
|
||
t.Fatalf("特权 key 应 200: %d %s", rec4.Code, rec4.Body.String())
|
||
}
|
||
}
|
||
|
||
// postV1As 指定明文 key 发请求。
|
||
func (e *testEnv) postV1As(t *testing.T, key, path, body string) *httptest.ResponseRecorder {
|
||
t.Helper()
|
||
req := httptest.NewRequest(http.MethodPost, path, strReader(body))
|
||
req.Header.Set("X-Service-Token", key)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
rec := httptest.NewRecorder()
|
||
e.srv.Handler().ServeHTTP(rec, req)
|
||
return rec
|
||
}
|