Files
MailUI4Agents/server/internal/handler/mail_cooldown_test.go
JianFeeeee 2fd18ba134 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
2026-10-01 20:19:47 +08:00

288 lines
12 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"
"fmt"
"net/http"
"net/http/httptest"
"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"
"github.com/google/uuid"
)
/*
Agent↔Agent 回路的**冷静期**在 handler 上的行为(2026-10-01)
# 两句话需求,落在两处
① 「agent 互发应当有 2h 恢复机制」
→ 撞 maxAgentPingPong=8 时**开始**冷静期,到期自动放行,不依赖人。
② 「锁定期间的邮件不自动重投递」
→ 冷静期内 **relay 邮件直接丢弃**(不排队、到时不补投)。
# 为什么 ② 必须单独钉
"不自动重投递"有三种可能的实现形状,观测上**不等价**:
· 直接拒(丢弃) ← 本次要求的
· 拒但入队、到期补投 ← 看着一样,其实把回路攒到 2h 后一次性放出来
· 收下但不投递 ← 库里多一封信,统计与将来的人工检查都被污染
所以这一格钉的是**库里没有这封信**,而不只是"接口返回了错误"。
*/
// lockSession 造一个「两个 Agent 互发已达上限、会话已进冷静期」的会话。
func lockSession(t *testing.T) (uuid.UUID, string) {
t.Helper()
ctx := context.Background()
// Agent 用 repo.CreateOrUpdateAgent 建(真实列是 secret/host_url,
// 不是 agent_key —— 我第一版照想象写了列名,被 schema 打回)。
// ⚠ **不要**把 pinger/ponger 建成了 user。
//
// 我第一版顺手调了 mustPermissionUser,结果 IsHumanUser("ponger")=true
// ⇒ 闸按"收件方是人类"正确放行 ⇒ 判据红。
// 闸没问题,是我的靶子造错了人:这两个必须是 Agent。
for _, u := range []string{"pinger", "ponger"} {
if err := repo.CreateOrUpdateAgent(ctx, u, "k-"+u, "test", nil); err != nil {
t.Fatalf("建 agent %s: %v", u, err)
}
}
sid, err := repo.CreateSession(ctx, nil, "pinger", "回路", "")
if err != nil {
t.Fatalf("建会话: %v", err)
}
// ⚠ 必须有一封**已存在的信**才能 reply_to 进去。
//
// 我第一版直接用 `ponger@.new` 投递:`.new` 会**另建一条会话**,
// 于是锁在 sid 上、请求却落在另一条会话上 ⇒ 一路 200,
// 判据看着像"闸没生效"。真实闸没问题,是我的靶子打偏了。
if _, err := repo.CreateMail(ctx, sid, nil, "ponger", "", "pinger", "",
"起个头", "在吗", nil); err != nil {
t.Fatalf("预置一封信: %v", err)
}
var mailID string
if err := db.DB.QueryRowContext(ctx,
`SELECT mail_id FROM mails WHERE session_id = $1 ORDER BY created_at DESC LIMIT 1`,
sid).Scan(&mailID); err != nil {
t.Fatal(err)
}
if _, err := repo.SetSessionLock(ctx, sid, 8, "test"); err != nil {
t.Fatalf("进入冷静期: %v", err)
}
return sid, mailID
}
// countMails 数会话里的邮件数。判据用它对比**前后的变化**:
// lockSession 自己会预置一封「起头信」(没有它 reply_to 无处可落),
// 所以基线不是 0。
func countMails(t *testing.T, sessionID uuid.UUID) int {
t.Helper()
var n int
if err := db.DB.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM mails WHERE session_id = $1`, sessionID).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
// postSend 以某个 agent 的身份发一封信,返回响应体。
func postSend(t *testing.T, agent, to, body, relayKey, replyTo string) *httptest.ResponseRecorder {
t.Helper()
ctx := context.Background()
if err := repo.CreateOrUpdateAgent(ctx, agent, "k-"+agent, "test", nil); err != nil {
t.Fatalf("注册 %s: %v", agent, err)
}
// relay 非空时服务端要求 relay_key 齐备("给了 relay_key 却没给 relay 类型"
// —— 我第一版只给了 relay_key,被这道前置校验先打回,看不到真正的闸)。
relay := ""
if relayKey != "" {
relay = "summary"
}
form := strings.NewReader(fmt.Sprintf(
`{"to":%q,"subject":"s","body":%q,"relay_key":%q,"relay":%q,"reply_to":%q}`,
to, body, relayKey, relay, replyTo))
req := httptest.NewRequest("POST", "/api/v1/mail", form)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(context.Background(), middleware.AgentNameKey, agent))
rr := httptest.NewRecorder()
SendMail(rr, req)
return rr
}
// ① 冷静期内:Agent 的自主回信被拒,且**给出的是可执行的出路**。
func TestCooldownBlocksAgentSendAndStatesRecovery(t *testing.T) {
setupPermissionHandlerDB(t)
sid, mailID := lockSession(t)
baseCount := countMails(t, sid)
rr := postSend(t, "pinger", "ponger@", "还在互相确认吗", "", mailID)
if rr.Code != http.StatusForbidden {
t.Fatalf("冷静期内应 403,实际 %d:%s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
// 文案必须说清「多久后自动恢复」——旧文案只教「请人类插话」,
// 而这条线索上常常根本没有人类(那正是原缺陷)。
if !strings.Contains(body, "冷静期") {
t.Fatalf("错误文案应说明这是冷静期:%s", body)
}
if !strings.Contains(body, "恢复") {
t.Fatalf("★ 文案必须给出恢复出路(自动恢复 or 人类插话):%s", body)
}
if n := countMails(t, sid); n != baseCount {
t.Fatalf("★ 被拒的信不得入库(否则它会被补投):%d → %d 封", baseCount, n)
}
}
// ② 冷静期内:relay(自动转发)**丢弃**,不排队不补投。
func TestCooldownDiscardsRelayWithoutQueueing(t *testing.T) {
setupPermissionHandlerDB(t)
sid, mailID := lockSession(t)
baseCount := countMails(t, sid)
rr := postSend(t, "pinger", "ponger@", "自动转发", "relay-key-1", mailID)
if rr.Code != http.StatusForbidden {
t.Fatalf("冷静期内 relay 应 403,实际 %d:%s", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "已丢弃") {
t.Fatalf("★ 文案必须明说「已丢弃」(否则发件方以为会补投而等待):%s", rr.Body.String())
}
// 关键:库里既没有邮件,**也没有留下待补投的占位**
if n := countMails(t, sid); n != baseCount {
t.Fatalf("★ relay 不得入库(否则 2h 后会被一次性灌回去):%d → %d 封", baseCount, n)
}
var keys int
if err := db.DB.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM relayed_mails WHERE relay_key = $1`, "relay-key-1").Scan(&keys); err != nil {
t.Fatal(err)
}
if keys != 0 {
t.Fatalf("★ 不得占用 relay 幂等键(占着会让重试拿到 duplicate 却从未发出):%d 行", keys)
}
}
// ③ 人类插话立刻恢复 —— 冷静期不是死等 2 小时。
func TestHumanPostClearsCooldownImmediately(t *testing.T) {
setupPermissionHandlerDB(t)
ctx := context.Background()
mustPermissionUser(t, "human1")
sid, mailID := lockSession(t)
// 人类在**同一会话**里插一封(非 relay)—— 必须 reply_to 进去:
// 用 .new 会另建一条会话,解锁的就不是被锁的那条了(我第一版的错)。
req := httptest.NewRequest("POST", "/api/v1/mail",
strings.NewReader(fmt.Sprintf(
`{"to":"ponger@","subject":"人来了","body":"停一下","reply_to":%q}`, mailID)))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(context.Background(), middleware.AgentNameKey, "human1"))
rr := httptest.NewRecorder()
SendMail(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("★ 人类参与后必须立刻解锁(不必等 2 小时)")
}
}
// ④ 收件方是人类时**永远不该进**回路计数/冷静期。
func TestHumanRecipientNeverLocked(t *testing.T) {
setupPermissionHandlerDB(t)
ctx := context.Background()
mustPermissionUser(t, "human1")
rr := postSend(t, "pinger", "human1@.new", "请教一下", "", "")
if rr.Code/100 != 2 {
t.Fatalf("发往人类不应被回路闸拦:%d %s", rr.Code, rr.Body.String())
}
var locks int
if err := db.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM session_agent_locks`).Scan(&locks); err != nil {
t.Fatal(err)
}
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 里「请由人类插一句话」是一句假话")
}
}