package gateway // 本文件锁定**入库前审查(D3)管理面**的两条端点(design/23 §15 D3-8 + §8;切片 L45): // ① 留痕列表的投影(camelCase、时间 UTC RFC3339、`mergedIds` 恒为非 null 数组); // ② `limit` 的默认值 / 钳位 / 非法值(400); // ③ 门与审计:owner|admin 200、viewer 403、匿名 401;回滚**成功与失败都落审计**; // ④ 未接线时诚实作答 500 `memory_review_unavailable`(**绝不回空列表**假装「没有留痕」); // ⑤ 回滚的三种结果:成功 200、不在被合并状态 404 `not_found`、存储报错 500。 import ( "context" "encoding/json" "errors" "net/http" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" ) // fakeIngestReviews 是审查留痕存储的替身:既满足 Deps.Memories(MemoryStore), // 又实现管理面要的窄视图(ingestReviewStore)。 type fakeIngestReviews struct { reviews []memory.IngestReview listErr error revertErr error limitGot int reverted []string } func (f *fakeIngestReviews) List(context.Context) ([]memory.MemoryItem, error) { return []memory.MemoryItem{{ID: "m1", Scope: memory.ScopeUser, Content: "一条记忆"}}, nil } func (f *fakeIngestReviews) ListIngestReviews(_ context.Context, limit int) ([]memory.IngestReview, error) { f.limitGot = limit return f.reviews, f.listErr } func (f *fakeIngestReviews) RevertMerged(_ context.Context, id string) error { if f.revertErr != nil { return f.revertErr } f.reverted = append(f.reverted, id) return nil } // listOnlyMemories 只实现 Deps.Memories 的最低要求(没有留痕视图的存储)。 type listOnlyMemories struct{} func (listOnlyMemories) List(context.Context) ([]memory.MemoryItem, error) { return nil, nil } // recordingAudit 捕获审计链上的每一次 Append(谁/做了什么/结果)。 type recordingAudit struct{ entries []AuditRecord } func (a *recordingAudit) Append(_ context.Context, who, what, result string, ts time.Time) error { a.entries = append(a.entries, AuditRecord{Who: who, What: what, When: ts.Format(time.RFC3339), Result: result}) return nil } func (a *recordingAudit) List(context.Context, int) ([]AuditRecord, error) { return a.entries, nil } // fixtureIngestReview 是一条四态留痕(merge:命中了既有条目并把新行软合并进去)。 func fixtureIngestReview() memory.IngestReview { return memory.IngestReview{ ID: "rev-1", ItemID: "item-new", PersonaID: "yuki", Scope: memory.ScopeUser, ProjectID: "proj-1", Decision: memory.IngestMerge, Reason: "subset_of_existing", TargetID: "item-existing", MergedIDs: []string{"item-new"}, Score: 0.91, RecallMode: "vector", Degraded: false, RuleVer: "d3.1", SessionID: "sess-memorytidy", CreatedAt: time.Date(2026, 10, 6, 3, 4, 5, 0, time.UTC), } } func TestAdminMemoryReviewsListProjection(t *testing.T) { t.Parallel() fake := &fakeIngestReviews{reviews: []memory.IngestReview{fixtureIngestReview()}} _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/reviews", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } if fake.limitGot != memoryReviewDefaultLimit { t.Fatalf("默认 limit = %d, want %d", fake.limitGot, memoryReviewDefaultLimit) } var rows []map[string]json.RawMessage if err := json.Unmarshal(decodeBody(t, body)["data"], &rows); err != nil { t.Fatalf("解析 data: %v (body=%s)", err, body) } if len(rows) != 1 { t.Fatalf("留痕条数 = %d, want 1", len(rows)) } want := map[string]string{ "id": `"rev-1"`, "itemId": `"item-new"`, "personaId": `"yuki"`, "scope": `"user"`, "projectId": `"proj-1"`, "decision": `"merge"`, "reason": `"subset_of_existing"`, "targetId": `"item-existing"`, "score": `0.91`, "recallMode": `"vector"`, "degraded": `false`, "ruleVersion": `"d3.1"`, "sessionId": `"sess-memorytidy"`, "createdAt": `"2026-10-06T03:04:05Z"`, } for key, val := range want { raw, ok := rows[0][key] if !ok { t.Fatalf("缺键 %q(row=%v)", key, rows[0]) } if string(raw) != val { t.Fatalf("%s = %s, want %s", key, raw, val) } } if string(rows[0]["mergedIds"]) != `["item-new"]` { t.Fatalf("mergedIds = %s, want [\"item-new\"]", rows[0]["mergedIds"]) } // 无否决规则时不发 veto 键(omitempty)。 if raw, ok := rows[0]["veto"]; ok { t.Fatalf("未命中否决规则时不该有 veto 键:%s", raw) } } func TestAdminMemoryReviewsLimitAndEmpty(t *testing.T) { t.Parallel() fake := &fakeIngestReviews{} _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/reviews?limit=3", nil, tok) if resp.StatusCode != http.StatusOK || fake.limitGot != 3 { t.Fatalf("limit=3 ⇒ status/limit = %d/%d, want 200/3 (body=%s)", resp.StatusCode, fake.limitGot, body) } // 超硬上限 ⇒ 钳位(不报错,与渠道事件查询面同一约定)。 resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/reviews?limit=9999", nil, tok) if resp.StatusCode != http.StatusOK || fake.limitGot != memoryReviewMaxLimit { t.Fatalf("limit=9999 ⇒ status/limit = %d/%d, want 200/%d (body=%s)", resp.StatusCode, fake.limitGot, memoryReviewMaxLimit, body) } // 非正 ⇒ 默认。 resp, _ = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/reviews?limit=0", nil, tok) if resp.StatusCode != http.StatusOK || fake.limitGot != memoryReviewDefaultLimit { t.Fatalf("limit=0 ⇒ status/limit = %d/%d, want 200/%d", resp.StatusCode, fake.limitGot, memoryReviewDefaultLimit) } // 非整数 ⇒ 400 bad_request。 resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/reviews?limit=abc", nil, tok) if resp.StatusCode != http.StatusBadRequest || mediaErrorCode(t, body) != "bad_request" { t.Fatalf("limit=abc ⇒ %d/%s, want 400/bad_request", resp.StatusCode, body) } // 空留痕 ⇒ 200 + 非 null 空数组(「算过了,这一条都没有」与「没接线」必须可区分)。 resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/reviews", nil, tok) if resp.StatusCode != http.StatusOK || string(decodeBody(t, body)["data"]) != "[]" { t.Fatalf("空留痕 ⇒ %d/%s, want 200/[]", resp.StatusCode, body) } } func TestAdminMemoryReviewsRBACAndUnavailable(t *testing.T) { t.Parallel() fake := &fakeIngestReviews{} _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake}) if resp, _ := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/reviews", nil, ""); resp.StatusCode != http.StatusUnauthorized { t.Fatalf("匿名 status = %d, want 401", resp.StatusCode) } viewer := signAny(t, jwt, "acct", auth.RoleViewer) if resp, _ := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/reviews", nil, viewer); resp.StatusCode != http.StatusForbidden { t.Fatalf("viewer status = %d, want 403(留痕属私有记忆面)", resp.StatusCode) } admin := signAny(t, jwt, "acct", auth.RoleAdmin) if resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/reviews", nil, admin); resp.StatusCode != http.StatusOK { t.Fatalf("admin status = %d, want 200 (body=%s)", resp.StatusCode, body) } // 存储没有留痕视图 ⇒ 500 memory_review_unavailable(绝不回空列表)。 _, bare, bareJWT := buildEnv(t, cfgDefault(), Deps{Memories: listOnlyMemories{}}) tok := signAny(t, bareJWT, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodGet, bare.URL+"/api/v1/admin/memories/reviews", nil, tok) if resp.StatusCode != http.StatusInternalServerError || mediaErrorCode(t, body) != memoryReviewUnavailableCode { t.Fatalf("未接线 ⇒ %d/%s, want 500/%s", resp.StatusCode, body, memoryReviewUnavailableCode) } } func TestAdminMemoryRevertOutcomesAndAudit(t *testing.T) { t.Parallel() fake := &fakeIngestReviews{} audit := &recordingAudit{} _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake, AuditStore: audit}) tok := signAny(t, jwt, "acct", auth.RoleOwner) // 成功:200 + 被回滚的 id,并落一条 ok 审计。 resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/item-new/revert", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("revert status = %d, want 200 (body=%s)", resp.StatusCode, body) } if len(fake.reverted) != 1 || fake.reverted[0] != "item-new" { t.Fatalf("回滚调用 = %v, want [item-new]", fake.reverted) } var payload map[string]string if err := json.Unmarshal(decodeBody(t, body)["data"], &payload); err != nil { t.Fatalf("解析 data: %v", err) } if payload["reverted"] != "item-new" { t.Fatalf("data.reverted = %q, want item-new", payload["reverted"]) } if len(audit.entries) != 1 || audit.entries[0].What != memoryReviewRevertAction || audit.entries[0].Result != "ok" { t.Fatalf("成功后的审计 = %+v, want 一条 %s/ok", audit.entries, memoryReviewRevertAction) } // 该行不在被合并状态 ⇒ 404 not_found,且**失败也落审计**。 fake.revertErr = memory.ErrNotFound resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/plain-item/revert", nil, tok) if resp.StatusCode != http.StatusNotFound || mediaErrorCode(t, body) != "not_found" { t.Fatalf("非合并行 ⇒ %d/%s, want 404/not_found", resp.StatusCode, body) } if len(audit.entries) != 2 || audit.entries[1].Result != "error" { t.Fatalf("失败后的审计 = %+v, want 一条 error", audit.entries) } // 存储报错 ⇒ 500 revert_failed(不谎报成功)。 fake.revertErr = errors.New("db exploded") resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/item-new/revert", nil, tok) if resp.StatusCode != http.StatusInternalServerError || mediaErrorCode(t, body) != "revert_failed" { t.Fatalf("存储报错 ⇒ %d/%s, want 500/revert_failed", resp.StatusCode, body) } // 未接线 ⇒ 500 memory_review_unavailable。 _, bare, bareJWT := buildEnv(t, cfgDefault(), Deps{Memories: listOnlyMemories{}}) btok := signAny(t, bareJWT, "acct", auth.RoleOwner) resp, body = doReq(t, http.MethodPost, bare.URL+"/api/v1/admin/memories/item-new/revert", nil, btok) if resp.StatusCode != http.StatusInternalServerError || mediaErrorCode(t, body) != memoryReviewUnavailableCode { t.Fatalf("未接线 revert ⇒ %d/%s, want 500/%s", resp.StatusCode, body, memoryReviewUnavailableCode) } }