Files
MailUI4Agents/server/internal/repo/session_status_invariant_test.go
JianFeeeee a3ca64b744 fix(归档): 权限决策同样不得把归档邮件改回 read + 修正决策路径误用参与方判据
pi 2026-09-28 §五 指出 DecidePermission 与 MarkMailRead 是同一族的反向写入,
只堵后者等于堵一半。成立,本轮收口。

## 1) DecidePermission 补守卫(pi §五)

  UPDATE mails SET permission_result=$1, status='read' WHERE mail_id=$2
                                                            ↑ 无 archived 守卫

与 MarkMailRead 同一处形状,补 `AND status <> 'archived'`。
判据 TestDecidePermissionDoesNotUnarchiveMail,已实测去掉守卫即转红。

★ mail_reads 的 INSERT 仍**不加**守卫(与 MarkMailRead 同口径):
事实表(谁读过,不可撤销)与派生列(全局可见性,可重算)语义不同,
一起挡会让「谁读过」不可审计 —— 判据 TestDecidePermissionDoesNotUnarchiveMail
顺带断言「决策人 bob 的 mail_reads 行仍要写进去」,防止守卫误伤事实表。

## 2) 修正 e78888b 的一处误用(我自己发现的)

DecidePermission 的 handler 路径(handler/permission.go)我原先挂了
`SessionOpenFor`(存在 + 未归档 + **参与方**),而该路径上一行刚放行的是
「该邮件收件人本人 **或** 管理员」—— 管理员本来就可以给任何线索做决策。
叠上参与方会把管理员挡在门外。改为 `EnsureSessionOpen`(只判存在 + 未归档)。

★ 这个错是在写 e78888b 时想到了、说了「要改成 EnsureSessionOpen」,
但**当时没落进文件**就提交了。已补,并在注释里写明两个函数的差别,
免得下一个人「顺手统一」把管理员又挡掉。

## 读侧清册

仍为 repo.go=13:新增 1 处命中在注释散文里,改措辞而非改数字。
repo/handler 全绿;internal/notify 的 TestInReplyToCarriesParentSender
仍是既有欠账 in-reply-to-ignores-direction,非本轮引入。
2026-09-28 11:12:59 +08:00

