Ctrl K
ComunidadGuías…

internal/app/auth/signup_state_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package auth

import (
	"context"
	"errors"
	"strings"
	"sync"
	"testing"
	"time"

	accountapp "telesrv/internal/app/account"
	"telesrv/internal/domain"
	"telesrv/internal/store"
	"telesrv/internal/store/memory"
)

func TestSignUpRequiresCorrectSignInAndConsumesMarkerOnce(t *testing.T) {
	ctx := context.Background()
	users := memory.NewUserStore()
	codes := memory.NewCodeStore()
	svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345")
	phone := "15550009301"

	hash, err := svc.SendCode(ctx, phone)
	if err != nil {
		t.Fatalf("SendCode: %v", err)
	}
	if _, _, err := svc.SignUp(ctx, domain.Authorization{}, phone, hash, "Direct", "Bypass"); !errors.Is(err, ErrCodeInvalid) {
		t.Fatalf("direct SignUp err=%v, want ErrCodeInvalid", err)
	}
	if _, _, _, err := svc.SignIn(ctx, domain.Authorization{}, phone, hash, "00000"); !errors.Is(err, ErrCodeInvalid) {
		t.Fatalf("wrong SignIn err=%v, want ErrCodeInvalid", err)
	}
	if rec, found, err := codes.Get(ctx, hash); err != nil || !found || rec.SignUpVerified {
		t.Fatalf("wrong code marker=%v found=%v err=%v, want live/unverified", rec.SignUpVerified, found, err)
	}
	if _, _, err := svc.SignUp(ctx, domain.Authorization{}, phone, hash, "Wrong", "Code"); !errors.Is(err, ErrCodeInvalid) {
		t.Fatalf("SignUp after wrong code err=%v, want ErrCodeInvalid", err)
	}

	verifyCodeForSignUp(t, svc, phone, hash, "12345")
	if rec, found, err := codes.Get(ctx, hash); err != nil || !found || !rec.SignUpVerified || rec.IssuedUserID != 0 {
		t.Fatalf("verified record=%+v found=%v err=%v", rec, found, err)
	}
	if _, msg, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345"); err != nil || !needSignUp || msg.ID != 0 {
		t.Fatalf("idempotent SignIn needSignUp=%v message=%+v err=%v", needSignUp, msg, err)
	}
	u, _, err := svc.SignUp(ctx, domain.Authorization{}, phone, hash, "Verified", "User")
	if err != nil || u.Phone != phone {
		t.Fatalf("verified SignUp user=%+v err=%v", u, err)
	}
	if _, _, err := svc.SignUp(ctx, domain.Authorization{}, phone, hash, "Replay", "User"); !errors.Is(err, ErrCodeExpired) {
		t.Fatalf("replayed SignUp err=%v, want ErrCodeExpired", err)
	}
}

func TestConcurrentSignUpConsumesVerifiedHashExactlyOnce(t *testing.T) {
	ctx := context.Background()
	users := memory.NewUserStore()
	svc := NewService(users, memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345")
	phone := "15550009302"
	hash, err := svc.SendCode(ctx, phone)
	if err != nil {
		t.Fatalf("SendCode: %v", err)
	}
	verifyCodeForSignUp(t, svc, phone, hash, "12345")

	const workers = 16
	start := make(chan struct{})
	errs := make(chan error, workers)
	for i := 0; i < workers; i++ {
		go func(i int) {
			<-start
			var key [8]byte
			key[0] = byte(i + 1)
			_, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, phone, hash, "Concurrent", "User")
			errs <- err
		}(i)
	}
	close(start)
	successes := 0
	for i := 0; i < workers; i++ {
		err := <-errs
		switch {
		case err == nil:
			successes++
		case errors.Is(err, ErrCodeExpired), errors.Is(err, ErrCodeInvalid):
		default:
			t.Fatalf("concurrent SignUp err=%v", err)
		}
	}
	if successes != 1 {
		t.Fatalf("successful SignUp calls=%d, want 1", successes)
	}
}

type afterVerifyCodeStore struct {
	store.CodeStore
	once        sync.Once
	afterVerify func()
}

