Files
MailUI4Agents/server/internal/handler/permission_relay_release_test.go
JianFeeeee 34a15dc09c fix(网关): 占住幂等键后的早退路径没退键 —— 永久占键、重试永远 duplicate
## 缺陷

`ClaimRelay` 成功之后、邮件落库之前有若干条早退路径,只有**部分**在 return 前
调了 `ReleaseRelay`。漏掉的那些会把 (agent_name, relay_key) 永久占住:
之后同一上游消息的重试拿到 `ErrRelayDuplicate` ⇒ 返回 200 `duplicate_relay`
(文案是「该上游消息已转发过,本次调用未产生新邮件」),
而那条消息**从未发出去** —— 响应在陈述一件没发生的事。

野外实例(现库仍在,`relayed_mails` 唯一一行 `mail_id IS NULL`):

    agent_name = zcode
    relay_key  = no-such-session-0000:toolu-nohuman-1789193173578
    created_at = 2026-09-12 06:06:13

它的 session 位不是 UUID,于是 `RequestPermission` 走到
`uuid.Parse` 失败那条 `Invalid session_id`(permission.go:112)直接 return,
没有退键。该行从 09-12 留到现在。

`mail.go` 同类:`ClaimRelay`(:396)之后 `ConsumeSessionBudget` 返回
非「额度耗尽」错误时(:433)也没退键。

## 修法

不在各 return 前逐个补 `ReleaseRelay` —— 那正是缺陷的成因(漏一处就是一个静默的
永久占键,而漏掉的那处不会有任何报错)。改为占键成功后立刻 `defer` 一次释放,
让"还键"成为该作用域的唯一出口不变量。

成功路径不需要额外开关:`ReleaseRelay` 的 WHERE 含 `mail_id IS NULL`,
`BindRelayMail` 一旦成功该行就不再匹配,defer 自然什么都不做。

## 判据(两条独立用例,缺一即放过一类坏实现)

`TestRequestPermissionReleasesRelayKeyOnEarlyExit` —— 失败路径:早退后同一个
relay_key 必须还能再次占用(键被还回来了)。

`TestRequestPermissionClaimsRelayKeyOnSuccess` —— 成功路径:同一询问重复投递
必须被幂等键挡住,且键确实绑定到了第一封邮件上。

两条必须分开:早退时"正确实现"与"把 ClaimRelay 换成空操作"的坏实现在表里
**观测等价**(都留下一个空键),单条用例无法同时钉住"该退的时候退"和
"该占的时候占"。已逐个变异验证:

    基线(缺陷代码)  → 早退用例红、成功用例绿
    早退处补 Release  → 两条全绿
    ClaimRelay 置空   → 成功用例红(早退用例仍绿,符合预期)
    BindRelayMail 去掉 → 两条全红

`go test ./...` 13 包全绿;handler 组 -race 通过;gofmt/vet 干净。
2026-09-25 05:41:57 +08:00

