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 个包)。
This commit is contained in:
106
server/internal/handler/session_permission_broadcast_test.go
Normal file
106
server/internal/handler/session_permission_broadcast_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user