perf: sync protocol and core hardening updates
This commit is contained in:
parent
152fed3b87
commit
4390ebf5a9
283 changed files with 29231 additions and 2295 deletions
554
internal/app/auth/signup_state_test.go
Normal file
554
internal/app/auth/signup_state_test.go
Normal file
|
|
@ -0,0 +1,554 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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 := 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)
|
||||
}
|
||||
if len(list.Dialogs) != 0 || len(list.Messages) != 0 {
|
||||
t.Fatalf("email SignUp created 777000 bootstrap state: dialogs=%+v messages=%+v", list.Dialogs, list.Messages)
|
||||
}
|
||||
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{}
|
||||
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345", WithLoginCodeDelivery(delivery))
|
||||
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 != codeChannelPhone {
|
||||
t.Fatalf("replacement code=%+v found=%v err=%v", rec, 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")
|
||||
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))
|
||||
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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue