293 lines
11 KiB
Go
293 lines
11 KiB
Go
package account
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"testing"
|
||
"time"
|
||
|
||
"telesrv/internal/domain"
|
||
"telesrv/internal/store"
|
||
"telesrv/internal/store/memory"
|
||
)
|
||
|
||
func newLoginEmailService(t *testing.T) (*Service, *memory.UserStore) {
|
||
t.Helper()
|
||
users := memory.NewUserStore()
|
||
svc := NewService(memory.NewPasswordStore(), WithUsers(users))
|
||
return svc, users
|
||
}
|
||
|
||
func createUser(t *testing.T, users *memory.UserStore, phone string) domain.User {
|
||
t.Helper()
|
||
u, err := users.Create(context.Background(), domain.User{Phone: phone, FirstName: "Test"})
|
||
if err != nil {
|
||
t.Fatalf("create user: %v", err)
|
||
}
|
||
return u
|
||
}
|
||
|
||
type captureMailSender struct {
|
||
to string
|
||
code string
|
||
}
|
||
|
||
func (s *captureMailSender) SendLoginCode(_ context.Context, to, code string, _ time.Duration) error {
|
||
s.to = to
|
||
s.code = code
|
||
return nil
|
||
}
|
||
|
||
// TestSetLoginEmailPersistsAndMasks 设置登录邮箱后,GetPassword 下发掩码 pattern,原始
|
||
// 地址只在 LoginEmail 读路径可见。
|
||
func TestSetLoginEmailPersistsAndMasks(t *testing.T) {
|
||
ctx := context.Background()
|
||
svc, users := newLoginEmailService(t)
|
||
u := createUser(t, users, "15550010001")
|
||
|
||
if err := svc.SetLoginEmail(ctx, u.ID, "alice@example.com"); err != nil {
|
||
t.Fatalf("SetLoginEmail: %v", err)
|
||
}
|
||
|
||
settings, err := svc.GetPassword(ctx, u.ID)
|
||
if err != nil {
|
||
t.Fatalf("GetPassword: %v", err)
|
||
}
|
||
if got, want := settings.LoginEmailPattern, "a***e@example.com"; got != want {
|
||
t.Fatalf("LoginEmailPattern = %q, want %q", got, want)
|
||
}
|
||
if settings.LoginEmail != "alice@example.com" {
|
||
t.Fatalf("LoginEmail = %q, want raw address", settings.LoginEmail)
|
||
}
|
||
|
||
email, found, err := svc.LoginEmail(ctx, u.ID)
|
||
if err != nil || !found || email != "alice@example.com" {
|
||
t.Fatalf("LoginEmail = %q found=%v err=%v", email, found, err)
|
||
}
|
||
}
|
||
|
||
// TestLoginEmailByPhoneAndClear 验证按手机号读取/清除登录邮箱(sendCode 检测 + reset 用)。
|
||
func TestLoginEmailByPhoneAndClear(t *testing.T) {
|
||
ctx := context.Background()
|
||
svc, users := newLoginEmailService(t)
|
||
createUser(t, users, "15550010002")
|
||
|
||
if err := svc.SetLoginEmailByPhone(ctx, "+1 555 001 0002", "bob@mail.com"); err != nil {
|
||
t.Fatalf("SetLoginEmailByPhone: %v", err)
|
||
}
|
||
email, found, err := svc.LoginEmailByPhone(ctx, "15550010002")
|
||
if err != nil || !found || email != "bob@mail.com" {
|
||
t.Fatalf("LoginEmailByPhone = %q found=%v err=%v", email, found, err)
|
||
}
|
||
|
||
if err := svc.ClearLoginEmailByPhone(ctx, "15550010002"); err != nil {
|
||
t.Fatalf("ClearLoginEmailByPhone: %v", err)
|
||
}
|
||
if _, found, _ := svc.LoginEmailByPhone(ctx, "15550010002"); found {
|
||
t.Fatal("login email still present after clear")
|
||
}
|
||
}
|
||
|
||
// TestSetLoginEmailRejectsInvalid 空/无 @ 的邮箱被拒。
|
||
func TestSetLoginEmailRejectsInvalid(t *testing.T) {
|
||
ctx := context.Background()
|
||
svc, users := newLoginEmailService(t)
|
||
u := createUser(t, users, "15550010003")
|
||
|
||
for _, bad := range []string{"", " ", "not-an-email"} {
|
||
if err := svc.SetLoginEmail(ctx, u.ID, bad); !errors.Is(err, domain.ErrEmailInvalid) {
|
||
t.Fatalf("SetLoginEmail(%q) err = %v, want ErrEmailInvalid", bad, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestSetLoginEmailRejectsDuplicateCaseInsensitive(t *testing.T) {
|
||
ctx := context.Background()
|
||
svc, users := newLoginEmailService(t)
|
||
u1 := createUser(t, users, "15550010103")
|
||
u2 := createUser(t, users, "15550010104")
|
||
|
||
if err := svc.SetLoginEmail(ctx, u1.ID, "Alice@Example.Test"); err != nil {
|
||
t.Fatalf("SetLoginEmail user1: %v", err)
|
||
}
|
||
if err := svc.SetLoginEmail(ctx, u2.ID, "alice@example.test"); !errors.Is(err, domain.ErrEmailOccupied) {
|
||
t.Fatalf("SetLoginEmail duplicate err = %v, want ErrEmailOccupied", err)
|
||
}
|
||
email, found, err := svc.LoginEmail(ctx, u1.ID)
|
||
if err != nil || !found || email != "alice@example.test" {
|
||
t.Fatalf("LoginEmail user1 = %q found=%v err=%v", email, found, err)
|
||
}
|
||
}
|
||
|
||
// TestRecoveryEmailDoesNotLeakIntoLoginEmailPattern 是核心解耦回归:设置 2FA 恢复邮箱
|
||
// 不得把恢复邮箱掩码写进 login_email_pattern(历史 bug)。
|
||
func TestRecoveryEmailDoesNotLeakIntoLoginEmailPattern(t *testing.T) {
|
||
ctx := context.Background()
|
||
svc, users := newLoginEmailService(t)
|
||
u := createUser(t, users, "15550010004")
|
||
|
||
// 设置 2FA 恢复邮箱(email-only 路径即可触发历史 bug 的写入点)。
|
||
if err := svc.UpdatePasswordSettings(ctx, u.ID, domain.PasswordCheck{Empty: true}, domain.PasswordInputSettings{
|
||
Email: "recovery@secret.com",
|
||
HasEmail: true,
|
||
}); err != nil {
|
||
t.Fatalf("UpdatePasswordSettings: %v", err)
|
||
}
|
||
|
||
settings, err := svc.GetPassword(ctx, u.ID)
|
||
if err != nil {
|
||
t.Fatalf("GetPassword: %v", err)
|
||
}
|
||
if settings.LoginEmailPattern != "" {
|
||
t.Fatalf("LoginEmailPattern = %q, want empty (recovery email must not leak into login email)", settings.LoginEmailPattern)
|
||
}
|
||
if !settings.HasRecovery {
|
||
t.Fatal("HasRecovery = false, want true after setting recovery email")
|
||
}
|
||
}
|
||
|
||
func TestSendLoginEmailCodeRejectsDuplicateBeforeSending(t *testing.T) {
|
||
ctx := context.Background()
|
||
users := memory.NewUserStore()
|
||
codes := memory.NewCodeStore()
|
||
passwords := memory.NewPasswordStore()
|
||
sender := &captureMailSender{}
|
||
svc := NewService(passwords,
|
||
WithUsers(users),
|
||
WithLoginEmailVerification(codes, sender, time.Minute, 2, 6))
|
||
u1 := createUser(t, users, "15550010105")
|
||
u2 := createUser(t, users, "15550010106")
|
||
if err := svc.SetLoginEmail(ctx, u1.ID, "taken@example.test"); err != nil {
|
||
t.Fatalf("SetLoginEmail user1: %v", err)
|
||
}
|
||
|
||
if _, _, err := svc.SendLoginEmailCode(ctx, u2.ID, "", "", "TAKEN@example.test", false); !errors.Is(err, domain.ErrEmailOccupied) {
|
||
t.Fatalf("SendLoginEmailCode duplicate err = %v, want ErrEmailOccupied", err)
|
||
}
|
||
if sender.to != "" || sender.code != "" {
|
||
t.Fatalf("duplicate email sent to=%q code=%q, want no send", sender.to, sender.code)
|
||
}
|
||
}
|
||
|
||
func TestLoginEmailSetupRejectsAlreadyOwnedEmailForNewPhone(t *testing.T) {
|
||
ctx := context.Background()
|
||
users := memory.NewUserStore()
|
||
codes := memory.NewCodeStore()
|
||
passwords := memory.NewPasswordStore()
|
||
sender := &captureMailSender{}
|
||
svc := NewService(passwords,
|
||
WithUsers(users),
|
||
WithLoginEmailVerification(codes, sender, time.Minute, 2, 6))
|
||
owner := createUser(t, users, "15550010107")
|
||
if err := svc.SetLoginEmail(ctx, owner.ID, "owner@example.test"); err != nil {
|
||
t.Fatalf("SetLoginEmail owner: %v", err)
|
||
}
|
||
if err := codes.Set(ctx, "new-phone-hash", store.PhoneCode{Phone: "15550010108", Channel: "email_setup_required", MaxAttempts: 2}, time.Minute); err != nil {
|
||
t.Fatalf("seed phone code: %v", err)
|
||
}
|
||
|
||
if _, _, err := svc.SendLoginEmailCode(ctx, 0, "+1 555 001 0108", "new-phone-hash", "OWNER@example.test", true); !errors.Is(err, domain.ErrEmailOccupied) {
|
||
t.Fatalf("setup duplicate email err = %v, want ErrEmailOccupied", err)
|
||
}
|
||
if sender.to != "" || sender.code != "" {
|
||
t.Fatalf("duplicate setup email sent to=%q code=%q, want no send", sender.to, sender.code)
|
||
}
|
||
}
|
||
|
||
func TestSendVerifyLoginEmailPersistsOnlyAfterVerify(t *testing.T) {
|
||
ctx := context.Background()
|
||
users := memory.NewUserStore()
|
||
codes := memory.NewCodeStore()
|
||
sender := &captureMailSender{}
|
||
svc := NewService(memory.NewPasswordStore(),
|
||
WithUsers(users),
|
||
WithLoginEmailVerification(codes, sender, time.Minute, 2, 6))
|
||
u := createUser(t, users, "15550010005")
|
||
|
||
pattern, length, err := svc.SendLoginEmailCode(ctx, u.ID, "", "", "alice@example.test", false)
|
||
if err != nil {
|
||
t.Fatalf("SendLoginEmailCode: %v", err)
|
||
}
|
||
if pattern != "a***e@example.test" || length != 6 || sender.to != "alice@example.test" || len(sender.code) != 6 {
|
||
t.Fatalf("send result pattern=%q length=%d to=%q code=%q", pattern, length, sender.to, sender.code)
|
||
}
|
||
if _, found, err := svc.LoginEmail(ctx, u.ID); err != nil || found {
|
||
t.Fatalf("LoginEmail before verify found=%v err=%v, want not found", found, err)
|
||
}
|
||
email, err := svc.VerifyLoginEmail(ctx, u.ID, "", "", sender.code, false)
|
||
if err != nil {
|
||
t.Fatalf("VerifyLoginEmail: %v", err)
|
||
}
|
||
if email != "alice@example.test" {
|
||
t.Fatalf("verified email = %q", email)
|
||
}
|
||
got, found, err := svc.LoginEmail(ctx, u.ID)
|
||
if err != nil || !found || got != "alice@example.test" {
|
||
t.Fatalf("LoginEmail after verify = %q found=%v err=%v", got, found, err)
|
||
}
|
||
}
|
||
|
||
func TestLoginEmailSetupStoresPendingEmailOnPhoneCodeHash(t *testing.T) {
|
||
ctx := context.Background()
|
||
codes := memory.NewCodeStore()
|
||
sender := &captureMailSender{}
|
||
svc := NewService(memory.NewPasswordStore(),
|
||
WithUsers(memory.NewUserStore()),
|
||
WithLoginEmailVerification(codes, sender, time.Minute, 2, 6))
|
||
if err := codes.Set(ctx, "phone-hash", store.PhoneCode{Phone: "15550010006", Channel: "email_setup_required", MaxAttempts: 2}, time.Minute); err != nil {
|
||
t.Fatalf("seed phone code: %v", err)
|
||
}
|
||
|
||
if _, _, err := svc.SendLoginEmailCode(ctx, 0, "+1 555 001 0006", "phone-hash", "new@example.test", true); err != nil {
|
||
t.Fatalf("SendLoginEmailCode setup: %v", err)
|
||
}
|
||
email, err := svc.VerifyLoginEmail(ctx, 0, "+1 555 001 0006", "phone-hash", sender.code, true)
|
||
if err != nil {
|
||
t.Fatalf("VerifyLoginEmail setup: %v", err)
|
||
}
|
||
if email != "new@example.test" {
|
||
t.Fatalf("verified setup email = %q", email)
|
||
}
|
||
rec, found, err := codes.Get(ctx, "phone-hash")
|
||
if err != nil || !found {
|
||
t.Fatalf("phone code found=%v err=%v", found, err)
|
||
}
|
||
if rec.Channel != "email_login" || rec.Code != sender.code || rec.Email != "new@example.test" || !rec.VerifiedEmail || rec.PendingEmail != "new@example.test" {
|
||
t.Fatalf("phone code after verify = %+v", rec)
|
||
}
|
||
}
|
||
|
||
func TestVerifyLoginEmailDeletesCodeAfterMaxAttempts(t *testing.T) {
|
||
ctx := context.Background()
|
||
users := memory.NewUserStore()
|
||
codes := memory.NewCodeStore()
|
||
sender := &captureMailSender{}
|
||
svc := NewService(memory.NewPasswordStore(),
|
||
WithUsers(users),
|
||
WithLoginEmailVerification(codes, sender, time.Minute, 2, 6))
|
||
u := createUser(t, users, "15550010007")
|
||
|
||
if _, _, err := svc.SendLoginEmailCode(ctx, u.ID, "", "", "limit@example.test", false); err != nil {
|
||
t.Fatalf("SendLoginEmailCode: %v", err)
|
||
}
|
||
bad1 := "000000"
|
||
if bad1 == sender.code {
|
||
bad1 = "111111"
|
||
}
|
||
bad2 := "222222"
|
||
if bad2 == sender.code {
|
||
bad2 = "333333"
|
||
}
|
||
if _, err := svc.VerifyLoginEmail(ctx, u.ID, "", "", bad1, false); !errors.Is(err, domain.ErrEmailCodeInvalid) {
|
||
t.Fatalf("first bad VerifyLoginEmail err = %v, want ErrEmailCodeInvalid", err)
|
||
}
|
||
if _, err := svc.VerifyLoginEmail(ctx, u.ID, "", "", bad2, false); !errors.Is(err, domain.ErrEmailCodeInvalid) {
|
||
t.Fatalf("second bad VerifyLoginEmail err = %v, want ErrEmailCodeInvalid", err)
|
||
}
|
||
if _, err := svc.VerifyLoginEmail(ctx, u.ID, "", "", sender.code, false); !errors.Is(err, domain.ErrEmailCodeInvalid) {
|
||
t.Fatalf("VerifyLoginEmail after max attempts err = %v, want ErrEmailCodeInvalid", err)
|
||
}
|
||
if _, found, _ := svc.LoginEmail(ctx, u.ID); found {
|
||
t.Fatal("login email was set after exhausted verification code")
|
||
}
|
||
}
|