type failingPasswordStore struct {
	store.PasswordStore
	err error
}

func (s *failingPasswordStore) GetByUser(context.Context, int64) (domain.PasswordSettings, bool, error) {
	return domain.PasswordSettings{}, false, s.err
}

type switchablePhoneOwnerStore struct {
	store.UserStore
	mu       sync.RWMutex
	phone    string
	override bool
	owner    domain.User
	found    bool
}

func (s *switchablePhoneOwnerStore) ByPhone(ctx context.Context, phone string) (domain.User, bool, error) {
	s.mu.RLock()
	if s.override && domain.NormalizePhone(phone) == s.phone {
		owner, found := s.owner, s.found
		s.mu.RUnlock()
		return owner, found, nil
	}
	s.mu.RUnlock()
	return s.UserStore.ByPhone(ctx, phone)
}

func (s *switchablePhoneOwnerStore) setOwnerView(phone string, owner domain.User, found bool) {
	s.mu.Lock()
	s.phone = domain.NormalizePhone(phone)
	s.owner = owner
	s.found = found
	s.override = true
	s.mu.Unlock()
}

func (s *switchablePhoneOwnerStore) resetOwnerView() {
	s.mu.Lock()
	s.override = false
	s.mu.Unlock()
}

func (s *afterVerifyCodeStore) VerifyLogin(ctx context.Context, hash, phone, code string, keep bool, maxAttempts int) (store.LoginCodeVerifyResult, error) {
	result, err := s.CodeStore.VerifyLogin(ctx, hash, phone, code, keep, maxAttempts)
	if err == nil && result.Status == store.LoginCodeVerifyAccepted && s.afterVerify != nil {
		s.once.Do(s.afterVerify)
	}
	return result, err
}

func TestOwnerTransferAcrossVerifyInvalidatesHashPermanently(t *testing.T) {
	ctx := context.Background()
	users := memory.NewUserStore()
	baseCodes := memory.NewCodeStore()
	var createErr error
	codes := &afterVerifyCodeStore{CodeStore: baseCodes}
	svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345")
	phone := "15550009303"
	hash, err := svc.SendCode(ctx, phone)
	if err != nil {
		t.Fatalf("SendCode: %v", err)
	}
	codes.afterVerify = func() {
		_, createErr = users.Create(ctx, domain.User{Phone: phone, FirstName: "NewOwner"})
	}
	if _, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345"); !errors.Is(err, ErrCodeInvalid) || needSignUp {
		t.Fatalf("SignIn across owner transfer needSignUp=%v err=%v, want invalid", needSignUp, err)
	}
	if createErr != nil {
		t.Fatalf("create concurrent owner: %v", createErr)
	}
	if _, found, err := baseCodes.Get(ctx, hash); err != nil || found {
		t.Fatalf("owner-drift hash found=%v err=%v, want invalidated", found, err)
	}
}

