Smail/backend/services/externalemail/repository_test.go
smail 417481f05c
Some checks are pending
Security Checks / security (push) Waiting to run
修复外部邮箱更换回归用例的函数签名适配
为含可选分组写入保护参数的方法添加用例调用适配,不改变业务授权逻辑或中文提示。后端全量静态检查通过,未执行回归用例或实机验收。
2026-09-28 21:33:17 +08:00

252 lines
8.6 KiB
Go
Raw Permalink 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 externalemail
import (
"path/filepath"
"testing"
"git.pcux.cn/xuans/Smail/models"
_ "modernc.org/sqlite"
"xorm.io/xorm"
)
func TestRepositoryBindsVerifiesAndScopesByUser(t *testing.T) {
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "external.db"))
if err != nil {
t.Fatal(err)
}
defer engine.Close()
if err := engine.Sync2(&models.ExternalEmail{}); err != nil {
t.Fatal(err)
}
vault := NewVaultWithKeys([]byte("01234567890123456789012345678901"), []byte("abcdefabcdefabcdefabcdefabcdefab"))
repo, err := NewRepository(engine, vault)
if err != nil {
t.Fatal(err)
}
item, err := repo.Bind(BindRequest{UserID: 1, Email: "Owner@Example.com", Type: models.ExternalEmailTypePrimary})
if err != nil {
t.Fatal(err)
}
if item.Status != models.ExternalEmailStatusPending || item.EmailCiphertext == "" {
t.Fatalf("unexpected binding: %+v", item)
}
pending, err := repo.FindPending(1, "OWNER@example.com", models.ExternalEmailTypePrimary)
if err != nil || pending.ID != item.ID {
t.Fatalf("pending binding = %+v, err = %v", pending, err)
}
if _, err := repo.Bind(BindRequest{UserID: 1, Email: "other@example.com", Type: models.ExternalEmailTypePrimary}); err != ErrPrimaryLimit {
t.Fatalf("primary limit error = %v", err)
}
if err := repo.Verify(2, item.ID); err != ErrBindingOwnership {
t.Fatalf("cross-user verify error = %v", err)
}
if err := repo.Verify(1, item.ID); err != nil {
t.Fatal(err)
}
if _, err := repo.FindPending(1, "owner@example.com", models.ExternalEmailTypePrimary); err != ErrBindingNotFound {
t.Fatalf("verified binding remained pending: %v", err)
}
stored, err := repo.List(1)
if err != nil || len(stored) != 1 {
t.Fatalf("list result = %v, %v", stored, err)
}
if stored[0].Status != models.ExternalEmailStatusVerified {
t.Fatalf("status = %s", stored[0].Status)
}
decrypted, err := repo.Decrypt(stored[0])
if err != nil || decrypted != "owner@example.com" {
t.Fatalf("decrypted email = %q, err = %v", decrypted, err)
}
}
func TestRepositoryRejectsInvalidEmailType(t *testing.T) {
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "external.db"))
if err != nil {
t.Fatal(err)
}
defer engine.Close()
vault := NewVaultWithKeys([]byte("01234567890123456789012345678901"), []byte("abcdefabcdefabcdefabcdefabcdefab"))
repo, err := NewRepository(engine, vault)
if err != nil {
t.Fatal(err)
}
if _, err := repo.Bind(BindRequest{UserID: 1, Email: "owner@example.com", Type: "unknown"}); err != ErrInvalidBinding {
t.Fatalf("error = %v, want invalid binding", err)
}
}
func TestRepositoryAbandonsOnlyOwnedPendingBinding(t *testing.T) {
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "external.db"))
if err != nil {
t.Fatal(err)
}
defer engine.Close()
if err := engine.Sync2(&models.ExternalEmail{}); err != nil {
t.Fatal(err)
}
vault := NewVaultWithKeys([]byte("01234567890123456789012345678901"), []byte("abcdefabcdefabcdefabcdefabcdefab"))
repo, err := NewRepository(engine, vault)
if err != nil {
t.Fatal(err)
}
item, err := repo.Bind(BindRequest{UserID: 1, Email: "pending@example.com", Type: models.ExternalEmailTypePrimary})
if err != nil {
t.Fatal(err)
}
if err := repo.AbandonPending(2, item.ID); err != ErrBindingOwnership {
t.Fatalf("cross-user abandon error = %v", err)
}
if err := repo.AbandonPending(1, item.ID); err != nil {
t.Fatal(err)
}
if count, err := engine.ID(item.ID).Count(&models.ExternalEmail{}); err != nil || count != 0 {
t.Fatalf("pending binding remains: count=%d err=%v", count, err)
}
verified, err := repo.Bind(BindRequest{UserID: 1, Email: "verified@example.com", Type: models.ExternalEmailTypePrimary})
if err != nil {
t.Fatal(err)
}
if err := repo.Verify(1, verified.ID); err != nil {
t.Fatal(err)
}
if err := repo.AbandonPending(1, verified.ID); err != ErrBindingOwnership {
t.Fatalf("verified binding was abandoned: %v", err)
}
}
func TestRepositoryReplacesExternalEmailOnlyAfterVerification(t *testing.T) {
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "external.db"))
if err != nil {
t.Fatal(err)
}
defer engine.Close()
if err := engine.Sync2(&models.ExternalEmail{}); err != nil {
t.Fatal(err)
}
vault := NewVaultWithKeys([]byte("01234567890123456789012345678901"), []byte("abcdefabcdefabcdefabcdefabcdefab"))
repo, err := NewRepository(engine, vault)
if err != nil {
t.Fatal(err)
}
oldBinding, err := repo.Bind(BindRequest{UserID: 1, Email: "old@example.com", Type: models.ExternalEmailTypePrimary})
if err != nil {
t.Fatal(err)
}
if err := repo.Verify(1, oldBinding.ID); err != nil {
t.Fatal(err)
}
pending, err := repo.BeginReplacement(BindRequest{UserID: 1, Email: "new@example.com", Type: models.ExternalEmailTypePrimary})
if err != nil {
t.Fatal(err)
}
before, err := repo.FindVerifiedPrimaryByUser(1)
if err != nil || before.ID != oldBinding.ID {
t.Fatalf("old binding was not retained before verification: %+v, %v", before, err)
}
if err := repo.VerifyReplacement(2, pending.ID); err != ErrBindingOwnership {
t.Fatalf("cross-user replacement error = %v", err)
}
if err := repo.VerifyReplacement(1, pending.ID); err != nil {
t.Fatal(err)
}
after, err := repo.FindVerifiedPrimaryByUser(1)
if err != nil || after.ID != pending.ID {
t.Fatalf("new binding was not activated: %+v, %v", after, err)
}
items, err := repo.List(1)
if err != nil {
t.Fatal(err)
}
statuses := map[int]string{}
for _, item := range items {
statuses[item.ID] = item.Status
}
if statuses[oldBinding.ID] != models.ExternalEmailStatusDisabled || statuses[pending.ID] != models.ExternalEmailStatusVerified {
t.Fatalf("unexpected replacement statuses: %+v", statuses)
}
}
func TestRepositoryRebindsDisabledEmailWithFreshVerification(t *testing.T) {
for _, replacement := range []bool{false, true} {
name := "bind"
if replacement {
name = "replacement"
}
t.Run(name, func(t *testing.T) {
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "external.db"))
if err != nil {
t.Fatal(err)
}
defer engine.Close()
if err := engine.Sync2(&models.ExternalEmail{}); err != nil {
t.Fatal(err)
}
vault := NewVaultWithKeys([]byte("01234567890123456789012345678901"), []byte("abcdefabcdefabcdefabcdefabcdefab"))
repo, err := NewRepository(engine, vault)
if err != nil {
t.Fatal(err)
}
item, err := repo.Bind(BindRequest{UserID: 1, Email: "owner@example.com", Type: models.ExternalEmailTypeBackup})
if err != nil {
t.Fatal(err)
}
if err := repo.Verify(1, item.ID); err != nil {
t.Fatal(err)
}
bind := repo.Bind
verify := repo.Verify
if replacement {
bind = func(request BindRequest) (*models.ExternalEmail, error) {
return repo.BeginReplacement(request)
}
verify = func(userID, id int) error {
return repo.VerifyReplacement(userID, id)
}
}
request := BindRequest{UserID: 1, Email: "OWNER@example.com", Type: models.ExternalEmailTypePrimary}
if _, err := bind(request); err != ErrBindingExists {
t.Fatalf("已验证邮箱不应直接更改用途:%v", err)
}
if err := repo.Disable(1, item.ID); err != nil {
t.Fatal(err)
}
other := request
other.UserID = 2
if _, err := bind(other); err != ErrBindingExists {
t.Fatalf("不能复用其他用户的停用记录:%v", err)
}
pending, err := bind(request)
if err != nil {
t.Fatal(err)
}
if pending.ID != item.ID || pending.Status != models.ExternalEmailStatusPending || pending.VerifiedAt != nil || pending.EmailType != models.ExternalEmailTypePrimary || pending.CredentialVersion <= item.CredentialVersion {
t.Fatalf("重新绑定必须保留记录并重新验证:%+v", pending)
}
if _, err := repo.FindVerifiedPrimaryByUser(1); err != ErrBindingNotFound {
t.Fatalf("未验证邮箱不能参与登录:%v", err)
}
stored, err := repo.List(1)
if err != nil || len(stored) != 1 || stored[0].VerifiedAt != nil || stored[0].Status != models.ExternalEmailStatusPending {
t.Fatalf("旧验证状态未清除:%+v,%v", stored, err)
}
if err := verify(1, pending.ID); err != nil {
t.Fatal(err)
}
if _, err := repo.FindVerifiedPrimaryByUser(1); err != nil {
t.Fatalf("重新验证后应可用于登录:%v", err)
}
if !replacement {
if err := repo.Disable(1, item.ID); err != nil {
t.Fatal(err)
}
if _, err := repo.Bind(BindRequest{UserID: 1, Email: "other@example.com", Type: models.ExternalEmailTypePrimary}); err != nil {
t.Fatal(err)
}
if _, err := bind(request); err != ErrPrimaryLimit {
t.Fatalf("重新绑定不能绕过主要邮箱数量限制:%v", err)
}
}
})
}
}