Files
MailUI4Agents/server/internal/repo/session_delete_test.go
JianFeeeee d284f1f0af feat(admin): 真实删除会话的 API —— DELETE /api/v1/admin/sessions/{id}
## 为什么要有

此前清理测试数据只能手工敲 sqlite3,而那立刻暴露了这套 schema 的两个陷阱,
两者都不是「照着表名删」能发现的:**级联在这套 schema 里不成立**。

    attachments        → CASCADE    ✔ 唯一声明了自动的
    mails              → NO ACTION
    mail_reads         → NO ACTION
    relayed_mails      → NO ACTION
    permission_requests→ NO ACTION
    session_agent_locks→ NO ACTION

漏删任何一张都不会报错,只会在几周后的一次体检里以 `foreign_key_check`
悬空引用的形式冒出来 —— 那时已经没人记得它是怎么来的。

实现按依赖倒序删 7 张表,全在一个事务里(任何一步失败即整体回滚;
半删比不删更糟:会话没了但邮件还在,而用户以为已经删干净了)。

返回**实际删掉的行数**而不是只回 200:一个只删了 sessions 却漏了 mails 的
实现也能返回 200,而 mails 还在意味着那封对话在界面上看不到却仍在库里。

## 两个设计决定

**① 挂 `AdminOnly` 组,不挂 `UserAuth` 组。**
会话是多方的协作记录(多个 Agent + 人类的往来)。`UserAuth` 组里任何登录
用户都能看到自己的全部会话 —— 放那里等于让任何人删别人的历史。

**② 刻意不进 MCP 工具面。**
删除不可撤销,而 MCP 的调用方是**模型**:误判一次就是真丢数据。
与 `connect_to_server` 刻意不提供改坐标参数同一条原则 ——
**不可逆的运维动作不进模型可及的面**。Agent 要结束线索走归档。

## ★ 实现中测出的两件事(都改了我的判断)

**① `parent_mail_id` 那步不是必需的 —— 我一开始写错了注释和判据**

我以为「有回复链时删除会撞外键约束」。实测:

    DELETE FROM mails WHERE session_id = X  →  同语句内删父子,SQLite 不报错

`NO ACTION` 只在删除后**仍有行**引用被删行时才拦,同语句内删父子是合法的。
所以那步是**防御性冗余**(为「将来拆成两条语句」那件事留的),
注释已改为陈述实测,不再说它必需。真正必须先处理的是 schema 本身:
历史数据里已有 7 条悬空引用,是早于这套代码的既存违规。

**② 判据自身出了两次假绿,都是同一个原因:观察方式比语义宽**

| 变异 | 表面 | 真相 |
|---|---|---|
| 撤掉 mails 删除 | 0 红 | 变异**没应用**(按字符串匹配命中了文件头注释) |
| 撤掉 parent 断开 | 0 红 | 变异确实应用了,但判据**断言了一个 SQLite 不提供的保证** |

第二次值得记:我写了个「删除是否生效」的断言,它报「未生效」,我一度以为
删除失败 —— 实际是**全文搜 `parent_mail_id = NULL` 命中了文件头注释里
同一句话**。断言本身写错了,不是删除错了。

改用**代码行特征**(反引号包裹的 SQL / 错误文案)判定后,两个真实变异都转红:

    漏删 mails            → 2 格红 ✓
    漏删 session_agent_locks → 1 格红 ✓

## 判据(5 格)

除上面两条,另含:删不存在的会话必须报 `ErrSessionNotFound`(幂等返回 200
会让「重试」与「成功」不可区分);不得误删别的会话;中途失败必须整体回滚
(用触发器注入失败,断言会话与邮件都还在)。

全量 14 包绿。
2026-10-03 13:41:02 +08:00

