package handler /* 会话树的**可见性剪断**判据(2026-10-04)。 # 为什么这一格最要紧 树天然会把**父节点**的信息带给子节点。而「我能看见 B」不等于「我能看见 B 的父A」—— A 可能是别人与别人的对话。 所以服务端必须**按节点过滤 + 剪断不可见祖先**。只做前者不做后者,输出里 就带着不可见父节点的 session_id 与标题 —— 那是一条真实的泄露路径, 而且它藏在「树视图」这个新功能里,不会有人想到去查。 ★ 与 Agent 侧那条边界无关:AgentMayReadSession(Agent 只能读自己参与过的会话) 是 15e4fe9 / 095213b 修出来的越权防护;本端点属人类登录态,admin 全看是用户 2026-10-04 显式授权的。两者语义不同,不要混谈。 */ import ( "testing" "github.com/agentmail/gateway/internal/repo" ) // A → B → C,用户只参与 B 与 C(A 是别人与别人的对话)。 func TestPruneTreeHidesInvisibleAncestor(t *testing.T) { nodes := []repo.SessionNode{ {SessionID: "A", Alias: "别人的私密线索", Depth: 0}, {SessionID: "B", Alias: "我参与的", ParentSessionID: "A", Depth: 1}, {SessionID: "C", Alias: "我参与的续线", ParentSessionID: "B", Depth: 2}, } visible := map[string]bool{"B": true, "C": true} out := repo.PruneTree(nodes, visible) if len(out) != 2 { t.Fatalf("只应留下可见的 2 个节点,实际 %d:%+v", len(out), out) } // ★ 初版这里对**每个**输出节点都断言 Depth==0,那是判据自己写错了: // B 重新起根(depth 0),但 C 仍挂在 B 之下(depth 1)—— 两者不同。 // 判据红着而代码是对的。(又一次「判据比语义宽/窄」的同族错误。) for _, n := range out { if n.SessionID == "A" { t.Fatal("★ 不可见的 A 不该出现在输出里") } if n.ParentSessionID == "A" { t.Errorf("★ B 的 parent 仍指向不可见的 A ⇒ 泄露了 A 的 session_id") } } depth := map[string]int{} parent := map[string]string{} for _, n := range out { depth[n.SessionID] = n.Depth parent[n.SessionID] = n.ParentSessionID } if depth["B"] != 0 || parent["B"] != "" { t.Errorf("B 的父被剪掉后应重新起根(depth=0, parent 空),实际 depth=%d parent=%q", depth["B"], parent["B"]) } // C 仍挂在 **B** 之下(B 可见),但绝不能经由不可见的 A if depth["C"] != 1 || parent["C"] != "B" { t.Errorf("C 应挂在 B 之下(depth=1, parent=B),实际 depth=%d parent=%q", depth["C"], parent["C"]) } } // 全部可见时不得改动任何东西(admin 走的就是这条路)。 func TestPruneTreeKeepsFullyVisibleTree(t *testing.T) { nodes := []repo.SessionNode{ {SessionID: "A", Alias: "a", Depth: 0}, {SessionID: "B", Alias: "b", ParentSessionID: "A", Depth: 1}, {SessionID: "C", Alias: "c", ParentSessionID: "B", Depth: 2}, } visible := map[string]bool{"A": true, "B": true, "C": true} out := repo.PruneTree(nodes, visible) if len(out) != 3 { t.Fatalf("全部可见时应保留 3 个节点,实际 %d", len(out)) } for _, n := range out { want := map[string]int{"A": 0, "B": 1, "C": 2}[n.SessionID] if n.Depth != want { t.Errorf("%s depth 应为 %d,实际 %d", n.SessionID, want, n.Depth) } } if out[1].ParentSessionID != "A" { t.Errorf("全部可见时 B 的 parent 不该被清空,实际 %q", out[1].ParentSessionID) } }