feat: change email, export my data, delete account (with data anonymization)

This commit is contained in:
Florian Sylvain committed 2026-10-30 20:19:07 +01:00
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)
}
})
}