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 }