229 lines
8.9 KiB
Go
229 lines
8.9 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|