## 为什么要有
此前清理测试数据只能手工敲 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 包绿。
247 lines
9.0 KiB
Go
247 lines
9.0 KiB
Go
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
|