Smail/test/review/imap_findings_test.go
smail 6f79441585 修复审查发现的认证配置邮件协议及前端问题
补齐安全运行时清理、配置候选恢复、泛域名证书事务提交、邮件副本与协议边界、插件隐私和安装回滚回归,并更新修复状态及待验收清单。
2026-09-06 13:02:02 +08:00

412 lines
16 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 imap_server
import (
"reflect"
"testing"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/group"
"git.pcux.cn/xuans/Smail/services/list"
"git.pcux.cn/xuans/Smail/utils/context"
"github.com/emersion/go-imap/v2"
)
func TestAuditLargeSelectedMailboxMustBatchUIDQueries(t *testing.T) {
user := models.User{Account: t.Name()}
if _, err := db.Instance.Insert(&user); err != nil {
t.Fatal(err)
}
message := models.Email{Subject: t.Name()}
if _, err := db.Instance.Insert(&message); err != nil {
t.Fatal(err)
}
const count = 10001
uids := make([]int, 0, count)
deleted := make(map[int]bool)
// 独立账户及共享邮件内容限制夹具体积,同时保留每个副本的真实 UID。
for start := 0; start < count; start += 400 {
batch := make([]models.UserEmail, min(400, count-start))
for index := range batch {
batch[index] = models.UserEmail{UserID: user.ID, EmailID: message.Id}
if position := start + index; position == 0 || position == 500 || position == count-1 {
batch[index].IsDeleted = 1
}
}
if _, err := db.Instance.Insert(&batch); err != nil {
t.Fatal(err)
}
}
relations, err := list.GetUEListByUID(&context.Context{UserID: user.ID}, "INBOX", 0, 0, nil)
if err != nil || len(relations) != count {
t.Fatalf("大邮箱夹具不完整:count=%d err=%v", len(relations), err)
}
for _, relation := range relations {
uids = append(uids, relation.ID)
if relation.IsDeleted == 1 {
deleted[relation.ID] = true
}
}
session := &serverSession{ctx: &context.Context{UserID: user.ID}, currentMailbox: "INBOX", mailboxUIDs: uids}
for _, numbers := range []imap.NumSet{
imap.SeqSet{{Start: 1, Stop: 0}},
imap.UIDSet{{Start: imap.UID(uids[0]), Stop: 0}},
} {
rows, err := session.selectedMessages(numbers)
if err != nil || len(rows) != count {
t.Fatalf("范围读取必须分批保留全部副本:count=%d err=%v", len(rows), err)
}
for index, row := range rows {
if row.UeId != uids[index] || row.SerialNumber != index+1 {
t.Fatalf("分批读取改变了 UID 或会话序号:index=%d uid=%d sequence=%d", index, row.UeId, row.SerialNumber)
}
}
}
if err := session.Expunge(nil, nil); err != nil {
t.Fatalf("大邮箱 EXPUNGE 不能被查询参数上限拒绝:%v", err)
}
if len(session.mailboxUIDs) != count-len(deleted) {
t.Fatalf("删除后快照数量错误:%d", len(session.mailboxUIDs))
}
for _, uid := range session.mailboxUIDs {
if deleted[uid] {
t.Fatalf("已清除的 UID 仍留在会话快照:%d", uid)
}
}
remaining, err := db.Instance.Where("user_id = ?", user.ID).Count(&models.UserEmail{})
if err != nil || remaining != int64(count-len(deleted)) {
t.Fatalf("大邮箱只能清除标记邮件:remaining=%d err=%v", remaining, err)
}
}
func TestAuditSnapshotSearchAndExpungeBoundaries(t *testing.T) {
first, last := auditIMAPMessage(t), auditIMAPMessage(t)
ctx := &context.Context{UserID: first.UserID}
snapshot := []int{first.ID, last.ID}
starUID := imap.UIDSet{{Start: 0, Stop: 0}}
starSequence := imap.SeqSet{{Start: 0, Stop: 0}}
for _, criteria := range []*imap.SearchCriteria{
{UID: []imap.UIDSet{starUID}},
{SeqNum: []imap.SeqSet{starSequence}},
} {
rows, err := list.SearchEmails(ctx, "INBOX", criteria, snapshot)
if err != nil || len(rows) != 1 || rows[0].ID != last.ID {
t.Fatalf("通配应选择快照最后一封:rows=%v err=%v", rows, err)
}
}
rows, err := list.SearchEmails(ctx, "INBOX", &imap.SearchCriteria{
UID: []imap.UIDSet{imap.UIDSetNum(imap.UID(first.ID))}, SeqNum: []imap.SeqSet{starSequence},
}, snapshot)
if err != nil || len(rows) != 0 {
t.Fatalf("UID 筛选不能缩小序号通配上界:rows=%v err=%v", rows, err)
}
session := &serverSession{ctx: ctx, currentMailbox: "INBOX", mailboxUIDs: append([]int{}, snapshot...)}
if _, err := db.Instance.ID(last.ID).Delete(&models.UserEmail{}); err != nil {
t.Fatal(err)
}
for _, criteria := range []*imap.SearchCriteria{
{UID: []imap.UIDSet{starUID}}, {SeqNum: []imap.SeqSet{starSequence}},
{Or: [][2]imap.SearchCriteria{{{UID: []imap.UIDSet{starUID}}, {SeqNum: []imap.SeqSet{starSequence}}}}},
} {
rows, err := list.SearchEmails(ctx, "INBOX", criteria, snapshot)
if err != nil || len(rows) != 0 {
t.Fatalf("未通知删除不能把通配目标前移:rows=%v err=%v", rows, err)
}
}
rows, err = list.SearchEmails(ctx, "INBOX", &imap.SearchCriteria{Not: []imap.SearchCriteria{{UID: []imap.UIDSet{starUID}}}}, snapshot)
if err != nil || len(rows) != 1 || rows[0].ID != first.ID {
t.Fatalf("NOT 必须使用同一快照:rows=%v err=%v", rows, err)
}
selected, err := session.selectedMessages(imap.SeqSetNum(2))
if err != nil || len(selected) != 0 {
t.Fatalf("FETCH/STORE 共用目标解析不能改变已删除序号:rows=%v err=%v", selected, err)
}
if _, err := db.Instance.ID(first.ID).Cols("is_deleted").Update(&models.UserEmail{IsDeleted: 1}); err != nil {
t.Fatal(err)
}
empty := imap.UIDSet{}
for _, set := range []*imap.UIDSet{&empty, &starUID} {
if err := session.Expunge(nil, set); err != nil {
t.Fatal(err)
}
if found, err := db.Instance.ID(first.ID).Exist(&models.UserEmail{}); err != nil || !found {
t.Fatalf("空集合或尾部通配不得删除其他邮件:found=%v err=%v", found, err)
}
}
// 极大反向区间按快照过滤,不能按数值展开或错误拒绝。
wide := imap.UIDSet{{Start: imap.UID(^uint32(0)), Stop: 1}}
if err := session.Expunge(nil, &wide); err != nil {
t.Fatal(err)
}
if found, err := db.Instance.ID(first.ID).Exist(&models.UserEmail{}); err != nil || found {
t.Fatalf("反向范围应删除匹配标记:found=%v err=%v", found, err)
}
if !reflect.DeepEqual(session.mailboxUIDs, []int{last.ID}) {
t.Fatalf("只移除已经通知删除的序号:%v", session.mailboxUIDs)
}
}
// 使用现有 TestMain 创建的临时 SQLite 和随机本地 TLS 端口,不连接开发服务。
func TestAuditCopyThenMoveMustKeepMailboxIdentity(t *testing.T) {
var user models.User
if found, err := db.Instance.Where("account=?", "testCase").Get(&user); err != nil || !found {
t.Fatalf("临时测试账户不可用: found=%v err=%v", found, err)
}
message := models.Email{Subject: "audit-copy-move"}
if _, err := db.Instance.Insert(&message); err != nil {
t.Fatal(err)
}
relation := models.UserEmail{UserID: user.ID, EmailID: message.Id, Status: 0}
if _, err := db.Instance.Insert(&relation); err != nil {
t.Fatal(err)
}
if _, err := clientLogin.Select("INBOX", nil).Wait(); err != nil {
t.Fatal(err)
}
uid := imap.UIDSetNum(imap.UID(relation.ID))
if _, err := clientLogin.Copy(uid, "Junk").Wait(); err != nil {
t.Fatalf("复制前置条件失败: %v", err)
}
count, err := db.Instance.Where("user_id=? AND email_id=?", user.ID, message.Id).Count(&models.UserEmail{})
if err != nil || count != 2 {
t.Fatalf("未生成预期副本: count=%d err=%v", count, err)
}
// 当前服务器未声明 MOVE 扩展,客户端会回退 COPY/STORE/EXPUNGE。
// 另外验证网页移动所用的服务,避免把回退成功误判为原生 MOVE 已通过。
webMoveErr := group.Move2DefaultBox(&context.Context{UserID: user.ID}, []int{message.Id}, "Deleted Messages")
t.Logf("web_move_after_imap_copy_error=%v", webMoveErr)
if webMoveErr != nil {
t.Errorf("IMAP 复制后网页移动不应把自己的邮件判断为不属于自己: %v", webMoveErr)
}
// 网页移动已经移走原 UID;另建独立样本验证客户端回退,不能重复移动旧 UID。
moveRelation := auditIMAPMessage(t)
if _, err := clientLogin.Select("INBOX", nil).Wait(); err != nil {
t.Fatal(err)
}
_, err = clientLogin.Move(imap.UIDSetNum(imap.UID(moveRelation.ID)), "Deleted Messages").Wait()
t.Logf("copy_relation_count=%d move_error=%v", count, err)
if err != nil {
t.Fatalf("同一用户复制邮件后,移动原文件夹中的 UID 不应失败: %v", err)
}
}
func auditIMAPMessage(t *testing.T) models.UserEmail {
t.Helper()
var user models.User
if found, err := db.Instance.Where("account=?", "testCase").Get(&user); err != nil || !found {
t.Fatalf("测试账户不可用: found=%v err=%v", found, err)
}
message := models.Email{Subject: t.Name()}
if _, err := db.Instance.Insert(&message); err != nil {
t.Fatal(err)
}
relation := models.UserEmail{UserID: user.ID, EmailID: message.Id, Status: 0}
if _, err := db.Instance.Insert(&relation); err != nil {
t.Fatal(err)
}
return relation
}
func TestAuditExamineMustNotPersistStore(t *testing.T) {
relation := auditIMAPMessage(t)
if _, err := clientLogin.Select("INBOX", &imap.SelectOptions{ReadOnly: true}).Wait(); err != nil {
t.Fatal(err)
}
err := clientLogin.Store(imap.UIDSetNum(imap.UID(relation.ID)), &imap.StoreFlags{
Op: imap.StoreFlagsAdd, Flags: []imap.Flag{imap.FlagSeen}, Silent: true,
}, nil).Close()
var updated models.UserEmail
if found, lookupErr := db.Instance.ID(relation.ID).Get(&updated); lookupErr != nil || !found {
t.Fatalf("读取测试邮件失败: found=%v err=%v", found, lookupErr)
}
t.Logf("readonly=true store_error=%v persisted_is_read=%d", err, updated.IsRead)
if updated.IsRead != relation.IsRead {
t.Fatal("EXAMINE 只读打开后,STORE 仍持久化修改已读状态")
}
}
func TestAuditCopiedMessagesMustKeepDistinctUIDs(t *testing.T) {
relation := auditIMAPMessage(t)
if _, err := clientLogin.Select("INBOX", nil).Wait(); err != nil {
t.Fatal(err)
}
if _, err := clientLogin.Copy(imap.UIDSetNum(imap.UID(relation.ID)), "INBOX").Wait(); err != nil {
t.Fatal(err)
}
var relations []models.UserEmail
if err := db.Instance.Where("user_id=? AND email_id=?", relation.UserID, relation.EmailID).Find(&relations); err != nil || len(relations) != 2 {
t.Fatalf("复制前置条件失败: count=%d err=%v", len(relations), err)
}
for _, stored := range relations {
t.Logf("stored_uid=%d status=%d group=%d", stored.ID, stored.Status, stored.GroupId)
}
rows, lookupErr := list.GetEmailListByGroup(&context.Context{UserID: relation.UserID}, "INBOX", list.ImapListReq{
UidList: []int{relations[0].ID, relations[1].ID},
}, true)
if lookupErr != nil || len(rows) != 2 {
t.Fatalf("直接查询副本失败: count=%d err=%v", len(rows), lookupErr)
}
t.Logf("hydrated_uids=%d,%d same_pointer=%v", rows[0].UeId, rows[1].UeId, rows[0] == rows[1])
if rows[0] == rows[1] || rows[0].UeId == rows[1].UeId {
t.Error("不同邮箱关系复用了同一个可变响应对象,UID 被后一个副本覆盖")
}
uids := imap.UIDSetNum(imap.UID(relations[0].ID), imap.UID(relations[1].ID))
messages, err := clientLogin.Fetch(uids, &imap.FetchOptions{UID: true, Flags: true}).Collect()
if err != nil || len(messages) != 2 {
t.Fatalf("读取两个副本失败: count=%d err=%v", len(messages), err)
}
t.Logf("stored_uids=%d,%d fetched_uids=%d,%d", relations[0].ID, relations[1].ID, messages[0].UID, messages[1].UID)
if messages[0].UID == messages[1].UID {
t.Fatal("不同副本在实际 IMAP FETCH 响应中使用了同一个 UID")
}
}
func TestAuditListPercentMustIncludeInbox(t *testing.T) {
mailboxes, err := clientLogin.List("", "%", nil).Collect()
if err != nil {
t.Fatal(err)
}
names := make([]string, 0, len(mailboxes))
found := false
for _, mailbox := range mailboxes {
names = append(names, mailbox.Mailbox)
found = found || mailbox.Mailbox == "INBOX"
}
t.Logf("top_level_mailboxes=%v", names)
if !found {
t.Fatal("LIST 顶层通配符遗漏 INBOX 和默认文件夹")
}
}
func TestAuditUIDNextMustNotDecrease(t *testing.T) {
relation := auditIMAPMessage(t)
before, err := clientLogin.Status("INBOX", &imap.StatusOptions{UIDNext: true, UIDValidity: true}).Wait()
if err != nil {
t.Fatal(err)
}
if err := group.Move2DefaultBox(&context.Context{UserID: relation.UserID}, []int{relation.EmailID}, "Junk"); err != nil {
t.Fatal(err)
}
after, err := clientLogin.Status("INBOX", &imap.StatusOptions{UIDNext: true, UIDValidity: true}).Wait()
if err != nil {
t.Fatal(err)
}
t.Logf("uidnext_before=%d uidnext_after=%d validity_before=%d validity_after=%d", before.UIDNext, after.UIDNext, before.UIDValidity, after.UIDValidity)
if after.UIDValidity == before.UIDValidity && after.UIDNext < before.UIDNext {
t.Fatal("同一 UIDVALIDITY 下,移走最大 UID 邮件导致 UIDNEXT 倒退")
}
}
func TestAuditFetchWildcardUIDMustReturnLastMessage(t *testing.T) {
relation := auditIMAPMessage(t)
if _, err := clientLogin.Select("INBOX", nil).Wait(); err != nil {
t.Fatal(err)
}
messages, err := clientLogin.Fetch(imap.UIDSetNum(0), &imap.FetchOptions{UID: true}).Collect()
if err != nil {
t.Fatal(err)
}
// go-imap/v2 beta.5 的动态 UID 集合 Contains(*) 固定返回 false,
// 因而客户端会丢弃合法的动态 UID 响应;静态 UID 用于验证响应解析本身。
if len(messages) != 0 {
t.Fatalf("动态 UID 客户端结果异常: count=%d", len(messages))
}
staticMessages, err := clientLogin.Fetch(imap.UIDSetNum(imap.UID(relation.ID)), &imap.FetchOptions{UID: true}).Collect()
if err != nil || len(staticMessages) != 1 || staticMessages[0].UID != imap.UID(relation.ID) {
t.Fatalf("静态 UID FETCH 未正确解析服务端响应: count=%d err=%v", len(staticMessages), err)
}
}
func TestAuditInboxCountMustExcludeLoginBindings(t *testing.T) {
relation := auditIMAPMessage(t)
bindings, err := db.Instance.Where("user_id=? AND email_id=0 AND group_id=0 AND status=0", relation.UserID).Count(&models.UserEmail{})
if err != nil || bindings < 1 {
t.Fatalf("临时登录邮箱绑定前置条件未成立: count=%d err=%v", bindings, err)
}
want, err := db.Instance.Where("user_id=? AND email_id>0 AND group_id=0 AND status=0", relation.UserID).Count(&models.UserEmail{})
if err != nil {
t.Fatal(err)
}
status, err := clientLogin.Status("INBOX", &imap.StatusOptions{NumMessages: true}).Wait()
if err != nil || status.NumMessages == nil {
t.Fatalf("查询邮箱数量失败: status=%v err=%v", status, err)
}
t.Logf("login_bindings=%d real_messages=%d status_messages=%d", bindings, want, *status.NumMessages)
if int64(*status.NumMessages) != want {
t.Fatal("登录邮箱绑定被当作邮件计入 INBOX,状态数量与可读取邮件不一致")
}
}
func TestAuditSearchKeysMustIntersect(t *testing.T) {
first := auditIMAPMessage(t)
second := auditIMAPMessage(t)
third := auditIMAPMessage(t)
if _, err := clientLogin.Select("INBOX", nil).Wait(); err != nil {
t.Fatal(err)
}
uidCriterion := func(ids ...int) imap.SearchCriteria {
var set imap.UIDSet
for _, id := range ids {
set.AddNum(imap.UID(id))
}
return imap.SearchCriteria{UID: []imap.UIDSet{set}}
}
cases := map[string]imap.SearchCriteria{
"multiple_or": {Or: [][2]imap.SearchCriteria{
{uidCriterion(first.ID), uidCriterion(second.ID)},
{uidCriterion(second.ID), uidCriterion(third.ID)},
}},
"multiple_uid": {UID: []imap.UIDSet{
imap.UIDSetNum(imap.UID(first.ID), imap.UID(second.ID)),
imap.UIDSetNum(imap.UID(second.ID), imap.UID(third.ID)),
}},
}
for name, criteria := range cases {
t.Run(name, func(t *testing.T) {
result, err := clientLogin.UIDSearch(&criteria, nil).Wait()
if err != nil {
t.Fatal(err)
}
uids := result.AllUIDs()
t.Logf("returned=%v expected_only=%d", uids, second.ID)
if len(uids) != 1 || uids[0] != imap.UID(second.ID) {
t.Fatal("同一个 SEARCH 的多个键应取交集,而不是并集")
}
})
}
}
func TestAuditSearchDeletedMustMatchStoredFlag(t *testing.T) {
relation := auditIMAPMessage(t)
if _, err := clientLogin.Select("INBOX", nil).Wait(); err != nil {
t.Fatal(err)
}
uid := imap.UIDSetNum(imap.UID(relation.ID))
if err := clientLogin.Store(uid, &imap.StoreFlags{
Op: imap.StoreFlagsAdd, Flags: []imap.Flag{imap.FlagDeleted}, Silent: true,
}, nil).Close(); err != nil {
t.Fatal(err)
}
messages, err := clientLogin.Fetch(uid, &imap.FetchOptions{Flags: true, UID: true}).Collect()
if err != nil || len(messages) != 1 {
t.Fatalf("读取标记前置条件失败: count=%d err=%v", len(messages), err)
}
deleted := false
for _, flag := range messages[0].Flags {
deleted = deleted || flag == imap.FlagDeleted
}
if !deleted {
t.Fatal("STORE 后 FETCH 没有返回删除标记,不能继续归因 SEARCH")
}
result, err := clientLogin.UIDSearch(&imap.SearchCriteria{
UID: []imap.UIDSet{uid}, Flag: []imap.Flag{imap.FlagDeleted},
}, nil).Wait()
if err != nil {
t.Fatal(err)
}
t.Logf("fetch_deleted=%t search_uids=%v expected_uid=%d", deleted, result.AllUIDs(), relation.ID)
if len(result.AllUIDs()) != 1 || result.AllUIDs()[0] != imap.UID(relation.ID) {
t.Fatal("已通过 STORE 设置且 FETCH 确认的删除标记未被 SEARCH 识别")
}
}