owpengram-server/internal/app/auth/login_code_delivery_test.go

366 lines
15 KiB
Go

package auth
import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
type captureLoginCodeDelivery struct {
requests []domain.LoginCodeDeliveryRequest
result domain.LoginCodeDeliveryResult
err error
failAt int
}
func (d *captureLoginCodeDelivery) DeliverLoginCodeMessage(_ context.Context, req domain.LoginCodeDeliveryRequest) (domain.LoginCodeDeliveryResult, error) {
d.requests = append(d.requests, req)
if d.err != nil && (d.failAt == 0 || len(d.requests) == d.failAt) {
return domain.LoginCodeDeliveryResult{}, d.err
}
return d.result, nil
}
type trackingCodeStore struct {
store.CodeStore
lastSetHash string
deleted []string
deleteCtx []error
deleteErr error
}
func (s *trackingCodeStore) Set(ctx context.Context, hash string, code store.PhoneCode, ttl time.Duration) error {
s.lastSetHash = hash
return s.CodeStore.Set(ctx, hash, code, ttl)
}
func (s *trackingCodeStore) Del(ctx context.Context, hash string) error {
s.deleted = append(s.deleted, hash)
s.deleteCtx = append(s.deleteCtx, ctx.Err())
if s.deleteErr != nil {
return s.deleteErr
}
return s.CodeStore.Del(ctx, hash)
}
func TestExistingAccountSendCodeDeliversBeforeSignInAndDoesNotRedeliver(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
authz := memory.NewAuthorizationStore()
codes := memory.NewCodeStore()
u, err := users.Create(ctx, domain.User{Phone: "15550009201", FirstName: "Existing"})
if err != nil {
t.Fatalf("create user: %v", err)
}
delivery := &captureLoginCodeDelivery{result: domain.LoginCodeDeliveryResult{Created: true}}
svc := NewService(users, authz, codes, nil, nil, "12345", WithLoginCodeDelivery(delivery))
before := int(time.Now().Unix())
hash, err := svc.SendCode(ctx, "+1 555 000 9201")
if err != nil {
t.Fatalf("SendCode: %v", err)
}
if hash == "" || len(delivery.requests) != 1 {
t.Fatalf("SendCode hash=%q delivery calls=%d, want non-empty/1", hash, len(delivery.requests))
}
req := delivery.requests[0]
if req.UserID != u.ID || req.PhoneCodeHash != hash || req.Code != "12345" || req.Date < before || req.ExpiresAt < int64(before)+int64((5*time.Minute)/time.Second)-1 {
t.Fatalf("delivery request = %+v, want user=%d hash=%q code=12345 date>=%d", req, u.ID, hash, before)
}
if rec, found, err := codes.Get(ctx, hash); err != nil || !found || rec.Code != "12345" {
t.Fatalf("code after synchronous delivery = %+v found=%v err=%v", rec, found, err)
}
var key [8]byte
key[0] = 0x92
got, lateMessage, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: key}, "+15550009201", hash, "12345")
if err != nil || needSignUp || got.ID != u.ID {
t.Fatalf("SignIn user=%d needSignUp=%v err=%v, want %d/false", got.ID, needSignUp, err, u.ID)
}
if lateMessage.ID != 0 || len(delivery.requests) != 1 {
t.Fatalf("SignIn lateMessage=%+v delivery calls=%d, want zero/unchanged", lateMessage, len(delivery.requests))
}
}
func TestDeliveredLoginCodeSurvivesWrongSignInAndCancelWithoutDuplicate(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
u, err := users.Create(ctx, domain.User{Phone: "15550009208", FirstName: "Cancel"})
if err != nil {
t.Fatalf("create user: %v", err)
}
codes := memory.NewCodeStore()
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
events := memory.NewUpdateEventStore()
delivery := memory.NewLoginCodeDeliveryStore(messages, events)
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345", WithLoginCodeDelivery(delivery))
hash, err := svc.SendCode(ctx, "15550009208")
if err != nil {
t.Fatalf("SendCode: %v", err)
}
assertFacts := func(stage string) {
t.Helper()
history, historyErr := messages.ListByUser(ctx, u.ID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
Limit: 10,
})
durable, eventErr := events.ListAfter(ctx, u.ID, 0, 10)
if historyErr != nil || eventErr != nil || len(history.Messages) != 1 || len(durable) != 1 {
t.Fatalf("%s messages=%d events=%d historyErr=%v eventErr=%v, want 1/1", stage, len(history.Messages), len(durable), historyErr, eventErr)
}
}
assertFacts("after SendCode")
if _, late, _, err := svc.SignIn(ctx, domain.Authorization{}, "15550009208", hash, "00000"); !errors.Is(err, ErrCodeInvalid) || late.ID != 0 {
t.Fatalf("wrong SignIn late=%+v err=%v, want ErrCodeInvalid/no message", late, err)
}
assertFacts("after wrong SignIn")
if err := svc.CancelCode(ctx, "15550009208", hash); err != nil {
t.Fatalf("CancelCode: %v", err)
}
assertFacts("after CancelCode")
if _, late, _, err := svc.SignIn(ctx, domain.Authorization{}, "15550009208", hash, "12345"); !errors.Is(err, ErrCodeExpired) || late.ID != 0 {
t.Fatalf("SignIn after cancel late=%+v err=%v, want ErrCodeExpired/no message", late, err)
}
assertFacts("after canceled SignIn")
}
func TestCodeIssuedBeforeConcurrentOwnerCreationIsRejected(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
authz := memory.NewAuthorizationStore()
codes := memory.NewCodeStore()
delivery := &captureLoginCodeDelivery{}
svc := NewService(users, authz, codes, nil, nil, "12345", WithLoginCodeDelivery(delivery))
hash, err := svc.SendCode(ctx, "15550009209")
if err != nil {
t.Fatalf("SendCode before signup: %v", err)
}
rec, found, err := codes.Get(ctx, hash)
if err != nil || !found || rec.Version != store.PhoneCodeVersionCurrent || rec.IssuedUserID != 0 || rec.SignUpVerified || len(delivery.requests) != 0 {
t.Fatalf("pre-signup code=%+v found=%v err=%v deliveries=%d", rec, found, err, len(delivery.requests))
}
u, err := users.Create(ctx, domain.User{Phone: "15550009209", FirstName: "Concurrent"})
if err != nil {
t.Fatalf("concurrent create user: %v", err)
}
var key [8]byte
key[0] = 0x93
got, lateMessage, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: key}, "15550009209", hash, "12345")
if !errors.Is(err, ErrCodeInvalid) || needSignUp || got.ID != 0 || lateMessage.ID != 0 {
t.Fatalf("SignIn after owner creation got=%+v late=%+v needSignUp=%v err=%v, want invalid", got, lateMessage, needSignUp, err)
}
if len(delivery.requests) != 0 {
t.Fatalf("owner-transfer code was delivered to new owner: %+v", delivery.requests)
}
if bound, ok, err := svc.UserID(ctx, key); err != nil || ok || bound != 0 {
t.Fatalf("bound user=%d ok=%v err=%v, want no authorization (created uid=%d)", bound, ok, err, u.ID)
}
}
func TestExistingAccountRepeatedSendCodeDeliversEachIssuedHashWithoutSignIn(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
if _, err := users.Create(ctx, domain.User{Phone: "15550009202"}); err != nil {
t.Fatalf("create user: %v", err)
}
delivery := &captureLoginCodeDelivery{}
svc := NewService(users, memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345", WithLoginCodeDelivery(delivery))
first, err := svc.SendCode(ctx, "15550009202")
if err != nil {
t.Fatalf("first SendCode: %v", err)
}
second, err := svc.SendCode(ctx, "15550009202")
if err != nil {
t.Fatalf("second SendCode: %v", err)
}
if first == second || len(delivery.requests) != 2 {
t.Fatalf("hashes=%q/%q delivery calls=%d, want distinct/2", first, second, len(delivery.requests))
}
if delivery.requests[0].PhoneCodeHash != first || delivery.requests[1].PhoneCodeHash != second {
t.Fatalf("delivery hashes = %q/%q, want %q/%q", delivery.requests[0].PhoneCodeHash, delivery.requests[1].PhoneCodeHash, first, second)
}
}
func TestExistingAccountSendCodeDeliveryFailureRevokesCode(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
if _, err := users.Create(ctx, domain.User{Phone: "15550009203"}); err != nil {
t.Fatalf("create user: %v", err)
}
baseCodes := memory.NewCodeStore()
codes := &trackingCodeStore{CodeStore: baseCodes}
deliveryCause := errors.New("durable write failed")
delivery := &captureLoginCodeDelivery{err: deliveryCause}
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345", WithLoginCodeDelivery(delivery))
hash, err := svc.SendCode(ctx, "15550009203")
if hash != "" || !errors.Is(err, ErrLoginCodeDeliveryFailed) || !errors.Is(err, deliveryCause) {
t.Fatalf("SendCode hash=%q err=%v, want empty ErrLoginCodeDeliveryFailed+cause", hash, err)
}
if codes.lastSetHash == "" || len(codes.deleted) != 1 || codes.deleted[0] != codes.lastSetHash {
t.Fatalf("set hash=%q deleted=%v, want exact rollback", codes.lastSetHash, codes.deleted)
}
if _, found, getErr := baseCodes.Get(ctx, codes.lastSetHash); getErr != nil || found {
t.Fatalf("rolled-back hash found=%v err=%v", found, getErr)
}
}
func TestExistingAccountAmbiguousDeliveryPreservesCodeForIdempotentRetry(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
if _, err := users.Create(ctx, domain.User{Phone: "15550009213"}); err != nil {
t.Fatalf("create user: %v", err)
}
baseCodes := memory.NewCodeStore()
codes := &trackingCodeStore{CodeStore: baseCodes}
delivery := &captureLoginCodeDelivery{err: domain.ErrLoginCodeDeliveryCommitAmbiguous}
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345", WithLoginCodeDelivery(delivery))
hash, err := svc.SendCode(ctx, "15550009213")
if hash != "" || !errors.Is(err, ErrLoginCodeDeliveryFailed) || !errors.Is(err, domain.ErrLoginCodeDeliveryCommitAmbiguous) {
t.Fatalf("SendCode hash=%q err=%v, want ambiguous delivery failure", hash, err)
}
if codes.lastSetHash == "" || len(codes.deleted) != 0 {
t.Fatalf("ambiguous delivery set=%q deleted=%v, want code preserved", codes.lastSetHash, codes.deleted)
}
if rec, found, getErr := baseCodes.Get(ctx, codes.lastSetHash); getErr != nil || !found || rec.Code != "12345" {
t.Fatalf("ambiguous delivery code=%+v found=%v err=%v", rec, found, getErr)
}
}
func TestExplicitDeliveryFailureRollsBackWithDetachedContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
users := memory.NewUserStore()
if _, err := users.Create(context.Background(), domain.User{Phone: "15550009214"}); err != nil {
t.Fatalf("create user: %v", err)
}
baseCodes := memory.NewCodeStore()
codes := &trackingCodeStore{CodeStore: baseCodes}
delivery := &captureLoginCodeDelivery{err: errors.New("definite rollback")}
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345", WithLoginCodeDelivery(delivery))
if hash, err := svc.SendCode(ctx, "15550009214"); hash != "" || !errors.Is(err, ErrLoginCodeDeliveryFailed) {
t.Fatalf("SendCode hash=%q err=%v, want definite failure", hash, err)
}
if len(codes.deleted) != 1 || len(codes.deleteCtx) != 1 || codes.deleteCtx[0] != nil {
t.Fatalf("rollback deleted=%v ctxErr=%v, want one detached delete", codes.deleted, codes.deleteCtx)
}
if _, found, err := baseCodes.Get(context.Background(), codes.lastSetHash); err != nil || found {
t.Fatalf("detached rollback found=%v err=%v", found, err)
}
}
func TestExistingAccountMissingDeliveryFailsClosedAndRevokesCode(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
if _, err := users.Create(ctx, domain.User{Phone: "15550009204"}); err != nil {
t.Fatalf("create user: %v", err)
}
baseCodes := memory.NewCodeStore()
codes := &trackingCodeStore{CodeStore: baseCodes}
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345")
hash, err := svc.SendCode(ctx, "15550009204")
if hash != "" || !errors.Is(err, ErrLoginCodeDeliveryUnavailable) {
t.Fatalf("SendCode hash=%q err=%v, want unavailable", hash, err)
}
if codes.lastSetHash == "" {
t.Fatal("missing delivery was checked before code creation; want rollback path covered")
}
if _, found, getErr := baseCodes.Get(ctx, codes.lastSetHash); getErr != nil || found {
t.Fatalf("unavailable delivery hash found=%v err=%v", found, getErr)
}
}
func TestExistingAccountResendDeliversNewHashAndInvalidatesOld(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
if _, err := users.Create(ctx, domain.User{Phone: "15550009205"}); err != nil {
t.Fatalf("create user: %v", err)
}
codes := memory.NewCodeStore()
delivery := &captureLoginCodeDelivery{}
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345", WithLoginCodeDelivery(delivery))
oldHash, err := svc.SendCode(ctx, "15550009205")
if err != nil {
t.Fatalf("SendCode: %v", err)
}
newHash, err := svc.ResendCode(ctx, "15550009205", oldHash)
if err != nil {
t.Fatalf("ResendCode: %v", err)
}
if oldHash == newHash || len(delivery.requests) != 2 || delivery.requests[1].PhoneCodeHash != newHash {
t.Fatalf("old/new=%q/%q deliveries=%+v", oldHash, newHash, delivery.requests)
}
if _, found, err := codes.Get(ctx, oldHash); err != nil || found {
t.Fatalf("old code found=%v err=%v", found, err)
}
if _, found, err := codes.Get(ctx, newHash); err != nil || !found {
t.Fatalf("new code found=%v err=%v", found, err)
}
}
func TestExistingAccountResendDeliveryFailureLeavesNoUsableCode(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
if _, err := users.Create(ctx, domain.User{Phone: "15550009206"}); err != nil {
t.Fatalf("create user: %v", err)
}
codes := memory.NewCodeStore()
delivery := &captureLoginCodeDelivery{err: errors.New("second delivery failed"), failAt: 2}
svc := NewService(users, memory.NewAuthorizationStore(), codes, nil, nil, "12345", WithLoginCodeDelivery(delivery))
oldHash, err := svc.SendCode(ctx, "15550009206")
if err != nil {
t.Fatalf("SendCode: %v", err)
}
newHash, err := svc.ResendCode(ctx, "15550009206", oldHash)
if newHash != "" || !errors.Is(err, ErrLoginCodeDeliveryFailed) || len(delivery.requests) != 2 {
t.Fatalf("ResendCode hash=%q err=%v deliveries=%d", newHash, err, len(delivery.requests))
}
failedHash := delivery.requests[1].PhoneCodeHash
for _, hash := range []string{oldHash, failedHash} {
if _, found, getErr := codes.Get(ctx, hash); getErr != nil || found {
t.Fatalf("failed resend hash %q found=%v err=%v", hash, found, getErr)
}
}
}
func TestConfiguredEmailLoginDoesNotLeakCodeThroughAppDelivery(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
if _, err := users.Create(ctx, domain.User{Phone: "15550009207"}); err != nil {
t.Fatalf("create user: %v", err)
}
emails := &testLoginEmailStore{emails: map[string]string{"15550009207": "secure@example.test"}}
mailSender := &testMailSender{}
delivery := &captureLoginCodeDelivery{}
svc := NewService(users, memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345",
WithLoginEmail(LoginEmailOptions{Enabled: true, CodeLength: 6, Store: emails, Sender: mailSender}),
WithLoginCodeDelivery(delivery),
)
if _, err := svc.SendCode(ctx, "15550009207"); err != nil {
t.Fatalf("SendCode: %v", err)
}
if mailSender.to != "secure@example.test" || mailSender.code == "" {
t.Fatalf("email delivery = %q/%q", mailSender.to, mailSender.code)
}
if len(delivery.requests) != 0 {
t.Fatalf("email code leaked into app delivery: %+v", delivery.requests)
}
}