func TestPasswordLookupFailureNeverCreatesOrChangesAuthorization(t *testing.T) {
	ctx := context.Background()
	users := memory.NewUserStore()
	target, err := users.Create(ctx, domain.User{Phone: "15550009320", FirstName: "Target"})
	if err != nil {
		t.Fatalf("create target: %v", err)
	}
	previous, err := users.Create(ctx, domain.User{Phone: "15550009321", FirstName: "Previous"})
	if err != nil {
		t.Fatalf("create previous: %v", err)
	}
	authz := memory.NewAuthorizationStore()
	lookupErr := errors.New("password store unavailable")
	passwords := &failingPasswordStore{PasswordStore: memory.NewPasswordStore(), err: lookupErr}
	svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345",
		WithPasswords(passwords),
		WithLoginCodeDelivery(&captureLoginCodeDelivery{}),
	)

	t.Run("unbound-key-remains-unbound", func(t *testing.T) {
		key := [8]byte{0xC1}
		hash, err := svc.SendCode(ctx, target.Phone)
		if err != nil {
			t.Fatalf("SendCode: %v", err)
		}
		if _, _, _, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: key}, target.Phone, hash, "12345"); !errors.Is(err, lookupErr) {
			t.Fatalf("SignIn err=%v, want password lookup failure", err)
		}
		if got, found, err := authz.ByAuthKey(ctx, key); err != nil || found {
			t.Fatalf("authorization=%+v found=%v err=%v, want absent", got, found, err)
		}
	})

	t.Run("previous-binding-remains-unchanged", func(t *testing.T) {
		key := [8]byte{0xC2}
		original := domain.Authorization{AuthKeyID: key, UserID: previous.ID, Hash: 987654321}
		if err := authz.Bind(ctx, original); err != nil {
			t.Fatalf("bind previous authorization: %v", err)
		}
		hash, err := svc.SendCode(ctx, target.Phone)
		if err != nil {
			t.Fatalf("SendCode: %v", err)
		}
		if _, _, _, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: key}, target.Phone, hash, "12345"); !errors.Is(err, lookupErr) {
			t.Fatalf("SignIn err=%v, want password lookup failure", err)
		}
		got, found, err := authz.ByAuthKey(ctx, key)
		if err != nil || !found || got.UserID != previous.ID || got.Hash != original.Hash || got.PasswordPending != original.PasswordPending {
			t.Fatalf("authorization after failure=%+v found=%v err=%v, want unchanged %+v", got, found, err, original)
		}
	})
}

func TestOwnerTransferAwayAndBackCannotReviveLoginHash(t *testing.T) {
	ctx := context.Background()
	t.Run("unregistered-signin", func(t *testing.T) {
		baseUsers := memory.NewUserStore()
		other, err := baseUsers.Create(ctx, domain.User{Phone: "15550009311", FirstName: "Other"})
		if err != nil {
			t.Fatalf("create other owner: %v", err)
		}
		users := &switchablePhoneOwnerStore{UserStore: baseUsers}
		codes := memory.NewCodeStore()
		svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345")
		phone := "15550009310"
		hash, err := svc.SendCode(ctx, phone)
		if err != nil {
			t.Fatalf("SendCode: %v", err)
		}
		users.setOwnerView(phone, other, true)
		if _, _, _, err := svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345"); !errors.Is(err, ErrCodeInvalid) {
			t.Fatalf("SignIn after 0->B owner transfer err=%v, want invalid", err)
		}
		users.resetOwnerView()
		if _, _, _, err := svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345"); !errors.Is(err, ErrCodeExpired) {
			t.Fatalf("SignIn after 0->B->0 err=%v, want expired", err)
		}
	})

	t.Run("existing-resend", func(t *testing.T) {
		baseUsers := memory.NewUserStore()
		ownerA, err := baseUsers.Create(ctx, domain.User{Phone: "15550009312", FirstName: "A"})
		if err != nil {
			t.Fatalf("create owner A: %v", err)
		}
		ownerB, err := baseUsers.Create(ctx, domain.User{Phone: "15550009313", FirstName: "B"})
		if err != nil {
			t.Fatalf("create owner B: %v", err)
		}
		users := &switchablePhoneOwnerStore{UserStore: baseUsers}
		codes := memory.NewCodeStore()
		svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345", WithLoginCodeDelivery(&captureLoginCodeDelivery{}))
		hash, err := svc.SendCode(ctx, ownerA.Phone)
		if err != nil {
			t.Fatalf("SendCode: %v", err)
		}
		users.setOwnerView(ownerA.Phone, ownerB, true)
		if _, err := svc.ResendCode(ctx, ownerA.Phone, hash); !errors.Is(err, ErrCodeInvalid) {
			t.Fatalf("ResendCode after A->B err=%v, want invalid", err)
		}
		users.resetOwnerView()
		if _, _, _, err := svc.SignIn(ctx, domain.Authorization{}, ownerA.Phone, hash, "12345"); !errors.Is(err, ErrCodeExpired) {
			t.Fatalf("SignIn after A->B->A err=%v, want expired", err)
		}
	})

	t.Run("existing-cancel", func(t *testing.T) {
		baseUsers := memory.NewUserStore()
		ownerA, err := baseUsers.Create(ctx, domain.User{Phone: "15550009314", FirstName: "A"})
		if err != nil {
			t.Fatalf("create owner A: %v", err)
		}
		ownerB, err := baseUsers.Create(ctx, domain.User{Phone: "15550009315", FirstName: "B"})
		if err != nil {
			t.Fatalf("create owner B: %v", err)
		}
		users := &switchablePhoneOwnerStore{UserStore: baseUsers}
		codes := memory.NewCodeStore()
		svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345", WithLoginCodeDelivery(&captureLoginCodeDelivery{}))
		hash, err := svc.SendCode(ctx, ownerA.Phone)
		if err != nil {
			t.Fatalf("SendCode: %v", err)
		}
		users.setOwnerView(ownerA.Phone, ownerB, true)
		if err := svc.CancelCode(ctx, ownerA.Phone, hash); !errors.Is(err, ErrCodeInvalid) {
			t.Fatalf("CancelCode after A->B err=%v, want invalid", err)
		}
		users.resetOwnerView()
		if _, _, _, err := svc.SignIn(ctx, domain.Authorization{}, ownerA.Phone, hash, "12345"); !errors.Is(err, ErrCodeExpired) {
			t.Fatalf("SignIn after canceled A->B->A err=%v, want expired", err)
		}
	})
}

