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,非本轮引入。
This commit is contained in:
@ -384,7 +384,12 @@ func DecidePermission(w http.ResponseWriter, r *http.Request) {
|
||||
// 归档检查必须在 DecidePermission **之前**:否则决策已落库、回执邮件却因为
|
||||
// 会话已归档建不出来 —— 发起方那边永远等不到回执(任务挂死),
|
||||
// 而人在界面上看到的却是"已处理"。宁可不决策,让人看见失败。
|
||||
if err := repo.SessionOpenFor(r.Context(), user.Username, perm.SessionID); err != nil {
|
||||
//
|
||||
// 这里刻意用 `EnsureSessionOpen`(只判存在+未归档)而不是
|
||||
// `SessionOpenFor`(多一格参与方):上面刚放行本请求的是
|
||||
// 「该邮件收件人本人 **或** 管理员」,而管理员**本来就可以**给任何线索做决策
|
||||
// —— 那不是越权。再叠一格参与方会把管理员挡在门外。
|
||||
if err := repo.EnsureSessionOpen(r.Context(), perm.SessionID); err != nil {
|
||||
if errors.Is(err, repo.ErrSessionArchived) {
|
||||
Error(w, http.StatusNotFound, "该会话已归档,无法回执决策")
|
||||
} else {
|
||||
|
||||
@ -1131,9 +1131,17 @@ func DecidePermission(ctx context.Context, mailID uuid.UUID, decider, decision s
|
||||
}
|
||||
json.Unmarshal(optsJSON, &pr.Options)
|
||||
|
||||
// Also update the mail
|
||||
// 冗余列**不得覆盖 archived**,与 MarkMailRead 同一处守卫
|
||||
// (pi 2026-09-28 指出:这里 `status='read'` 无 archived 守卫是同族反向洞,
|
||||
// 只堵 MarkMailRead 一处等于堵一半)。
|
||||
//
|
||||
// 下面那条 `INSERT INTO mail_reads` 同样**不加**守卫,理由与 MarkMailRead 一致:
|
||||
// `mail_reads` 是**事实表**(谁读过,不可撤销),行级那列是**派生列**
|
||||
// (全局可见性,可重算)。一起挡会让「谁读过」变得不可审计 ——
|
||||
// 而「事实与派生分居两表」正是这轮反复出现的坑。
|
||||
_, _ = db.DB.ExecContext(context.Background(),
|
||||
`UPDATE mails SET permission_result = $1, status = 'read' WHERE mail_id = $2`,
|
||||
`UPDATE mails SET permission_result = $1, status = 'read'
|
||||
WHERE mail_id = $2 AND status <> 'archived'`,
|
||||
decision, mailID)
|
||||
if decider != "" {
|
||||
// 记到决策人名下(按读者记,见 repo.markReadFor)
|
||||
|
||||
@ -210,6 +210,47 @@ func TestArchivedStaysInvisibleInBothTables(t *testing.T) {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user