313 lines
13 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 repo
import (
"context"
"errors"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
/*
判据:`sessions.status` 与 `mails.status` **不得分叉**。
# 为什么不变量、单条路径都测不出来
「已归档 ⇒ 对所有人不可见」在读侧由两处判据保证,而它们挂在**不同的表**上:
unreadFor / readStateFor 判 `m.status` (repo.go:unreadFor)
ListInbox / UnreadWorkspaces 判 `s.status` (repo.go:ListInbox)
两边对同一条已归档线索给出**不同答案**,而每一边单独看都是"对的"
—— `unreadFor` 说"这封是归档所以不算未读",`ListInbox` 说"这条会话没归档所以列出来"。
分叉是怎么被造出来的:`TouchSession` 写的是 `sessions.status`
(`UPDATE ... SET status='active'`),而邮件那条 INSERT 写的是 `mails.status`。
**任何只碰前者而不碰后者的写路径,都会造出半活会话。**
所以判据不能写成"reply_to 应当 404"—— 那是**单点**,今天有三个入口
(reply_to / permission-request / adopt),明天可能有第四个。
真正堵住洞的是这一条:
对任意 session_id:sessions.status = 'archived' ⟹ 该会话全部 mails.status = 'archived'
它是**不可分叉**的形状:只要 CreateMail 系(唯一会写出 mails.status <>
'archived' 的入口)拒绝归档会话,TouchSession 系(唯一会解除归档的入口)
不再碰 status,那么任何**组合**调用都不可能分叉 —— 判据不必枚举入口。
入口级的回归仍单测(TestArchivedSessionRefuses*),但它们是**说明**,
这一条才是防线。
# 参照的坏结论
2026-09-28 探针期间 pi 的 `压测-限流-17` 得出「16/16 归档会话零回信、
4/4 活会话有回信」—— 那 4 条"活"会话正是被 TouchSession 复活的,
把「从未归档」和「归档后被复活」混成了一类。判据若只测「归档后回信会怎样」,
就抓不到这类混用;**分叉**是它们的共同签名。
*/
// sessionStatusOf 读一条会话的 sessions.status。
func sessionStatusOf(t *testing.T, sid uuid.UUID) string {
t.Helper()
var s string
if err := db.DB.QueryRowContext(context.Background(),
`SELECT status FROM sessions WHERE session_id = $1`, sid).Scan(&s); err != nil {
t.Fatal(err)
}
return s
}
// assertNoDivergence 是本文件所有判据的公共末尾:扫全库确认无半活会话。
//
// 扫全库而不是只看刚操作那条:分叉的读数要跨会话聚合才看得见
// (`ListInbox` 按 s.status 过滤、`unreadFor` 按 m.status 过滤,同一会话的
// 同一封邮件在两条路径上分别"在"和"不在")。
func assertNoDivergence(t *testing.T) {
t.Helper()
rows, err := db.DB.QueryContext(context.Background(), `
SELECT s.session_id, s.session_alias, s.status, m.mail_id, m.status
FROM sessions s JOIN mails m ON m.session_id = s.session_id
WHERE s.status = 'archived' AND m.status <> 'archived'`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
if rows.Next() {
var sid, alias, sstat, mid, mstat string
if err := rows.Scan(&sid, &alias, &sstat, &mid, &mstat); err != nil {
t.Fatal(err)
}
t.Fatalf("★ 半活会话:session %s(alias=%q) status=%s,但邮件 %s 的 mails.status=%s\n"+
" sessions.status='archived' 的会话里不该有非 archived 的邮件 ——\n"+
" ListInbox 按 s.status 放它出来、unreadFor 按 m.status 继续藏着,同一封两个答案。\n"+
" 查这两条写路径:TouchSession 是否又写了 status?CreateMail 系是否又漏了守卫?",
sid, alias, sstat, mid, mstat)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
}
// seedArchivedSession 建一条含一封邮件的会话并把它整条归档,返回 (sessionID, mailID)。
func seedArchivedSession(t *testing.T) (uuid.UUID, uuid.UUID) {
t.Helper()
ctx := context.Background()
id := seedMailTo(t, "bob", "")
var sid uuid.UUID
if err := db.DB.QueryRowContext(ctx,
`SELECT session_id FROM mails WHERE mail_id = $1`, id).Scan(&sid); err != nil {
t.Fatal(err)
}
if err := ArchiveSession(ctx, sid); err != nil {
t.Fatal(err)
}
return sid, id
}
// TouchSession 不得解除归档 —— 这是半活会话的**唯一**成因。
func TestTouchSessionDoesNotUnarchive(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := seedArchivedSession(t)
if err := TouchSession(ctx, sid); err != nil {
t.Fatal(err)
}
if got := sessionStatusOf(t, sid); got != "archived" {
t.Fatalf("★ TouchSession 把已归档会话改成了 %q —— 它只该刷 updated_at。\n"+
" 这一格是全库唯一能写 sessions.status 的地方(除 ArchiveSession),\n"+
" 它一旦能写 status',reply_to / permission / adopt 三个入口就都能复活归档会话。", got)
}
assertNoDivergence(t)
}
// 归档后不能有**任何**新邮件落进去 —— 落进去的那封必是 mails.status='unread'
// (DEFAULT),于是归档线索长出未读信,而 ListInbox 又因为 s.status 放它出来。
func TestArchivedSessionRefusesNewMail(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := seedArchivedSession(t)
if _, err := CreateMail(ctx, sid, nil, "sender", "", "bob", "", "s", "b", nil); !errors.Is(err, ErrSessionArchived) {
t.Fatalf("CreateMail 落进已归档会话时 err=%v,期望 ErrSessionArchived", err)
}
if _, err := CreatePermissionMail(ctx, sid, "sender", "bob", "q", "b", []string{"同意"}, "permission", false); !errors.Is(err, ErrSessionArchived) {
t.Fatalf("CreatePermissionMail 落进已归档会话时 err=%v,期望 ErrSessionArchived", err)
}
if _, err := CreateDecisionMail(ctx, sid, uuid.Nil, "bob", "sender", "同意", ""); !errors.Is(err, ErrSessionArchived) {
t.Fatalf("CreateDecisionMail 落进已归档会话时 err=%v,期望 ErrSessionArchived", err)
}
assertNoDivergence(t)
}
// 守卫必须是 ErrSessionArchived 而不是 ErrSessionNotFound:调用方要能分开回话。
// 「已归档」可以 `.new` 另起一条;「不存在」要先确认地址写没写错。
func TestArchivedIsDistinctFromMissing(t *testing.T) {
setupTestDB(t)
sid, _ := seedArchivedSession(t)
if err := EnsureSessionOpen(context.Background(), sid); !errors.Is(err, ErrSessionArchived) {
t.Fatalf("已归档会话的读数是 %v,期望 ErrSessionArchived(与 NotFound 分列)", err)
}
if err := EnsureSessionOpen(context.Background(), uuid.New()); !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("不存在会话的读数是 %v,期望 ErrSessionNotFound", err)
}
}
// SessionOpenFor 补上写路径此前一个都不查的**参与方**判据。
func TestSessionOpenForRejectsForeignSession(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := seedArchivedSession(t)
// "bob" 是收件人,参与过 → 但会话已归档,先被归档这一格挡住
if err := SessionOpenFor(ctx, "bob", sid); !errors.Is(err, ErrSessionArchived) {
t.Fatalf("参与者对已归档会话的读数是 %v,期望 ErrSessionArchived", err)
}
// 解除归档后(模拟人先归档又改主意),参与者应通过
if _, err := db.DB.ExecContext(ctx,
`UPDATE sessions SET status='active' WHERE session_id = $1`, sid); err != nil {
t.Fatal(err)
}
if err := SessionOpenFor(ctx, "bob", sid); err != nil {
t.Fatalf("参与者 bob 应可向未归档会话发信,读数 %v", err)
}
// 没参与过的第三方:这条线是他没参与的线索
if err := SessionOpenFor(ctx, "stranger", sid); !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("未参与者 stranger 的读数是 %v,期望 ErrSessionNotFound(携带别人的 id 不得注入邮件)", err)
}
// from_agent 也是一方:它发起的线索自己当然能继续
if err := SessionOpenFor(ctx, "sender", sid); err != nil {
t.Fatalf("会话发起方 sender 应可发信,读数 %v", err)
}
}
// 归档会话在读侧仍然对所有人不可见 —— 与 readstate_test.go 的既有判据同源,
// 这里重跑一遍是为了让「写侧堵死」不会悄悄改掉「读侧仍成立」的前提。
func TestArchivedStaysInvisibleInBothTables(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
_, id := seedArchivedSession(t)
if n, _ := CountUnread(ctx, "bob", ""); n != 0 {
t.Fatalf("已归档会话的邮件不该计入未读(mails.status 判据),实际 %d", n)
}
if hasID(unreadList(t, "bob"), id) {
t.Fatal("已归档会话的邮件不该出现在 unread 收件箱")
}
mails, err := ListInbox(ctx, "bob", "all", "", 50)
if err != nil {
t.Fatal(err)
}
for _, m := range mails {
if m.ID == id {
t.Fatal("已归档会话的邮件不该出现在收件箱(all 也不该有)—— sessions.status 判据被绕过了")
}
}
assertNoDivergence(t)
}
// 权限决策同样不得把归档邮件改回 read —— 与 MarkMailRead 同一族的反向写入。
//
// `DecidePermission`(repo.go:DecidePermission)那句
// `UPDATE mails SET permission_result=$1, status='read' WHERE mail_id=$2`
// 与 MarkMailRead 只差一个字段,守卫也该同形:只堵 MarkMailRead 一处,
// 权限决策这条路仍能把归档邮件的语义在行级抹掉(pi 2026-09-28 指出)。
func TestDecidePermissionDoesNotUnarchiveMail(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
_, id := seedArchivedSession(t)
if _, err := db.DB.ExecContext(ctx,
`INSERT INTO permission_requests (mail_id, session_id, agent_name, question, options, kind)
SELECT mail_id, session_id, 'sender', 'q', '["同意"]', 'permission' FROM mails WHERE mail_id = $1`,
id); err != nil {
t.Fatal(err)
}
if _, err := DecidePermission(ctx, id, "bob", "同意"); err != nil {
t.Fatalf("DecidePermission 读数 %v", err)
}
var mstat string
if err := db.DB.QueryRowContext(ctx,
`SELECT status FROM mails WHERE mail_id = $1`, id).Scan(&mstat); err != nil {
t.Fatal(err)
}
if mstat != "archived" {
t.Fatalf("★ DecidePermission 把已归档邮件改成了 %q —— 与 MarkMailRead 同一个洞,只堵一半会让归档语义仍能被抹掉", mstat)
}
// 决策事实**必须**记下来:守卫只挡派生列,不挡事实(见 DecidePermission 的注释)
var n int
if err := db.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM mail_reads WHERE mail_id = $1 AND reader_name = 'bob'`, id).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("决策人 bob 的已读事实没记进 mail_reads(实际 %d 行)—— 守卫误伤了事实表", n)
}
assertNoDivergence(t)
}
// 归档**之后**再走一遍 TouchSession(投递路径的唯一副作用)也不该分叉。
// 单测按「入口」写,这条按「时序」写:先归档、后投递,是线上真实发生的顺序。
func TestArchiveAfterDeliveryKeepsTablesTogether(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := seedArchivedSession(t)
// 归档后再投递三次:别名命中、TouchSession、CreateMail 三步都得被拒
for i := 0; i < 3; i++ {
_ = TouchSession(ctx, sid)
_, _ = CreateMail(ctx, sid, nil, "sender", "", "bob", "", "s", "b", nil)
}
assertNoDivergence(t)
if got := sessionStatusOf(t, sid); got != "archived" {
t.Fatalf("重复投递把会话状态推成了 %q,期望仍是 archived", got)
}
}
// 标已读不得把归档邮件改回 read —— 与 TestTouchSessionDoesNotUnarchive 对称。
//
// ★ 这条是 pi 2026-09-28 复核 e78888b 时指出的**反向**洞:EnsureSessionOpen 堵住了
// 「往归档会话里建邮件」(会话 active?/邮件行不该非 archived),但
// `MarkMailRead`(repo.go:MarkMailRead)那句 `UPDATE mails SET status='read'
// WHERE mail_id=$1` **不查 status**,于是
//
// POST /api/v1/mail/{id}/read(对归档会话里的一封)
// → GetMailByID(无 status 过滤)→ UserCanAccessSession(只查参与方)
// → UPDATE ⇒ mails.status: archived → read,而 sessions.status 仍是 archived
//
// 实测读数:`archived → read`(会话仍 archived)。此时 `readStateFor` 返回
// 'read' 而非 'archived' ⇒ **归档语义在行级被抹掉**,正是本文件要禁止的分叉。
//
// 同族的批量路径 `MarkAllInboxRead` **本来就有**守卫
// (`session_id IN (SELECT ... WHERE status <> 'archived')`,markread_test.go:126
// 断言了它)—— 所以缺的只有单封这一处,这也说明它不是"设计如此"而是漏网。
func TestMarkReadDoesNotUnarchiveMail(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
_, id := seedArchivedSession(t)
if err := MarkMailRead(ctx, id, "bob"); err != nil {
t.Fatalf("MarkMailRead 读数 %v(对归档邮件标已读应当被拒或无害)", err)
}
var mstat string
if err := db.DB.QueryRowContext(ctx,
`SELECT status FROM mails WHERE mail_id = $1`, id).Scan(&mstat); err != nil {
t.Fatal(err)
}
if mstat != "archived" {
t.Fatalf("★ MarkMailRead 把已归档邮件改成了 %q —— 归档是全局属性,标已读不该抹掉它。\n"+
" sessions.status 仍是 archived 而这一行变成 %q,两表分叉(readStateFor 会返回 %q\n"+
" 而不是 'archived',归档语义在行级被抹掉)。\n"+
" 批量那条 MarkAllInboxRead 有 `session_id IN (... status <> 'archived')` 守卫,\n"+
" 单封这条漏了 —— 对称的洞,别只堵一半。", mstat, mstat, mstat)
}
assertNoDivergence(t)
}