Files
Cyrene_v2/internal/services/gateway/admin_flow_graph_test.go
T
AskaEth 1bf85a955b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): 链路图冷启动快照(v0.71)+ 面板来源说明条 —— 修「轮次结束后打开页面一片空白」
- 断点已证 = **时序层**:graph_update 只走实时帧从不重放、flowGraphs 不持久化、补拉通路此前根本不存在(EndRound 注释承诺过「刷新能重放」,代码里从来没有)
  铁证:15:16:38 页面重载抹掉 15:09 那轮图,而下一轮心跳要等 ~15:24 ⇒ 打开后台思考页时桶必然为空
- 新增只读端点 GET /api/v1/admin/flow/graph?sessionId=(owner|admin;省略=主会话;graph 与 graph_update 帧的 data 逐字同形)
  无图 ⇒ 200 + graph:null(不画假图);图源未接线 ⇒ 500 flow_unavailable(不伪装成「没有图」);不审计、不发布
- loop.FlowPublisher.LastGraph:只读快照(不发布/不改图/不动轮次计数),FlowGraph.Clone 深拷贝避免锁外编码撕裂
- 前端 useFlowSnapshot:只在「本会话还没有图」时补拉(避免每行一个请求);Flow/Agents/Backthink 三处接入 + 来源说明条(明确「最近一轮」与「多轮历史回放尚未实现」)
- 变异实测:LastGraph 恒 nil ⇒ 4 条测试红;白名单去掉 sess-backthink ⇒ 对应测试红(首次变异因未使用 import 编译失败 ⇒ 已作废重做)
- ⚠️ 诚实边界:每会话只回**最近一轮**(多轮回放属 F2);白名单外会话(IM 群聊/陌生人)永远没有图;**后端重启后未跑新轮次前端点如实返 null(图是内存资产,不落库)**—— 主人要的「本地数据」正是 F2 要补的
2026-10-05 15:38:53 +08:00

