onesvm-browser-server/server/internal/gateway/v1_test.go
chii 347c94c121 fix: 搜索缓存往返保留 results,并加检索源规则过滤
缓存命中改写信封时补 UnmarshalJSON,避免 results 被抹空;零结果不进 300s 缓存。模版层对 SearXNG hits 做广告标记丢弃与同域限额 1 条,减轻 1688 霸屏。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 11:33:13 +08:00

590 lines
21 KiB
Go
Raw Permalink 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.

// 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
}