func TestEmailSetupVerificationAuthorizesSignUpWithout777000Message(t *testing.T) {
	ctx := context.Background()
	users := memory.NewUserStore()
	codes := memory.NewCodeStore()
	passwords := memory.NewPasswordStore()
	sender := &testMailSender{}
	accountSvc := accountapp.NewService(passwords,
		accountapp.WithUsers(users),
		accountapp.WithLoginEmailVerification(codes, sender, time.Minute, 3, 6),
	)
	dialogs := memory.NewDialogStore()
	messages := memory.NewMessageStore(dialogs)
	authSvc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345",
		WithLoginMessages(messages, dialogs),
		WithLoginEmail(LoginEmailOptions{
			Enabled:      true,
			RequireSetup: true,
			CodeLength:   6,
			Store:        accountSvc,
			Sender:       sender,
		}),
	)
	phone := "15550009304"
	hash, err := authSvc.SendCode(ctx, phone)
	if err != nil {
		t.Fatalf("SendCode: %v", err)
	}
	if _, _, err := authSvc.SignUp(ctx, domain.Authorization{}, phone, hash, "Direct", "Email"); !errors.Is(err, ErrCodeInvalid) {
		t.Fatalf("SignUp before email setup err=%v, want ErrCodeInvalid", err)
	}
	if _, _, _, err := authSvc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345"); !errors.Is(err, ErrCodeInvalid) {
		t.Fatalf("WebK SignIn with setup-required placeholder err=%v, want ErrCodeInvalid", err)
	}
	if _, _, _, err := authSvc.SignInWithEmail(ctx, domain.Authorization{}, phone, hash, "12345"); !errors.Is(err, ErrCodeInvalid) {
		t.Fatalf("native SignInWithEmail with setup-required placeholder err=%v, want ErrCodeInvalid", err)
	}
	if _, _, err := accountSvc.SendLoginEmailCode(ctx, 0, phone, hash, "new@example.test", true); err != nil {
		t.Fatalf("SendLoginEmailCode: %v", err)
	}
	bad := wrongCode(sender.code, '0')
	if _, err := accountSvc.VerifyLoginEmail(ctx, 0, phone, hash, bad, true); !errors.Is(err, domain.ErrEmailCodeInvalid) {
		t.Fatalf("wrong VerifyLoginEmail err=%v, want ErrEmailCodeInvalid", err)
	}
	if rec, found, err := codes.Get(ctx, hash); err != nil || !found || rec.SignUpVerified {
		t.Fatalf("wrong SMTP code marker=%v found=%v err=%v", rec.SignUpVerified, found, err)
	}
	if _, err := accountSvc.VerifyLoginEmail(ctx, 0, phone, hash, sender.code, true); err != nil {
		t.Fatalf("VerifyLoginEmail: %v", err)
	}
	if rec, found, err := codes.Get(ctx, hash); err != nil || !found || !rec.SignUpVerified || rec.Channel != codeChannelEmailLogin {
		t.Fatalf("email-verified phone code=%+v found=%v err=%v", rec, found, err)
	}
	if _, msg, needSignUp, err := authSvc.SignInWithEmail(ctx, domain.Authorization{}, phone, hash, sender.code); err != nil || !needSignUp || msg.ID != 0 {
		t.Fatalf("SignInWithEmail after setup needSignUp=%v message=%+v err=%v", needSignUp, msg, err)
	}
	u, msg, err := authSvc.SignUp(ctx, domain.Authorization{}, phone, hash, "Email", "User")
	if err != nil {
		t.Fatalf("SignUp after email setup: %v", err)
	}
	if msg.ID != 0 || msg.Body != "" {
		t.Fatalf("email SignUp returned SMTP code message: %+v", msg)
	}
	list, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10})
	if err != nil {
		t.Fatalf("ListByUser: %v", err)
	}
	// email SignUp must not create the bootstrap *login-code* message (no
	// dialogs test pre-welcome), but every SignUp now fires the unconditional
	// welcome notification from 777000.
	if len(list.Dialogs) != 1 || list.Dialogs[0].Peer.ID != domain.OfficialSystemUserID || len(list.Messages) != 1 {
		t.Fatalf("email SignUp 777000 state: dialogs=%+v messages=%+v", list.Dialogs, list.Messages)
	}
	if got := list.Messages[0]; strings.Contains(got.Body, "Login code:") || len(got.Entities) != 0 {
		t.Fatalf("email SignUp wrote a login-code message: %+v", got)
	}
	if email, found, err := accountSvc.LoginEmailByPhone(ctx, phone); err != nil || !found || email != "new@example.test" {
		t.Fatalf("LoginEmailByPhone email=%q found=%v err=%v", email, found, err)
	}
}

