feat: change email, export my data, delete account (with data anonymization)
This commit is contained in:
1 parent
bb191c389a
commit
10a66cd979
123 files changed
+10789
-76
No files matched your search
@@ -0,0 +1,228 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user