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:
2026-09-14 16:41:32 +08:00
parent 7e696f9a8c
commit ef82bcdfd3

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