package handler import ( "context" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "github.com/agentmail/gateway/internal/db" "github.com/agentmail/gateway/internal/middleware" "github.com/agentmail/gateway/internal/models" "github.com/agentmail/gateway/internal/sse" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) /* * 判据:**值没变也要广播**(pi 2026-09-14 §3:这是一个"承重的没有")。 * * # 它承载什么 * * "已经降级的会话怎么恢复"这个问题,我在回信里给的答案依赖一条**否定事实**: * `UpdateSessionPermission` **没有** "值没变就提前返回" —— 所以人在 WebUI 里把同一个档位 * **再选一次也有效**,而且在**旧版桥仍在生产上跑**的时候就能生效(不需要等部署)。 * 这是我唯一给出的"今天就能用"的恢复手段。 * * 一条否定事实如果没人替下一个人验一遍,就会以"我以为它有那个 early return"的形式烂掉 —— * 而这条一旦被加上提前返回,恢复路径就**静默**消失了:用户点了一次、界面没变化、 * 插件也没收到新档位,看起来像"点了没用"。 * * # 为什么是行为判据而不是读源码 * * 判源码形状只能证明"现在没有那个 if",改天有人把判断挪到 repo 层、挪到 middleware、 * 或者写成"值相等就不调 Broadcast 但调别的",形状判据全都漏。 * 这里直接**订阅 SSE 再点两次同一个档位**,数收到的 `session_update` 帧: * 两次都必须有。与"探针要真跑一次"同一个道理。 */ func TestPermissionUpdateBroadcastsEvenWhenValueUnchanged(t *testing.T) { dir := t.TempDir() if err := db.Connect(context.Background(), "sqlite://"+filepath.Join(dir, "t.db")); err != nil { t.Fatalf("connect: %v", err) } if err := db.Migrate(context.Background()); err != nil { t.Fatalf("migrate: %v", err) } t.Cleanup(func() { db.Close() }) ctx := context.Background() if _, err := db.DB.ExecContext(ctx, `INSERT INTO users (username, password_hash, role) VALUES ('alice', 'x', 'user')`); err != nil { t.Fatalf("建用户: %v", err) } var userID uuid.UUID if err := db.DB.QueryRowContext(ctx, `SELECT user_id FROM users WHERE username = 'alice'`).Scan(&userID); err != nil { t.Fatalf("读用户 id: %v", err) } var sessionID uuid.UUID if err := db.DB.QueryRowContext(ctx, `INSERT INTO sessions (from_agent, subject, workspace, owner_user_id) VALUES ('sender', 's', '', $1) RETURNING session_id`, userID).Scan(&sessionID); err != nil { t.Fatalf("建会话: %v", err) } // 订阅 SSE:真的挂一个客户端上去,然后数帧 rec := httptest.NewRecorder() sub := httptest.NewRequest(http.MethodGet, "/api/v1/events", nil) sub = sub.WithContext(context.WithValue(sub.Context(), middleware.UserKey, &models.User{ID: userID, Username: "alice"})) sse.Default.AddClient(rec, sub, "", "alice") t.Cleanup(func() { sse.Default.RemoveClient("") }) call := func(mode string) { body, _ := json.Marshal(map[string]string{"permission_mode": mode}) r := httptest.NewRequest(http.MethodPut, "/api/v1/sessions/"+sessionID.String()+"/permission", strings.NewReader(string(body))) r = r.WithContext(context.WithValue(r.Context(), middleware.UserKey, &models.User{ID: userID, Username: "alice"})) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", sessionID.String()) r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) w := httptest.NewRecorder() UpdateSessionPermission(w, r) if w.Code != http.StatusOK { t.Fatalf("改档位返回 %d:%s", w.Code, w.Body.String()) } } count := func() int { return strings.Count(rec.Body.String(), "session_update") } call("workspace") // 第一次:真变化 afterFirst := count() if afterFirst == 0 { t.Fatalf("第一次改档位没收到 session_update(判据自己没接上:先看 SSE 客户端有没有挂上)") } call("workspace") // 第二次:**值没变** —— 恢复路径就靠这一下 afterSecond := count() if afterSecond <= afterFirst { t.Fatalf("同一个档位再选一次**没有**再广播(%d → %d)。\n"+ "这条不是洁癖:人在 WebUI 里重选同值是**已降级会话**在旧版桥仍在线时唯一的恢复手段,\n"+ "加上「值没变就不广播」会让它静默失效 —— 用户点了一次、界面没变、插件没收到,看起来像没反应。", afterFirst, afterSecond) } }