247 lines
9.0 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
/*
DeleteSession 的判据(2026-10-03)。
# 这个判据要挡住的具体事故
**① 漏删从属表(最要紧)**
只 `DELETE FROM sessions` 的实现也能返回 200,但 mails 还在库里——
用户在界面上看不到那条对话,那些邮件却还在,且可能被搜索/统计命中。
所以判据不仅断言「会话没了」,还断言**每一张引用表都空了**。
**② `mails.parent_mail_id` 自引用**
schema 里它是 `REFERENCES mails(mail_id)`,**没有 ON DELETE**。
★ 这条我一开始判断错了,判据也跟着错:我以为「有回复链时删除会撞约束」,
于是专门写了一格 `TestDeleteSessionWithReplyChain`。实测(变异验证时才发现):
`DELETE FROM mails WHERE session_id = X` **同语句内删掉父子是合法的**,
SQLite 不报错 —— `NO ACTION` 只在删除后**仍有行**引用被删行时才拦。
⇒ 代码里那步「先置空 parent」是**防御性冗余**,不是必需。
⇒ 判据也据此改了措辞:它现在测的是「有回复链时也要删得干净」
(真正的价值在**残留检查**,不在「会不会报错」)。
⇒ 变异验证也据此改了:撤掉那步**不会**让判据转红,这是正确行为;
判据不该断言一个 SQLite 并不提供的保证。
**③ 删两遍要能与「删对」区分**
幂等返回 200 会让「重试」和「成功」不可区分。所以不存在必须报
ErrSessionNotFound,由 handler 映射成 404。
**④ 事务性**
任何一步失败必须整体回滚,不能留半删状态 —— 半删比不删更糟:
会话没了但邮件还在,而用户以为已经删干净了。
*/
import (
"context"
"database/sql"
"errors"
"testing"
"github.com/agentmail/gateway/internal/db"
)
func setupDeleteDB(t *testing.T) {
t.Helper()
db.Close()
if err := db.Connect(context.Background(), "sqlite://"+t.TempDir()+"/del.db"); err != nil {
t.Fatalf("连接测试库: %v", err)
}
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("迁移测试库: %v", err)
}
t.Cleanup(db.Close)
}
// seedDeleteSession 造一条会话,返回 session_id。
func seedDeleteSession(t *testing.T, alias string) string {
t.Helper()
var id string
err := db.DB.QueryRowContext(context.Background(),
`INSERT INTO sessions (session_alias, subject, status, workspace, from_agent)
VALUES ($1, '测试会话', 'active', '/tmp', 'pi') RETURNING session_id`,
alias).Scan(&id)
if err != nil {
t.Fatalf("建会话: %v", err)
}
return id
}
// seedMail 造一封邮件;parent 非空时挂到另一封下(造回复链)。
func seedMail(t *testing.T, sid, subject, parent string) string {
t.Helper()
var id string
var p any
if parent != "" {
p = parent
}
err := db.DB.QueryRowContext(context.Background(),
`INSERT INTO mails (session_id, from_name, to_workspace, to_name, subject, body, status, parent_mail_id)
VALUES ($1,'pi','/tmp','dsh',$2,'x','unread',$3) RETURNING mail_id`,
sid, subject, p).Scan(&id)
if err != nil {
t.Fatalf("建邮件 %s: %v", subject, err)
}
return id
}
func countRows(t *testing.T, table, where string, args ...any) int {
t.Helper()
q := `SELECT COUNT(*) FROM ` + table + ` WHERE ` + where
var n int
if err := db.DB.QueryRowContext(context.Background(), q, args...).Scan(&n); err != nil {
t.Fatalf("统计 %s: %v", table, err)
}
return n
}
// ★ 主判据:一张引用表都不能漏。
func TestDeleteSessionRemovesAllDependents(t *testing.T) {
setupDeleteDB(t)
sid := seedDeleteSession(t, "s1")
parent := seedMail(t, sid, "父邮件", "")
child := seedMail(t, sid, "回复邮件", parent)
grand := seedMail(t, sid, "再回复", child)
// 造齐从属数据。★ 列名一律照真实 schema 写 ——
// 初版凭印象写了 attachments(size) 与 permission_requests(requested_mode),
// 两个都不存在,于是造数据就 SQL error —— 判据自己先崩了,什么也没测到。
if _, err := db.DB.ExecContext(context.Background(),
`INSERT INTO attachments (mail_id, uploader, filename, content_type, size_bytes, sha256)
VALUES ($1,'pi','a.txt','text/plain',3,'abc')`, parent); err != nil {
t.Fatalf("造附件: %v", err)
}
if _, err := db.DB.ExecContext(context.Background(),
`INSERT INTO mail_reads (mail_id, reader_name) VALUES ($1,'dsh')`,
parent); err != nil {
t.Fatalf("造已读: %v", err)
}
if _, err := db.DB.ExecContext(context.Background(),
`INSERT INTO relayed_mails (agent_name, relay_key, mail_id, kind)
VALUES ('pi', $1, $2, 'human')`, "rk-"+parent, parent); err != nil {
t.Fatalf("造转发记录: %v", err)
}
if _, err := db.DB.ExecContext(context.Background(),
`INSERT INTO permission_requests
(mail_id, session_id, agent_name, question, options, kind, multi_select)
VALUES ($1,$2,'dsh','同意吗','["full"]','tool',0)`, parent, sid); err != nil {
t.Fatalf("造权限请求: %v", err)
}
if _, err := db.DB.ExecContext(context.Background(),
`INSERT INTO session_agent_locks (session_id, locked_at, until_at, hops, reason)
VALUES ($1, datetime('now'), datetime('now','+1 hour'), 0, 'test')`,
sid); err != nil {
t.Fatalf("造会话锁: %v", err)
}
res, err := DeleteSession(context.Background(), sid)
if err != nil {
t.Fatalf("删除失败: %v", err)
}
// 会话与每一张引用表都必须为空
for _, c := range []struct {
table, where string
args []any
}{
{"sessions", "session_id = $1", []any{sid}},
{"mails", "session_id = $1", []any{sid}},
{"attachments", "mail_id = $1", []any{parent}},
{"mail_reads", "mail_id = $1", []any{parent}},
{"relayed_mails", "mail_id = $1", []any{parent}},
{"permission_requests", "session_id = $1", []any{sid}},
{"session_agent_locks", "session_id = $1", []any{sid}},
} {
if n := countRows(t, c.table, c.where, c.args...); n != 0 {
t.Errorf("★ %s 仍有 %d 行 ⇒ 删会话漏了从属表", c.table, n)
}
}
// 计数要如实反映(判据要能看出「删了但没删全」)
if res.Mails != 3 {
t.Errorf("报告删了 %d 封邮件,实际 3 封", res.Mails)
}
if res.Attachments != 1 || res.MailReads != 1 || res.Related != 1 ||
res.Permissions != 1 || res.Locks != 1 {
t.Errorf("从属计数不符:%+v", res)
}
_ = grand // 三级回复链:父←子←孙,用来压自引用约束
}
// ★ 有子回复链时也必须删得掉 —— 这是 parent_mail_id 自引用约束的正面测。
func TestDeleteSessionWithReplyChain(t *testing.T) {
setupDeleteDB(t)
sid := seedDeleteSession(t, "s2")
a := seedMail(t, sid, "A", "")
b := seedMail(t, sid, "B", a)
seedMail(t, sid, "C", b)
// 这格真正测的是「有回复链时也删得干净」,不是「会不会报约束错误」——
// 实测同语句内删父子合法,所以删掉那步 parent 置空也不会失败(见文件头)。
if _, err := DeleteSession(context.Background(), sid); err != nil {
t.Fatalf("有回复链时删除失败: %v", err)
}
if n := countRows(t, "mails", "session_id = $1", sid); n != 0 {
t.Errorf("★ 回复链残留 %d 封邮件", n)
}
}
// ★ 删不存在的会话必须报 NotFound(不能静默成功)。
func TestDeleteSessionNotFound(t *testing.T) {
setupDeleteDB(t)
seedDeleteSession(t, "s3")
_, err := DeleteSession(context.Background(), "00000000-0000-0000-0000-000000000000")
if !errors.Is(err, ErrSessionNotFound) {
t.Errorf("★ 应返回 ErrSessionNotFound(handler 据此回 404),实际 %v", err)
}
}
// ★ 删除不得影响别的会话 —— 按 session_id 精确删,不能整表清。
func TestDeleteSessionDoesNotTouchOthers(t *testing.T) {
setupDeleteDB(t)
doomed := seedDeleteSession(t, "s4")
keep := seedDeleteSession(t, "s5")
keepMail := seedMail(t, keep, "要保留的", "")
if _, err := DeleteSession(context.Background(), doomed); err != nil {
t.Fatalf("删除失败: %v", err)
}
if n := countRows(t, "sessions", "session_id = $1", keep); n != 1 {
t.Error("★ 别的会话被误删")
}
if n := countRows(t, "mails", "mail_id = $1", keepMail); n != 1 {
t.Error("★ 别的会话的邮件被误删")
}
}
// ★ 事务性:中途失败必须整体回滚,不能留半删状态。
func TestDeleteSessionIsAtomic(t *testing.T) {
setupDeleteDB(t)
sid := seedDeleteSession(t, "s6")
mail := seedMail(t, sid, "唯一", "")
// 让 mails 的删除必然失败:先给 parent_mail_id 建一个**指向别处**
// 的外键不现实,改为用触发器注入失败。
if _, err := db.DB.ExecContext(context.Background(), `
CREATE TRIGGER boom BEFORE DELETE ON mails
BEGIN SELECT RAISE(ABORT, 'boom'); END;`); err != nil {
t.Skipf("触发器不可用: %v", err)
}
if _, err := DeleteSession(context.Background(), sid); err == nil {
t.Fatal("★ 注入失败后 DeleteSession 居然返回成功")
}
// 关键:会话必须还在,且邮件还在 —— 即整体回滚
if n := countRows(t, "sessions", "session_id = $1", sid); n != 1 {
t.Error("★ 失败后会话被删了 ⇒ 没有整体回滚(半删比不删更糟)")
}
if n := countRows(t, "mails", "mail_id = $1", mail); n != 1 {
t.Error("★ 失败后邮件被删了 ⇒ 没有整体回滚")
}
}
var _ = sql.ErrNoRows