From ef82bcdfd3bcc0fe818db5ff7d77af7b89f88358 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 16:41:32 +0800 Subject: [PATCH] =?UTF-8?q?test(=E6=9D=83=E9=99=90):=20=E9=92=89=E4=BD=8F"?= =?UTF-8?q?=E5=80=BC=E6=B2=A1=E5=8F=98=E4=B9=9F=E5=B9=BF=E6=92=AD"?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=B7=B2=E9=99=8D=E7=BA=A7=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E7=9A=84=E6=81=A2=E5=A4=8D=E8=B7=AF=E5=BE=84=E9=9D=A0?= =?UTF-8?q?=E8=BF=99=E6=9D=A1=E5=90=A6=E5=AE=9A=E4=BA=8B=E5=AE=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi 2026-09-14 §3:这是一个**承重的"没有"**。我给"已降级会话怎么恢复"的答案里, 唯一"今天就能用"的手段是"人在 WebUI 里把同一个档位再选一次"——它之所以有效, 正是因为 `UpdateSessionPermission` **没有** "值没变就提前返回"。 判据是**行为**判据,不是读源码(形状判据挡不住"判断挪到 repo 层/挪到 middleware"): 起真库 + 真 SSE 客户端,连点两次同一个档位,数 `session_update` 帧 —— 第一次必须 ≥1(否则判据自己没接上,Fatal 而不是静默通过),第二次必须更多。 变异验证(先用 `db.DB` 注入 → 报的是 build failed,**不算判据红**,改用已有 `repo.SessionPermissionMode` 重注入): --- FAIL: TestPermissionUpdateBroadcastsEvenWhenValueUnchanged (0.01s) 撤回后 ok。`go test ./...` 全绿(11 个包)。 --- .../session_permission_broadcast_test.go | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 server/internal/handler/session_permission_broadcast_test.go diff --git a/server/internal/handler/session_permission_broadcast_test.go b/server/internal/handler/session_permission_broadcast_test.go new file mode 100644 index 0000000..223959c --- /dev/null +++ b/server/internal/handler/session_permission_broadcast_test.go @@ -0,0 +1,106 @@ +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) + } +}