fix(回路): 人类经 /me/mail/send 插话也要解锁 —— 端到端实测抓到的缺口

上一提交(4d8165f)的 403 文案写着「若要立刻恢复,请由人类在会话里插一句话」,
但**那句话是假的**。

## 实测证据

拿真实用户登录态经 `POST /api/v1/me/mail/send` 在被锁会话里插话:

    HTTP 200  {"mail_id":"68eda0c3-…"}    ← 人类的信进去了(这是对的)
    session_agent_locks 锁行数: 1          ← ★ 锁还在

`MeSendMail` 有自己的 resolve + CreateMail,**根本不经过 Agent 侧那道闸**
(main.go 里 UserAuth 组下单独注册)。所以解锁只写在 `SendMail` 里是不够的:
人类插了话,锁仍要等满 2 小时。

## 为什么单测没抓到

`TestHumanPostClearsCooldownImmediately` 走的是 Agent 侧 handler,
证明了「解锁逻辑本身对」,却没证明「人类实际会走的那条路也解锁」。
判据钉的是 A 路径、生产走的是 B 路径 —— 这个形状本仓已遇到三次
(opencode 的 withScope、homeagent 的 InjectInputSync、这次)。

## 修法

把解锁放在 `SetSessionOwner` 旁边 —— 那是「人类参与这条会话」的权威落点,
比在每个调用点各写一遍可靠(漏一处就又是一句假承诺)。
解锁失败只记日志不阻断:人的来信优先入库,代价只是那把锁到期自消(保守方向)。

判据:新增 `TestHumanSendPathAlsoClearsCooldown`,走真实 `middleware.UserAuth`
+ 真实 user_sessions 行。变异(撤掉解锁)后该格变红。

踩到的两个测试夹具坑(都记在判据注释里):
- 直接调 handler 会 401 —— 生产上这条路由在 UserAuth 中间件后面
- UserAuth → SessionToken 读 config.C.CookieName,而测试里 config.C 是 nil ⇒ panic
This commit is contained in:
2026-10-01 20:19:47 +08:00
parent f86f08c7dc
commit 2fd18ba134
2 changed files with 94 additions and 1 deletions

View File

@ -8,6 +8,7 @@ import (
"strings"
"testing"
"github.com/agentmail/gateway/internal/config"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/repo"
@ -209,4 +210,78 @@ func TestHumanRecipientNeverLocked(t *testing.T) {
if locks != 0 {
t.Fatalf("★ 发往人类不得产生锁(人类在回路里正是我们要的):%d 行", locks)
}
}
}
/*
★★ 2026-10-01 端到端实测抓到的缺口:**人类走的是另一条路径**。
# 现象
我在 `SendMail`(Agent 侧)里写了「人类插话立刻解锁」。部署后拿真实用户
登录态经 `POST /api/v1/me/mail/send` 在被锁会话里插话:
HTTP 200 {"mail_id":"68eda0c3-…"} ← 人类的信进去了(对)
session_agent_locks 锁行数: 1 ← ★ 锁还在
`MeSendMail` 有它自己的 resolve + CreateMail,**根本不经过 Agent 侧那道闸**。
所以我写在 403 里的那句「若要立刻恢复,请由人类在会话里插一句话」是**假话** ——
人类插了话,锁仍要等满 2 小时。
# 为什么单测没抓到
`TestHumanPostClearsCooldownImmediately` 走的是 `SendMail`(Agent 侧 handler),
且注入身份 `human1`(一个 **user**)。那条恰好是**能**解锁的路径 ——
它证明了「解锁逻辑本身对」,却没证明「人类实际会走的那条路也解锁」。
# 这一格钉的是**接线**
不是解锁的语义(那个已由上面那格覆盖),而是「人类的路径上也调了它」。
这类缺陷(判据钉的是 A 路径,生产走的是 B 路径)在别的桥上已出现过两次:
opencode 的 withScope、homeagent 的 InjectInputSync。
*/
func TestHumanSendPathAlsoClearsCooldown(t *testing.T) {
setupPermissionHandlerDB(t)
ctx := context.Background()
if _, err := repo.CreateUser(ctx, "guiuser", "x", "guiuser", "user", nil, nil); err != nil {
t.Fatalf("建用户: %v", err)
}
sid, mailID := lockSession(t)
// 用**用户登录态**(middleware.UserAuth 那条路),而不是 Agent 身份
// ⚠ UserAuth → SessionToken 读 config.C.CookieName,而 config.C 在测试里是 nil
// (实测 panic: nil pointer at middleware/user.go:35)。
// appearance_test.go 里有同样的处理,照它来。
if config.C == nil {
config.C = &config.Config{CookieName: "am_session"}
t.Cleanup(func() { config.C = nil })
}
tok := "probe-session-token-20261001"
if _, err := db.DB.ExecContext(ctx,
`INSERT INTO user_sessions (token, user_id, created_at, expires_at)
SELECT $1, user_id, datetime('now'), datetime('now','+1 day')
FROM users WHERE username = $2`, tok, "guiuser"); err != nil {
t.Fatalf("造登录态: %v", err)
}
req := httptest.NewRequest("POST", "/api/v1/me/mail/send",
strings.NewReader(fmt.Sprintf(
`{"to":"ponger@","subject":"人来了","body":"停一下","reply_to":%q}`, mailID)))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: "am_session", Value: tok})
rr := httptest.NewRecorder()
// ★ 必须包上 UserAuth —— 生产里这条路由就在该中间件后面
// (main.go: r.Group(UserAuth) → r.Post("/me/mail/send", …))。
// 我第一版直接调 handler ⇒ 401,判据红得莫名其妙。
middleware.UserAuth(http.HandlerFunc(MeSendMail)).ServeHTTP(rr, req)
if rr.Code/100 != 2 {
t.Fatalf("人类的信不该被回路闸拦:%d %s", rr.Code, rr.Body.String())
}
// ★ 关键断言:人类的信进来了,锁**同时**必须消失。
// 只断言前者的话,"人类能发信"与"人类插话解锁"两件事会一起通过,
// 而后者恰恰是缺失的那个。
if _, locked, _ := repo.SessionLockOf(ctx, sid); locked {
t.Fatal("★ 人类经 /me/mail/send 插话后必须立刻解锁 —— " +
"否则 403 里「请由人类插一句话」是一句假话")
}
}