145 lines
5.8 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 (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/repo"
)
// 占住幂等键之后,**任何**一条早退路径都必须把键还回去;同时幂等性本身不能坏掉。
//
// 背景(真实数据):现库里 `relayed_mails` 有一行孤儿键 ——
//
// agent_name = zcode
// relay_key = no-such-session-0000:toolu-nohuman-1789193173578
// mail_id = NULL (= 占位成功,但从未绑定到任何邮件)
// created_at = 2026-09-12 06:06:13
//
// 该键的 session 位不是 UUID,于是 `RequestPermission` 走到
// `uuid.Parse` 失败那条 `Invalid session_id`(permission.go:112)时直接 return,
// **没有调用 ReleaseRelay**。键因此永久留在表里:之后任何用同一个
// (agent_name, relay_key) 的重试都会拿到 `ErrRelayDuplicate`(→ 200
// duplicate_relay),而那次询问其实**从未发出去**。
//
// 为什么必须是两个独立的测试:这两件事在**早退路径**上观测等价。
// 早退时正确实现与"把 ClaimRelay 换成空操作"的坏实现**都**在表里留下一个空键,
// 所以任何单条用例都无法同时钉住"该退的时候退"和"该占的时候占"。
// 前者只能在**失败路径**上观测(键本该被还回来),
// 后者只能在**成功路径**上观测(键本该被占住)。
// 失败路径:早退之后同一个 relay_key 必须还能再次被占用(键被还回来了)。
func TestRequestPermissionReleasesRelayKeyOnEarlyExit(t *testing.T) {
setupPermissionHandlerDB(t)
ctx := context.Background()
if err := repo.CreateOrUpdateAgent(ctx, "pi", "secret", "test", nil); err != nil {
t.Fatalf("注册 pi: %v", err)
}
// 非 UUID 的 session 位:这正是野外那行孤儿键的形态,也是走到早退的入口。
const relayKey = "no-such-session-0000:toolu-nohuman-1789193173578"
resp := requestPermissionRaw(t, map[string]any{
"session_id": "no-such-session-0000",
"to": "human",
"question": "是否允许执行 bash?",
"options": []string{"同意", "拒绝"},
"relay_key": relayKey,
})
// 前提:请求确实失败(否则这个用例没验到早退那条路)。
if resp.Code != http.StatusBadRequest {
t.Fatalf("非 UUID 的 session_id 必须 400,实际 HTTP %d: %s", resp.Code, resp.Body.String())
}
// 断言:键被还回来了 —— 同一个键必须能再次占用。
if err := repo.ClaimRelay(ctx, "pi", relayKey, "permission"); err != nil {
t.Fatalf("早退路径没把幂等键还回去:再次占用同一个键被拒(%v)—— "+
"该 (agent, relay_key) 已被永久占用,之后的重试只会拿到 duplicate_relay,"+
"而那次询问从未发出", err)
}
}
// 成功路径:幂等键必须真的被占住 —— 同一条询问重复投递只能生成一封邮件。
//
// 这条与上一条是**互补**的,不是重复:把 `ClaimRelay` 换成空操作会让上一条全绿
// (键从未被占 ⇒ "还能再占"天然成立),只有这条会红。
func TestRequestPermissionClaimsRelayKeyOnSuccess(t *testing.T) {
setupPermissionHandlerDB(t)
ctx := context.Background()
if err := repo.CreateOrUpdateAgent(ctx, "pi", "secret", "test", nil); err != nil {
t.Fatalf("注册 pi: %v", err)
}
mustPermissionUser(t, "owner")
sid, err := repo.CreateSession(ctx, nil, "pi", "权限幂等", "/tmp")
if err != nil {
t.Fatalf("建会话: %v", err)
}
if err := repo.SetSessionOwner(ctx, sid, permissionUserID(t, "owner")); err != nil {
t.Fatalf("设置 owner: %v", err)
}
if _, err := repo.CreateMail(ctx, sid, nil, "owner", "", "pi", "/tmp",
"任务", "请执行", nil); err != nil {
t.Fatalf("建线索邮件: %v", err)
}
const relayKey = "sess-1:toolu-repeat-1"
body := map[string]any{
"session_id": sid.String(),
"to": "owner",
"question": "是否允许执行 bash?",
"options": []string{"同意", "拒绝"},
"relay_key": relayKey,
}
first := requestPermissionRaw(t, body)
if first.Code != http.StatusOK {
t.Fatalf("首次询问必须受理,HTTP %d: %s", first.Code, first.Body.String())
}
// 第二次同一 relay_key:幂等要求它**不产生新邮件**。
second := requestPermissionRaw(t, body)
if second.Code != http.StatusOK {
t.Fatalf("重复投递应幂等返回 200,HTTP %d: %s", second.Code, second.Body.String())
}
var got map[string]any
if err := json.Unmarshal(second.Body.Bytes(), &got); err != nil {
t.Fatalf("解析响应: %v", err)
}
if got["status"] != "duplicate_relay" {
t.Fatalf("重复投递必须被幂等键挡住(status=duplicate_relay),实际 %v —— "+
"幂等键没生效,SSE 重放与插件重试会给同一次询问发多封邮件", got["status"])
}
// 并且库里那条键确实绑到了第一封邮件上(不是留在空占位状态)。
var bound string
if err := db.DB.QueryRowContext(ctx,
`SELECT mail_id FROM relayed_mails WHERE agent_name = $1 AND relay_key = $2`,
"pi", relayKey).Scan(&bound); err != nil {
t.Fatalf("查幂等键绑定: %v", err)
}
if bound == "" {
t.Fatal("成功路径没有把幂等键绑定到邮件上(mail_id 为空)—— " +
"该键会被 ReleaseRelay 当成占位残留删掉,幂等随之失效")
}
}
// requestPermissionRaw 发一次 /permission/request,body 由调用方给定。
func requestPermissionRaw(t *testing.T, body map[string]any) *httptest.ResponseRecorder {
t.Helper()
payload, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, "/api/v1/permission/request", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
ctx := context.WithValue(req.Context(), middleware.AgentNameKey, "pi")
resp := httptest.NewRecorder()
RequestPermission(resp, req.WithContext(ctx))
return resp
}