func TestConsumeLoginEmailResetRequiresExactIssuedHash(t *testing.T) {
	ctx := context.Background()
	baseUsers := memory.NewUserStore()
	owner, err := baseUsers.Create(ctx, domain.User{Phone: "15550009330", FirstName: "Owner"})
	if err != nil {
		t.Fatalf("create owner: %v", err)
	}
	other, err := baseUsers.Create(ctx, domain.User{Phone: "15550009331", FirstName: "Other"})
	if err != nil {
		t.Fatalf("create other: %v", err)
	}
	users := &switchablePhoneOwnerStore{UserStore: baseUsers}
	codes := memory.NewCodeStore()
	delivery := &captureLoginCodeDelivery{}
	otp := &captureOTPSender{}
	svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345",
		WithLoginCodeDelivery(delivery), WithPhoneCodeDelivery(otp, 5))
	if !svc.LoginEmailResetAvailable() {
		t.Fatal("LoginEmailResetAvailable=false with real SMS sender")
	}
	seed := func(hash, channel string) {
		t.Helper()
		if err := codes.Set(ctx, hash, store.PhoneCode{
			Version:      store.PhoneCodeVersionCurrent,
			IssuedUserID: owner.ID,
			Phone:        owner.Phone,
			Code:         "654321",
			Channel:      channel,
			MaxAttempts:  5,
		}, time.Minute); err != nil {
			t.Fatalf("seed %s: %v", hash, err)
		}
	}

	if _, err := svc.ConsumeLoginEmailReset(ctx, owner.Phone, "arbitrary-missing"); !errors.Is(err, ErrCodeExpired) {
		t.Fatalf("arbitrary hash err=%v, want expired", err)
	}
	seed("wrong-phone", codeChannelEmailLogin)
	if _, err := svc.ConsumeLoginEmailReset(ctx, other.Phone, "wrong-phone"); !errors.Is(err, ErrCodeInvalid) {
		t.Fatalf("wrong phone err=%v, want invalid", err)
	}
	if _, found, err := codes.Get(ctx, "wrong-phone"); err != nil || !found {
		t.Fatalf("wrong-phone probe destroyed valid hash found=%v err=%v", found, err)
	}
	seed("wrong-channel", codeChannelPhone)
	if _, err := svc.ConsumeLoginEmailReset(ctx, owner.Phone, "wrong-channel"); !errors.Is(err, ErrCodeInvalid) {
		t.Fatalf("wrong channel err=%v, want invalid", err)
	}

	seed("owner-drift", codeChannelEmailLogin)
	users.setOwnerView(owner.Phone, other, true)
	if _, err := svc.ConsumeLoginEmailReset(ctx, owner.Phone, "owner-drift"); !errors.Is(err, ErrCodeInvalid) {
		t.Fatalf("A->B reset err=%v, want invalid", err)
	}
	users.resetOwnerView()
	if _, err := svc.ConsumeLoginEmailReset(ctx, owner.Phone, "owner-drift"); !errors.Is(err, ErrCodeExpired) {
		t.Fatalf("A->B->A reset err=%v, want expired", err)
	}

	seed("successful-reset", codeChannelEmailLogin)
	resetUserID, err := svc.ConsumeLoginEmailReset(ctx, owner.Phone, "successful-reset")
	if err != nil || resetUserID != owner.ID {
		t.Fatalf("successful reset consume uid=%d err=%v", resetUserID, err)
	}
	replacementHash, err := svc.SendPhoneCodeAfterLoginEmailReset(ctx, owner.Phone, resetUserID)
	if err != nil || replacementHash == "" {
		t.Fatalf("replacement hash=%q err=%v", replacementHash, err)
	}
	if len(delivery.requests) != 1 || delivery.requests[0].UserID != owner.ID || delivery.requests[0].PhoneCodeHash != replacementHash {
		t.Fatalf("replacement delivery=%+v", delivery.requests)
	}
	if rec, found, err := codes.Get(ctx, replacementHash); err != nil || !found || rec.Version != store.PhoneCodeVersionCurrent || rec.IssuedUserID != owner.ID || rec.Channel != codeChannelSMS {
		t.Fatalf("replacement code=%+v found=%v err=%v", rec, found, err)
	}
}

func TestLoginEmailResetUnavailableWithoutRealSMSSender(t *testing.T) {
	ctx := context.Background()
	users := memory.NewUserStore()
	owner, err := users.Create(ctx, domain.User{Phone: "15550009339", FirstName: "Owner"})
	if err != nil {
		t.Fatal(err)
	}
	codes := memory.NewCodeStore()
	const hash = "unavailable-email-reset"
	if err := codes.Set(ctx, hash, store.PhoneCode{
		Version: store.PhoneCodeVersionCurrent, IssuedUserID: owner.ID,
		Phone: owner.Phone, Code: "654321", Channel: codeChannelEmailLogin,
	}, time.Minute); err != nil {
		t.Fatal(err)
	}
	svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345")
	if svc.LoginEmailResetAvailable() {
		t.Fatal("LoginEmailResetAvailable=true without real SMS sender")
	}
	if _, err := svc.ConsumeLoginEmailReset(ctx, owner.Phone, hash); !errors.Is(err, ErrCodeInvalid) {
		t.Fatalf("ConsumeLoginEmailReset err=%v, want invalid", err)
	}
	if _, found, err := codes.Get(ctx, hash); err != nil || !found {
		t.Fatalf("unavailable reset consumed proof found=%v err=%v", found, err)
	}
}

