package handler import ( "context" "fmt" "net/http" "net/http/httptest" "strings" "testing" "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) } }