package test import ( domainUser "RenewCMS/internal/domain/user" persistence "RenewCMS/internal/infrastructure/persistence/repositories" "errors" "fmt" "sync" "testing" "time" "gorm.io/gorm" ) func TestUserRepositoryEmailChangeContract(t *testing.T) { backends := []contractBackend{ {name: "sqlite", open: openContractSQLite}, {name: "mysql", open: openContractMySQL}, {name: "postgres", open: openContractPostgres}, } expiration := time.Now().Add(2 * time.Hour).UTC().Truncate(time.Second) cases := []struct { name string run func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User) }{ {"setting a pending change keeps the email and clearing empties the three fields", func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User) { set, err := users.SetPendingEmailChange(marta.ID, "new@renew.example", "hash", expiration) if err != nil || set.PendingEmail != "new@renew.example" || set.EmailChangeCode != "hash" || !set.EmailChangeExpiration.Equal(expiration) || set.Email != marta.Email { t.Fatalf("set %+v %v", set, err) } cleared, err := users.ClearPendingEmailChange(marta.ID) if err != nil || cleared.PendingEmail != "" || cleared.EmailChangeCode != "" || !cleared.EmailChangeExpiration.IsZero() { t.Errorf("cleared %+v %v", cleared, err) } }}, {"an address is in use whatever its case, except for the asking user", func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User) { taken, err := users.IsEmailInUse("INES@renew.example", marta.ID) own, ownErr := users.IsEmailInUse(marta.Email, marta.ID) free, freeErr := users.IsEmailInUse("free@renew.example", marta.ID) if err != nil || !taken || ownErr != nil || own || freeErr != nil || free { t.Errorf("taken %v %v own %v %v free %v %v", taken, err, own, ownErr, free, freeErr) } }}, {"confirming moves the pending address to the email once", func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User) { _, _ = users.SetPendingEmailChange(marta.ID, "new@renew.example", "hash", expiration) confirmed, err := users.ConfirmEmailChange(marta.ID, "new@renew.example", "hash", time.Now()) _, again := users.ConfirmEmailChange(marta.ID, "new@renew.example", "hash", time.Now()) if err != nil || confirmed.Email != "new@renew.example" || confirmed.PendingEmail != "" || confirmed.EmailChangeCode != "" { t.Errorf("confirmed %+v %v", confirmed, err) } if !errors.Is(again, domainUser.ErrInvalidEmailChangeCode) { t.Errorf("second confirm %v", again) } }}, {"confirming after the expiry changes nothing", func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User) { _, _ = users.SetPendingEmailChange(marta.ID, "new@renew.example", "hash", expiration) _, err := users.ConfirmEmailChange(marta.ID, "new@renew.example", "hash", expiration.Add(time.Second)) stored, _ := users.Get(marta.ID) if !errors.Is(err, domainUser.ErrInvalidEmailChangeCode) || stored.Email != marta.Email { t.Errorf("err %v stored %+v", err, stored) } }}, {"confirming with another code changes nothing", func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User) { _, _ = users.SetPendingEmailChange(marta.ID, "new@renew.example", "hash", expiration) _, err := users.ConfirmEmailChange(marta.ID, "new@renew.example", "other", time.Now()) stored, _ := users.Get(marta.ID) if !errors.Is(err, domainUser.ErrInvalidEmailChangeCode) || stored.Email != marta.Email || stored.PendingEmail != "new@renew.example" { t.Errorf("err %v stored %+v", err, stored) } }}, {"confirming a taken address clears the pending change and keeps the email", func(t *testing.T, users *persistence.UserRepository, marta domainUser.User, ines domainUser.User) { _, _ = users.SetPendingEmailChange(marta.ID, "Ines@Renew.example", "hash", expiration) _, err := users.ConfirmEmailChange(marta.ID, "Ines@Renew.example", "hash", time.Now()) stored, _ := users.Get(marta.ID) if !errors.Is(err, domainUser.ErrEmailUnavailable) || stored.Email != marta.Email || stored.PendingEmail != "" || stored.EmailChangeCode != "" { t.Errorf("err %v stored %+v", err, stored) } }}, } for _, backend := range backends { t.Run(backend.name, func(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { db := backend.open(t) users := persistence.NewUserRepository(db) marta := mustCreateEmailChangeUser(t, users, "marta", "marta@renew.example") ines := mustCreateEmailChangeUser(t, users, "ines", "ines@renew.example") c.run(t, users, marta, ines) }) } }) } } func mustCreateEmailChangeUser(t *testing.T, users *persistence.UserRepository, username string, email string) domainUser.User { t.Helper() created, err := users.Create(domainUser.User{Username: username, Email: email, Password: "hash", IsVerified: true}) if err != nil { t.Fatalf("create %s: %v", username, err) } return created } func TestUserRepositoryConcurrentEmailChangeOnSQLite(t *testing.T) { const contenders = 8 expiration := time.Now().Add(2 * time.Hour) users := persistence.NewUserRepository(openContractSQLite(t)) ids := make([]uint32, 0, contenders) for i := 0; i < contenders; i++ { created := mustCreateEmailChangeUser(t, users, fmt.Sprintf("user%d", i), fmt.Sprintf("user%d@renew.example", i)) if _, err := users.SetPendingEmailChange(created.ID, "shared@renew.example", "hash", expiration); err != nil { t.Fatal(err) } ids = append(ids, created.ID) } results := make([]error, contenders) var group sync.WaitGroup for i, id := range ids { group.Add(1) go func() { defer group.Done() _, results[i] = users.ConfirmEmailChange(id, "shared@renew.example", "hash", time.Now()) }() } group.Wait() succeeded := 0 for _, err := range results { switch { case err == nil: succeeded++ case !errors.Is(err, domainUser.ErrEmailUnavailable): t.Errorf("a losing confirm must answer ErrEmailUnavailable, got %v", err) } } if succeeded != 1 { t.Errorf("expected exactly one success, got %d", succeeded) } for _, id := range ids { stored, _ := users.Get(id) if stored.PendingEmail != "" { t.Errorf("user %d still has a pending change", id) } } } func failUserUpdates(t *testing.T, db *gorm.DB, failures ...error) { t.Helper() var mu sync.Mutex remaining := failures err := db.Callback().Update().Before("gorm:update").Register("test:fail_user_updates", func(tx *gorm.DB) { mu.Lock() defer mu.Unlock() if len(remaining) == 0 || tx.Statement.Table != "users" { return } failure := remaining[0] remaining = remaining[1:] if failure != nil { _ = tx.AddError(failure) } }) if err != nil { t.Fatal(err) } } func TestUserRepositoryConfirmEmailChangeUniqueViolationRace(t *testing.T) { expiration := time.Now().Add(2 * time.Hour) setup := func(t *testing.T, failures ...error) (*persistence.UserRepository, domainUser.User) { db := openContractSQLite(t) users := persistence.NewUserRepository(db) account := mustCreateEmailChangeUser(t, users, "racer", "racer@renew.example") if _, err := users.SetPendingEmailChange(account.ID, "raced@renew.example", "hash", expiration); err != nil { t.Fatal(err) } failUserUpdates(t, db, failures...) return users, account } t.Run("a unique violation clears the pending change and answers ErrEmailUnavailable", func(t *testing.T) { users, account := setup(t, gorm.ErrDuplicatedKey) updated, err := users.ConfirmEmailChange(account.ID, "raced@renew.example", "hash", time.Now()) if !errors.Is(err, domainUser.ErrEmailUnavailable) { t.Fatalf("expected ErrEmailUnavailable, got %v", err) } if updated.ID != 0 { t.Errorf("no user must be returned, got %+v", updated) } stored, _ := users.Get(account.ID) if stored.PendingEmail != "" || stored.Email != "racer@renew.example" { t.Errorf("pending change must be cleared and email kept, got %+v", stored) } }) t.Run("a failing cleanup after a unique violation surfaces its own error", func(t *testing.T) { cleanupFailure := errors.New("cleanup exploded") users, account := setup(t, gorm.ErrDuplicatedKey, cleanupFailure) _, err := users.ConfirmEmailChange(account.ID, "raced@renew.example", "hash", time.Now()) if !errors.Is(err, cleanupFailure) { t.Fatalf("expected the cleanup error, got %v", err) } if errors.Is(err, domainUser.ErrEmailUnavailable) { t.Error("a failed cleanup must not be reported as ErrEmailUnavailable") } }) t.Run("a non-unique database error is returned untouched", func(t *testing.T) { failure := errors.New("disk on fire") users, account := setup(t, failure) _, err := users.ConfirmEmailChange(account.ID, "raced@renew.example", "hash", time.Now()) if !errors.Is(err, failure) { t.Fatalf("expected the database error, got %v", err) } }) }