func TestConcurrentLoginEmailResetHasSingleConsumer(t *testing.T) {
	ctx := context.Background()
	users := memory.NewUserStore()
	owner, err := users.Create(ctx, domain.User{Phone: "15550009332", FirstName: "Owner"})
	if err != nil {
		t.Fatalf("create owner: %v", err)
	}
	codes := memory.NewCodeStore()
	hash := "concurrent-email-reset"
	if err := codes.Set(ctx, hash, store.PhoneCode{
		Version:      store.PhoneCodeVersionCurrent,
		IssuedUserID: owner.ID,
		Phone:        owner.Phone,
		Code:         "654321",
		Channel:      codeChannelEmailLogin,
		MaxAttempts:  5,
	}, time.Minute); err != nil {
		t.Fatalf("seed code: %v", err)
	}
	svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345",
		WithPhoneCodeDelivery(&captureOTPSender{}, 5))
	const workers = 24
	start := make(chan struct{})
	errs := make(chan error, workers)
	for i := 0; i < workers; i++ {
		go func() {
			<-start
			_, err := svc.ConsumeLoginEmailReset(ctx, owner.Phone, hash)
			errs <- err
		}()
	}
	close(start)
	successes := 0
	for i := 0; i < workers; i++ {
		err := <-errs
		if err == nil {
			successes++
			continue
		}
		if !errors.Is(err, ErrCodeExpired) {
			t.Fatalf("concurrent reset err=%v", err)
		}
	}
	if successes != 1 {
		t.Fatalf("successful reset consumers=%d, want 1", successes)
	}
}

func TestLoginEmailResetLocksUserAcrossOwnerTransfer(t *testing.T) {
	ctx := context.Background()
	baseUsers := memory.NewUserStore()
	ownerA, err := baseUsers.Create(ctx, domain.User{Phone: "15550009340", FirstName: "A"})
	if err != nil {
		t.Fatalf("create A: %v", err)
	}
	ownerB, err := baseUsers.Create(ctx, domain.User{Phone: "15550009341", FirstName: "B"})
	if err != nil {
		t.Fatalf("create B: %v", err)
	}
	users := &switchablePhoneOwnerStore{UserStore: baseUsers}
	passwords := memory.NewPasswordStore()
	accountSvc := accountapp.NewService(passwords, accountapp.WithUsers(users))
	if err := accountSvc.SetLoginEmail(ctx, ownerA.ID, "a@example.test"); err != nil {
		t.Fatalf("SetLoginEmail A: %v", err)
	}
	if err := accountSvc.SetLoginEmail(ctx, ownerB.ID, "b@example.test"); err != nil {
		t.Fatalf("SetLoginEmail B: %v", err)
	}
	codes := memory.NewCodeStore()
	hash := "locked-reset-user"
	if err := codes.Set(ctx, hash, store.PhoneCode{
		Version:      store.PhoneCodeVersionCurrent,
		IssuedUserID: ownerA.ID,
		Phone:        ownerA.Phone,
		Code:         "654321",
		Channel:      codeChannelEmailLogin,
		MaxAttempts:  5,
	}, time.Minute); err != nil {
		t.Fatalf("seed reset code: %v", err)
	}
	delivery := &captureLoginCodeDelivery{}
	authSvc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345",
		WithLoginCodeDelivery(delivery), WithPhoneCodeDelivery(&captureOTPSender{}, 5))
	resetUserID, err := authSvc.ConsumeLoginEmailReset(ctx, ownerA.Phone, hash)
	if err != nil || resetUserID != ownerA.ID {
		t.Fatalf("ConsumeLoginEmailReset uid=%d err=%v", resetUserID, err)
	}
	users.setOwnerView(ownerA.Phone, ownerB, true)
	if err := accountSvc.ClearLoginEmail(ctx, resetUserID); err != nil {
		t.Fatalf("ClearLoginEmail exact A: %v", err)
	}
	if _, err := authSvc.SendPhoneCodeAfterLoginEmailReset(ctx, ownerA.Phone, resetUserID); !errors.Is(err, ErrCodeInvalid) {
		t.Fatalf("SendPhoneCodeAfterLoginEmailReset across A->B err=%v, want invalid", err)
	}
	if _, found, err := accountSvc.LoginEmail(ctx, ownerA.ID); err != nil || found {
		t.Fatalf("A login email found=%v err=%v, want cleared", found, err)
	}
	if email, found, err := accountSvc.LoginEmail(ctx, ownerB.ID); err != nil || !found || email != "b@example.test" {
		t.Fatalf("B login email=%q found=%v err=%v, want unchanged", email, found, err)
	}
	if len(delivery.requests) != 0 {
		t.Fatalf("owner B received reset replacement code: %+v", delivery.requests)
	}
}