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) }