Smail/test/review/imap_findings_test.go

267 lines
10 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 (
"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"
)
// 使用现有 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)
}
_, err = clientLogin.Move(uid, "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)
}
t.Logf("wildcard_uid_count=%d expected_uid=%d", len(messages), relation.ID)
if len(messages) != 1 || messages[0].UID != imap.UID(relation.ID) {
t.Fatal("UID FETCH * 没有返回非空文件夹中最大 UID 的邮件")
}
}
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 识别")
}
}