// 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{}, } } func searchHitEnvelope(reqID string) map[string]any { env := searchOKEnvelope(reqID) env["results"] = []any{ map[string]any{"id": "r1", "title": "示例结果", "url": "https://example.com", "content": "摘要", "score": 0.9, "engine": "bing"}, } return env } // 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 match, found := jobsIntent(e.sched, rid, "read"); found && match { b, _ := json.Marshal(readOKEnvelope(rid)) return b } b, _ := json.Marshal(searchHitEnvelope(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) } var hitWire struct { Results []contract.SearchResult `json:"results"` } if err := json.Unmarshal(rec2.Body.Bytes(), &hitWire); err != nil { t.Fatal(err) } if len(hitWire.Results) == 0 { t.Fatalf("缓存命中不得丢掉 results: %s", rec2.Body.String()) } // 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) } } // TestSearchEmptyNotCached 零结果信封不进 300s 缓存:引擎抖空后下次必须再出网。 func TestSearchEmptyNotCached(t *testing.T) { e := newTestEnv(t) e.sched.autoComplete = true e.sched.doneEnv = func(rid string) json.RawMessage { empty := searchOKEnvelope(rid) empty["results"] = []any{} b, _ := json.Marshal(empty) 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+1 { t.Fatalf("零结果不应缓存: enqueue %d want %d", e.sched.enqueues.Load(), first+1) } var env contract.Envelope if err := json.Unmarshal(rec2.Body.Bytes(), &env); err != nil { t.Fatal(err) } if env.Provenance.Cached { t.Fatal("零结果二次请求不得标 cached") } } // 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 }