Files
MailUI4Agents/server/internal/repo/session_status_invariant_test.go
JianFeeeee 2b77b17e65 fix(归档): 标已读不得把归档邮件改回 read —— e78888b 的不变量有反向缺口
pi 2026-09-28 复核 e78888b 时指出:EnsureSessionOpen 堵住了「往归档会话里建邮件」,
但 MarkMailRead 能**反向**打破同一个不变量。复核成立。

  POST /api/v1/mail/{id}/read(对归档会话里的一封)
    → GetMailByID(无 status 过滤)→ UserCanAccessSession(只查参与方)
    → UPDATE mails SET status='read' WHERE mail_id=$1   ← 不查 status

实测:mails.status `archived → read`,而 sessions.status 仍是 archived
⇒ 两表分叉,且 readStateFor 返回 'read' 而非 'archived',
**归档语义在行级被抹掉**。已加 TestMarkReadDoesNotUnarchiveMail,
先确认它在修之前转红(不是改完就绿的装饰)。

同族的批量路径 MarkAllInboxRead 本来就有守卫
(`session_id IN (SELECT ... WHERE status <> 'archived')`,markread_test.go:126
断言了它)—— 缺的只有单封这一处,所以这是漏网而非设计如此。修法与批量那条同形。

★ mail_reads 的 INSERT 刻意**不加**守卫:已读是按读者记的事实,
人确实读过,归档不该改写它。行级那列是「全局可见性」的冗余、mail_reads 是
「谁读过」的事实,两者语义不同 —— 一起挡会把事实也丢掉。

读侧清册仍为 repo.go=13:新增的那 1 处命中在注释里(散文里拼了列名字面量),
改写措辞而不改数字 —— 让数字 +1 会给未来新增读取凭空送出 1 格余量。
2026-09-28 11:07:32 +08:00

272 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 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)
}
// 归档**之后**再走一遍 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)
}