Files
MailUI4Agents/server/internal/handler/session_permission_broadcast_test.go
JianFeeeee ef82bcdfd3 test(权限): 钉住"值没变也广播"—— 已降级会话的恢复路径靠这条否定事实
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 个包)。
2026-09-14 16:41:32 +08:00

107 lines
4.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}