- 断点已证 = **时序层**: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 要补的
284 lines
12 KiB
Go
284 lines
12 KiB
Go
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
|
||
}
|