284 lines
12 KiB
Go
Raw 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.
package gateway
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// 本文件钉住契约 v0.71 的唯一新增面:`GET /api/v1/admin/flow/graph` ——
// 「最近一轮链路图快照」的只读出口。它修的是主人第二次报的那个洞:
// `graph_update` 只走实时帧、从不重放(前端图桶也不持久化)⇒ 轮次结束后
// 才打开的页面永远空着。
// flowTestEnv 构建一个接了**真** *loop.FlowPublisher 作图源的网关。
// 与 newEnv 一样接真 bus、真 JWT,只是多注入了 Flow —— 因此测的是
// 生产那条路径(发布器 → 只读查询),不是假实现。
type flowTestEnv struct {
ts *httptest.Server
auth *auth.JWTService
flow *loop.FlowPublisher
bus bus.Bus
graph *trace.RunGraph
}
func newFlowTestEnv(t *testing.T, allow func(string) bool) *flowTestEnv {
t.Helper()
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
b := bus.New()
// 图的发布出口在这里只用于「证明查询面不影响发布」:收集每一帧发布。
pub := loop.NewFlowPublisher(func(string, any) {}, allow)
cfg := &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}}
srv, err := New(cfg, Deps{Auth: jwt, Bus: b, Flow: pub})
if err != nil {
t.Fatalf("new server: %v", err)
}
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return &flowTestEnv{ts: ts, auth: jwt, flow: pub, bus: b}
}
func (e *flowTestEnv) token(t *testing.T, role auth.Role) string {
t.Helper()
tok, err := e.auth.Sign("acct", role, time.Hour)
if err != nil {
t.Fatalf("sign token: %v", err)
}
return tok
}
// runFinishedRound 让发布器跑完**一整轮**(起轮 → span 起止 → 收轮),
// 模拟「轮次已经结束」的时刻。返回时该轮所有实时帧都已发完 ——
// 因此此刻打开页面的客户端一帧都收不到,只能靠只读快照。
func (e *flowTestEnv) runFinishedRound(t *testing.T, session string) {
t.Helper()
e.flow.StartRound(session, "帮我查一下天气")
sp := &trace.Span{ID: "span-llm", Name: "llm.call", Kind: trace.SpanKindLLM, StartedAt: time.Now()}
e.flow.SpanStarted(session, sp)
sp.Finish(trace.SpanStatusOK)
e.flow.SpanFinished(session, sp)
e.flow.EndRound(session, "今天晴,26 度。")
}
// flowGraphData 解码 {data:{sessionId,graph}} 信封;`graph` 键必须**恒在**
// (没有图时是 JSON null),因为客户端靠它区分「没有图」与「字段缺失」。
func flowGraphData(t *testing.T, body []byte) (string, map[string]json.RawMessage, bool) {
t.Helper()
env := decodeBody(t, body)
var data struct {
SessionID string `json:"sessionId"`
Graph map[string]json.RawMessage `json:"graph"`
}
if err := json.Unmarshal(env["data"], &data); err != nil {
t.Fatalf("decode data %s: %v", body, err)
}
var envelope map[string]json.RawMessage
if err := json.Unmarshal(env["data"], &envelope); err != nil {
t.Fatalf("decode data envelope %s: %v", body, err)
}
_, present := envelope["graph"]
return data.SessionID, data.Graph, present
}
// TestAdminFlowGraph_LatePageSeesLastRound 是主人的原话场景:
// **轮次结束后才打开页面**(本次测试里没有任何 WS 客户端连过)也必须能看到
// 该轮的图。它是本片的核心断言 —— 去掉只读快照通路它必须失败。
func TestAdminFlowGraph_LatePageSeesLastRound(t *testing.T) {
env := newFlowTestEnv(t, func(string) bool { return true })
env.runFinishedRound(t, "web")
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph?sessionId=web", nil, env.token(t, auth.RoleOwner))
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
sessionID, graph, present := flowGraphData(t, body)
if !present {
t.Fatalf("`graph` key must always be present (null when empty): %s", body)
}
if graph == nil {
t.Fatalf("graph = null,但该会话刚跑完一整轮 ⇒ 轮次结束后打开的页面看不到图(这正是本次要修的那个洞): %s", body)
}
if sessionID != "web" {
t.Fatalf("sessionId = %q, want web", sessionID)
}
var nodes []map[string]json.RawMessage
if err := json.Unmarshal(graph["nodes"], &nodes); err != nil {
t.Fatalf("decode nodes: %v (graph=%s)", err, graph)
}
if len(nodes) == 0 {
t.Fatalf("nodes 为空,快照没带上该轮的节点: %s", graph)
}
// 主干两端必须在:input(用户输入)与 output(回复)。
kinds := map[string]bool{}
for _, n := range nodes {
var kind string
_ = json.Unmarshal(n["kind"], &kind)
kinds[kind] = true
}
if !kinds["input"] || !kinds["output"] {
t.Fatalf("快照缺少主干端点 input/output(kinds=%v): %s", kinds, graph)
}
// 图的归属必须是它自己的会话 id —— 前端按这个键入桶(裁决 16.4.2)。
var gsid string
_ = json.Unmarshal(graph["sessionId"], &gsid)
if gsid != "web" {
t.Fatalf("graph.sessionId = %q, want web", gsid)
}
}
// TestAdminFlowGraph_NoGraphYetIsHonestNull 钉住空态:该会话**确实**还没有图时
// 回 200 + `graph:null`(键恒在)—— 绝不允许画假图、也绝不把「没有图」当错误。
func TestAdminFlowGraph_NoGraphYetIsHonestNull(t *testing.T) {
env := newFlowTestEnv(t, func(string) bool { return true })
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph?sessionId=sess-backthink", nil, env.token(t, auth.RoleAdmin))
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
_, graph, present := flowGraphData(t, body)
if !present {
t.Fatalf("`graph` 键必须恒在(空时是 null): %s", body)
}
if graph != nil {
t.Fatalf("graph = %s,但该会话从未跑过轮次 ⇒ 必须如实为 null", body)
}
}
// TestAdminFlowGraph_DefaultSessionIsMain 钉住 `?sessionId=` 省略时落到主会话
// `web`(与网关其余默认会话口径同源)。
func TestAdminFlowGraph_DefaultSessionIsMain(t *testing.T) {
env := newFlowTestEnv(t, func(string) bool { return true })
env.runFinishedRound(t, "web")
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph", nil, env.token(t, auth.RoleOwner))
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
sessionID, graph, _ := flowGraphData(t, body)
if sessionID != "web" || graph == nil {
t.Fatalf("省略 sessionId 应落到主会话 web 并带回它的图;got sessionId=%q graph=%v (body=%s)", sessionID, graph, body)
}
}
// TestAdminFlowGraph_UnwiredSourceIsHonest 钉住接线缺失的诚实口径:图源未注入时
// 回 `500 flow_unavailable`,**不是** `graph:null` —— 后者会把「图源没接线」伪装成
// 「这一轮确实没有图」,正是项目明令禁止的那类静默降级。
func TestAdminFlowGraph_UnwiredSourceIsHonest(t *testing.T) {
env := newEnv(t, nil, false) // newEnv 不注入 Flow
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph", nil, env.token(t, auth.RoleOwner))
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body)
}
envErr := decodeBody(t, body)
var eobj struct {
Code string `json:"code"`
}
if err := json.Unmarshal(envErr["error"], &eobj); err != nil {
t.Fatalf("error envelope: %v (body=%s)", err, body)
}
if eobj.Code != flowUnavailableCode {
t.Fatalf("error code = %q, want %q", eobj.Code, flowUnavailableCode)
}
}
// TestAdminFlowGraph_RoleGate 钉住角色门与同一张图的 WS 帧同档
// (owner/admin 可读、viewer 403、匿名 401):不存在「帧收不到、REST 看得到」的旁路。
func TestAdminFlowGraph_RoleGate(t *testing.T) {
env := newFlowTestEnv(t, func(string) bool { return true })
env.runFinishedRound(t, "web")
url := env.ts.URL + "/api/v1/admin/flow/graph"
resp, body := doReq(t, http.MethodGet, url, nil, env.token(t, auth.RoleAdmin))
if resp.StatusCode != http.StatusOK {
t.Fatalf("admin status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
resp, body = doReq(t, http.MethodGet, url, nil, env.token(t, auth.RoleViewer))
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("viewer status = %d, want 403 (body=%s)", resp.StatusCode, body)
}
resp, _ = doReq(t, http.MethodGet, url, nil, "")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("anonymous status = %d, want 401", resp.StatusCode)
}
}
// TestAdminFlowGraph_ReadDoesNotPublish 钉住「只读查询不改任何会话」:
// 拉快照不产生新的 bus 事件(否则补拉会在管理面板里凭空造出一帧 graph_update)。
func TestAdminFlowGraph_ReadDoesNotPublish(t *testing.T) {
published := 0
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
b := bus.New()
pub := loop.NewFlowPublisher(func(string, any) { published++ }, func(string) bool { return true })
cfg := &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}}
srv, err := New(cfg, Deps{Auth: jwt, Bus: b, Flow: pub})
if err != nil {
t.Fatalf("new server: %v", err)
}
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
pub.StartRound("web", "你好")
pub.EndRound("web", "你好呀")
before := published
tok, err := jwt.Sign("acct", auth.RoleOwner, time.Hour)
if err != nil {
t.Fatalf("sign token: %v", err)
}
for i := 0; i < 3; i++ {
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/flow/graph?sessionId=web", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
}
if published != before {
t.Fatalf("补拉发布了 %d 帧新图(%d → %d),只读查询必须不发布", published-before, before, published)
}
}
// TestAdminFlowGraph_OutsideWhitelistStaysNil 钉住图源白名单仍然生效:
// 发布会话范围之外的会话(隔离渠道会话)从来没有建过图,因此只读查询
// **不可能**把它们变成可查的(design/19 §1 裁决 01.4.5 未被本版改动)。
func TestAdminFlowGraph_OutsideWhitelistStaysNil(t *testing.T) {
env := newFlowTestEnv(t, func(session string) bool { return session == "web" })
env.runFinishedRound(t, "web")
// 白名单外的会话:起轮与 span 都被发布器直接丢弃 ⇒ 从不建图。
env.runFinishedRound(t, "sess-im-stranger")
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph?sessionId=sess-im-stranger", nil, env.token(t, auth.RoleOwner))
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
_, graph, present := flowGraphData(t, body)
if !present {
t.Fatalf("`graph` 键必须恒在: %s", body)
}
if graph != nil {
t.Fatalf("白名单外的会话不该有图,却拿到了 %s", body)
}
// 反向:白名单内的会话照常可查(保证上面的 null 不是「端点整体坏了」)。
_, ok, _ := flowGraphData(t, mustBody(t, env, "web"))
if ok == nil {
t.Fatal("白名单内的 web 会话应当仍有图")
}
}
// mustBody 直接问端点并把响应体交回给调用方(上面反向断言的辅助)。
func mustBody(t *testing.T, env *flowTestEnv, session string) []byte {
t.Helper()
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph?sessionId="+session, nil, env.token(t, auth.RoleOwner))
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
return body
}