package test import ( "RenewCMS/api/handlers/auth" "RenewCMS/api/middleware" "RenewCMS/api/pkg/authtoken" validator "RenewCMS/api/pkg/validator" "RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/mail" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/useCases" "bytes" "context" "errors" "log" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "github.com/go-chi/chi/v5" ) const ( changeEmailHost = "https://cms.example.com" changeEmailOld = "marta@renew.example" changeEmailNew = "marta.alves@atelier-renew.example" changeEmailPassword = "limewash2026" ) type changeEmailDispatcher struct { mu sync.Mutex jobs []func() } func (d *changeEmailDispatcher) Dispatch(job func()) { d.mu.Lock() defer d.mu.Unlock() d.jobs = append(d.jobs, job) } func (d *changeEmailDispatcher) Drain() { d.mu.Lock() jobs := d.jobs d.jobs = nil d.mu.Unlock() for _, job := range jobs { job() } } func (d *changeEmailDispatcher) pending() int { d.mu.Lock() defer d.mu.Unlock() return len(d.jobs) } var _ mail.Dispatcher = &changeEmailDispatcher{} type changeEmailFixture struct { dispatcher *changeEmailDispatcher users *mockUserRepo mail *mockMailRepo recorder *recordingRecorder token *authtoken.Auth router *chi.Mux } func newChangeEmailFixture(t *testing.T) *changeEmailFixture { t.Helper() validator.InitValidator() f := &changeEmailFixture{ dispatcher: &changeEmailDispatcher{}, users: newMockUserRepo(), mail: newMockMailRepo(), recorder: &recordingRecorder{}, token: authtoken.New([]byte("test-secret")), } f.users.users[7] = user.User{ID: 7, Username: "marta", Email: changeEmailOld, Password: "hashed:" + changeEmailPassword, IsVerified: true} f.users.users[8] = user.User{ID: 8, Username: "ines", Email: "ines@renew.example", Password: "hashed:other", IsVerified: true} f.users.nextID = 8 hasher := &stubHasher{} getUser := useCases.NewGetUserUseCase(f.users) handler := auth.NewEmailChangeHandler( useCases.NewRequestEmailChangeUseCase(f.users, hasher, f.mail, f.dispatcher, changeEmailHost, time.Now, f.recorder), useCases.NewResendEmailChangeUseCase(f.users, hasher, f.mail, f.dispatcher, changeEmailHost, time.Now, f.recorder), useCases.NewCancelEmailChangeUseCase(f.users, f.recorder), useCases.NewConfirmEmailChangeUseCase(f.users, hasher, time.Now, f.recorder), ) f.router = chi.NewRouter() f.router.Route("/v1/auth", func(r chi.Router) { r.Use(middleware.RequireSession) r.Use(authtoken.Verifier(f.token)) r.Use(middleware.RequireSignedIn) r.Use(authtoken.Authenticator) r.Use(middleware.RequireCurrentToken(getUser)) r.Use(middleware.RequireVerifiedUser(getUser)) r.Delete("/email/change", handler.Cancel) r.Post("/email/confirm", handler.Confirm) r.Group(func(r chi.Router) { r.Use(middleware.NewUserRateLimiter(5, 15*time.Minute)) r.Post("/email/change", handler.Request) r.Post("/email/resend", handler.Resend) }) }) return f } func (f *changeEmailFixture) as(t *testing.T, userId uint32, method, target, body string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, target, bytes.NewBufferString(body)) req.AddCookie(displayNameCookie(t, f.token, userId, 0)) rec := httptest.NewRecorder() f.router.ServeHTTP(rec, req) f.dispatcher.Drain() return rec } func (f *changeEmailFixture) request(t *testing.T, body string) *httptest.ResponseRecorder { t.Helper() return f.as(t, 7, http.MethodPost, "/v1/auth/email/change", body) } func (f *changeEmailFixture) requestFor(t *testing.T, newEmail string) *httptest.ResponseRecorder { t.Helper() return f.request(t, `{"new_email": "`+newEmail+`", "current_password": "`+changeEmailPassword+`"}`) } func (f *changeEmailFixture) resend(t *testing.T) *httptest.ResponseRecorder { t.Helper() return f.as(t, 7, http.MethodPost, "/v1/auth/email/resend", "") } func (f *changeEmailFixture) cancel(t *testing.T) *httptest.ResponseRecorder { t.Helper() return f.as(t, 7, http.MethodDelete, "/v1/auth/email/change", "") } func (f *changeEmailFixture) confirm(t *testing.T, userId uint32, code string) *httptest.ResponseRecorder { t.Helper() return f.as(t, userId, http.MethodPost, "/v1/auth/email/confirm", `{"code": "`+code+`"}`) } func (f *changeEmailFixture) lastVerificationCode(t *testing.T) string { t.Helper() for i := len(f.mail.SentEmails) - 1; i >= 0; i-- { if data, ok := f.mail.SentEmails[i].Data.(useCases.MailVerifyNewEmailData); ok { return data.VerificationCode } } t.Fatal("no verify-new-email mail was sent") return "" } func (f *changeEmailFixture) mailsTo(address string) []recordedMail { var sent []recordedMail for _, m := range f.mail.SentEmails { if m.Address == address { sent = append(sent, m) } } return sent } func (f *changeEmailFixture) marta() user.User { return f.users.users[7] } func assertChangeEmailResponse(t *testing.T, rec *httptest.ResponseRecorder, status int, body string) { t.Helper() if rec.Code != status { t.Fatalf("expected status %d, got %d: %s", status, rec.Code, rec.Body.String()) } if strings.TrimSpace(rec.Body.String()) != body { t.Fatalf("expected body %q, got %q", body, rec.Body.String()) } } func assertNothingPending(t *testing.T, f *changeEmailFixture) { t.Helper() if marta := f.marta(); marta.PendingEmail != "" || marta.EmailChangeCode != "" || !marta.EmailChangeExpiration.IsZero() { t.Errorf("expected no pending change, got %+v", marta) } } func TestAccountChangeEmailRequestHTTP(t *testing.T) { t.Run("A valid request stores a pending change and sends two mails", func(t *testing.T) { f := newChangeEmailFixture(t) rec := f.requestFor(t, changeEmailNew) assertChangeEmailResponse(t, rec, http.StatusOK, `{"pending_email":"`+changeEmailNew+`"}`) marta := f.marta() if marta.Email != changeEmailOld { t.Errorf("users.email must be untouched, got %q", marta.Email) } rawCode := f.lastVerificationCode(t) if marta.PendingEmail != changeEmailNew || marta.EmailChangeCode == "" || marta.EmailChangeCode == rawCode { t.Errorf("expected the pending email and a hashed code, got %+v", marta) } if delta := time.Until(marta.EmailChangeExpiration.Add(-2 * time.Hour)); delta > 5*time.Second || delta < -5*time.Second { t.Errorf("expected the expiration within 5 seconds of now plus 2 hours, got %v", marta.EmailChangeExpiration) } if len(f.mail.SentEmails) != 2 { t.Fatalf("expected two mails, got %d", len(f.mail.SentEmails)) } verify := f.mailsTo(changeEmailNew) if len(verify) != 1 || verify[0].Template != "verify-new-email" { t.Fatalf("expected one verify-new-email mail to the new address, got %+v", verify) } if data := verify[0].Data.(useCases.MailVerifyNewEmailData); data.Host != changeEmailHost || data.Username != "marta" || data.VerificationCode != rawCode { t.Errorf("unexpected verify data %+v", data) } notice := f.mailsTo(changeEmailOld) if len(notice) != 1 || notice[0].Template != "email-change-notice" { t.Fatalf("expected one email-change-notice to the old address, got %+v", notice) } if data := notice[0].Data.(useCases.MailEmailChangeNoticeData); data.Host != changeEmailHost || data.Username != "marta" || data.NewEmail != changeEmailNew { t.Errorf("unexpected notice data %+v", data) } if len(f.recorder.events) != 1 { t.Fatalf("expected one audit event, got %d", len(f.recorder.events)) } event := f.recorder.events[0] if event.Action != "user.request_email_change" || event.Category != audit.CategoryUsers || event.Outcome != audit.OutcomeSuccess || !event.Security || event.Target.ID != "7" || len(event.Params) != 0 { t.Errorf("unexpected audit event %+v", event) } }) refused := []struct { name string body string status int text string }{ {"an invalid address", `{"new_email": "marta.alves@atelier", "current_password": "limewash2026"}`, http.StatusBadRequest, "NewEmail"}, {"an empty address", `{"new_email": "", "current_password": "limewash2026"}`, http.StatusBadRequest, "NewEmail"}, {"an empty password", `{"new_email": "marta.alves@atelier-renew.example", "current_password": ""}`, http.StatusBadRequest, "CurrentPassword"}, {"a body that is not json", `not-json`, http.StatusBadRequest, "The request cannot be processed due to a mismatch in the format of the body."}, {"the current address in another case", `{"new_email": "MARTA@renew.example", "current_password": "limewash2026"}`, http.StatusBadRequest, "same_email"}, {"a wrong password", `{"new_email": "marta.alves@atelier-renew.example", "current_password": "nope"}`, http.StatusForbidden, "wrong_password"}, } for _, tc := range refused { t.Run("A refused request changes nothing and sends nothing: "+tc.name, func(t *testing.T) { f := newChangeEmailFixture(t) rec := f.request(t, tc.body) if rec.Code != tc.status || !strings.Contains(rec.Body.String(), tc.text) { t.Errorf("expected %d containing %q, got %d %q", tc.status, tc.text, rec.Code, rec.Body.String()) } assertNothingPending(t, f) if len(f.mail.SentEmails) != 0 { t.Errorf("expected no mail, got %d", len(f.mail.SentEmails)) } }) } t.Run("A wrong password records a failed security event", func(t *testing.T) { f := newChangeEmailFixture(t) f.request(t, `{"new_email": "`+changeEmailNew+`", "current_password": "nope"}`) if len(f.recorder.events) != 1 { t.Fatalf("expected one audit event, got %d", len(f.recorder.events)) } event := f.recorder.events[0] if event.Action != "user.request_email_change" || event.Outcome != audit.OutcomeFailed || !event.Security || event.FailureReason == nil || *event.FailureReason != "Wrong password." { t.Errorf("unexpected audit event %+v", event) } }) t.Run("An address already in use answers like success and sends no verification mail", func(t *testing.T) { f := newChangeEmailFixture(t) fresh := f.requestFor(t, changeEmailNew) freshBody := fresh.Body.String() g := newChangeEmailFixture(t) taken := g.requestFor(t, "Ines@Renew.example") if taken.Code != fresh.Code || strings.ReplaceAll(taken.Body.String(), "Ines@Renew.example", changeEmailNew) != freshBody { t.Errorf("expected the same response as a free address, got %d %q", taken.Code, taken.Body.String()) } for name, values := range fresh.Header() { if name != "Date" && strings.Join(taken.Header()[name], ",") != strings.Join(values, ",") { t.Errorf("header %s differs", name) } } if g.marta().PendingEmail != "Ines@Renew.example" { t.Errorf("expected the pending change to be stored, got %q", g.marta().PendingEmail) } if len(g.mailsTo("Ines@Renew.example"))+len(g.mailsTo("ines@renew.example")) != 0 { t.Error("no mail may reach the taken address") } if notice := g.mailsTo(changeEmailOld); len(notice) != 1 || notice[0].Template != "email-change-notice" { t.Errorf("expected the notice to the old address, got %+v", notice) } }) t.Run("A new request replaces the previous link", func(t *testing.T) { f := newChangeEmailFixture(t) f.requestFor(t, "a@renew.example") first := f.lastVerificationCode(t) f.requestFor(t, "b@renew.example") second := f.lastVerificationCode(t) old := f.confirm(t, 7, first) current := f.confirm(t, 7, second) assertChangeEmailResponse(t, old, http.StatusForbidden, "invalid_email_change_code") assertChangeEmailResponse(t, current, http.StatusOK, `{"email":"b@renew.example"}`) }) t.Run("A failed send is logged and the response is unchanged", func(t *testing.T) { f := newChangeEmailFixture(t) f.mail.fail.failNextWith("Send", errors.New("smtp: connection refused")) var logBuf bytes.Buffer previous := log.Writer() log.SetOutput(&logBuf) defer log.SetOutput(previous) rec := f.requestFor(t, changeEmailNew) assertChangeEmailResponse(t, rec, http.StatusOK, `{"pending_email":"`+changeEmailNew+`"}`) if f.marta().PendingEmail != changeEmailNew { t.Error("the pending change must be stored") } logged := logBuf.String() if !strings.Contains(logged, "smtp: connection refused") || !strings.Contains(logged, "verification") { t.Errorf("expected the log to name the failed send and the error, got %q", logged) } if f.marta().EmailChangeCode != "" && strings.Contains(logged, strings.TrimPrefix(f.marta().EmailChangeCode, "hashed:")) { t.Error("the log must not contain the raw code") } }) t.Run("A failed notice send is logged and the response is unchanged", func(t *testing.T) { f := newChangeEmailFixture(t) f.mail.fail.failNextWith("Send", errors.New("smtp: notice refused")) var logBuf bytes.Buffer previous := log.Writer() log.SetOutput(&logBuf) defer log.SetOutput(previous) rec := f.requestFor(t, "ines@renew.example") assertChangeEmailResponse(t, rec, http.StatusOK, `{"pending_email":"ines@renew.example"}`) logged := logBuf.String() if !strings.Contains(logged, "smtp: notice refused") || !strings.Contains(logged, "notice") { t.Errorf("expected the log to name the failed notice send, got %q", logged) } }) t.Run("A successful send logs nothing", func(t *testing.T) { f := newChangeEmailFixture(t) var logBuf bytes.Buffer previous := log.Writer() log.SetOutput(&logBuf) defer log.SetOutput(previous) f.requestFor(t, changeEmailNew) if logBuf.Len() != 0 { t.Errorf("expected no log output, got %q", logBuf.String()) } }) t.Run("The sixth request in 15 minutes is rate limited", func(t *testing.T) { f := newChangeEmailFixture(t) for i := 0; i < 3; i++ { f.requestFor(t, changeEmailNew) } f.resend(t) f.resend(t) sentBefore := len(f.mail.SentEmails) eventsBefore := len(f.recorder.events) codeBefore := f.marta().EmailChangeCode rec := f.requestFor(t, "other@renew.example") assertChangeEmailResponse(t, rec, http.StatusTooManyRequests, "Too many requests. Please try again later.") if rec.Header().Get("Retry-After") == "" { t.Error("expected a Retry-After header") } if len(f.mail.SentEmails) != sentBefore || len(f.recorder.events) != eventsBefore || f.marta().EmailChangeCode != codeBefore || f.marta().PendingEmail != changeEmailNew { t.Error("nothing may be sent or stored once limited") } }) t.Run("The endpoints refuse a missing session", func(t *testing.T) { f := newChangeEmailFixture(t) req := httptest.NewRequest(http.MethodPost, "/v1/auth/email/change", strings.NewReader("{}")) rec := httptest.NewRecorder() f.router.ServeHTTP(rec, req) assertChangeEmailResponse(t, rec, http.StatusUnauthorized, "You must be logged in to access this resource.") }) } func TestAccountChangeEmailResendAndCancelHTTP(t *testing.T) { t.Run("Resend issues a new code and invalidates the old one", func(t *testing.T) { f := newChangeEmailFixture(t) f.requestFor(t, changeEmailNew) first := f.lastVerificationCode(t) f.mail.SentEmails = nil f.recorder.events = nil rec := f.resend(t) assertChangeEmailResponse(t, rec, http.StatusOK, `{"pending_email":"`+changeEmailNew+`"}`) if len(f.mail.SentEmails) != 1 || f.mail.SentEmails[0].Template != "verify-new-email" || f.mail.SentEmails[0].Address != changeEmailNew { t.Fatalf("expected one verify-new-email mail only, got %+v", f.mail.SentEmails) } second := f.lastVerificationCode(t) if second == first { t.Error("expected a new code") } if delta := time.Until(f.marta().EmailChangeExpiration.Add(-2 * time.Hour)); delta > 5*time.Second || delta < -5*time.Second { t.Errorf("expected the expiration reset to now plus 2 hours, got %v", f.marta().EmailChangeExpiration) } resendEvents := f.recorder.events assertChangeEmailResponse(t, f.confirm(t, 7, first), http.StatusForbidden, "invalid_email_change_code") if len(resendEvents) != 1 || resendEvents[0].Action != "user.resend_email_change" { t.Errorf("expected one user.resend_email_change event, got %+v", resendEvents) } }) t.Run("Resend for a taken address sends nothing", func(t *testing.T) { f := newChangeEmailFixture(t) f.users.users[7] = func() user.User { marta := f.users.users[7] marta.PendingEmail = "ines@renew.example" marta.EmailChangeCode = "hashed:old" return marta }() rec := f.resend(t) assertChangeEmailResponse(t, rec, http.StatusOK, `{"pending_email":"ines@renew.example"}`) if len(f.mail.SentEmails) != 0 { t.Errorf("expected no mail, got %d", len(f.mail.SentEmails)) } }) t.Run("Resend with nothing pending", func(t *testing.T) { f := newChangeEmailFixture(t) assertChangeEmailResponse(t, f.resend(t), http.StatusNotFound, "no_pending_email_change") }) t.Run("Cancel clears the pending change", func(t *testing.T) { f := newChangeEmailFixture(t) f.requestFor(t, changeEmailNew) code := f.lastVerificationCode(t) f.recorder.events = nil rec := f.cancel(t) if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 { t.Errorf("expected an empty 204, got %d %q", rec.Code, rec.Body.String()) } assertNothingPending(t, f) cancelEvents := f.recorder.events assertChangeEmailResponse(t, f.confirm(t, 7, code), http.StatusForbidden, "invalid_email_change_code") if len(cancelEvents) != 1 || cancelEvents[0].Action != "user.cancel_email_change" { t.Errorf("expected one user.cancel_email_change event, got %+v", cancelEvents) } }) t.Run("Cancel keeps the pending change and records nothing when the store fails", func(t *testing.T) { f := newChangeEmailFixture(t) f.requestFor(t, changeEmailNew) f.recorder.events = nil f.users.fail.failNextWith("ClearPendingEmailChange", errors.New("db down")) rec := f.cancel(t) if rec.Code != http.StatusInternalServerError { t.Errorf("expected 500, got %d %q", rec.Code, rec.Body.String()) } if f.marta().PendingEmail != changeEmailNew { t.Error("the pending change must remain") } if len(f.recorder.events) != 1 || f.recorder.events[0].Action != "user.cancel_email_change" || f.recorder.events[0].Outcome != audit.OutcomeFailed { t.Errorf("expected one failed user.cancel_email_change event, got %+v", f.recorder.events) } }) t.Run("Cancel with nothing pending is harmless", func(t *testing.T) { f := newChangeEmailFixture(t) rec := f.cancel(t) if rec.Code != http.StatusNoContent || len(f.recorder.events) != 0 { t.Errorf("expected 204 and no event, got %d and %d events", rec.Code, len(f.recorder.events)) } }) } func TestAccountChangeEmailConfirmHTTP(t *testing.T) { t.Run("A valid code changes the email and is single use", func(t *testing.T) { f := newChangeEmailFixture(t) f.requestFor(t, changeEmailNew) code := f.lastVerificationCode(t) f.recorder.events = nil rec := f.confirm(t, 7, code) assertChangeEmailResponse(t, rec, http.StatusOK, `{"email":"`+changeEmailNew+`"}`) if f.marta().Email != changeEmailNew || f.marta().TokenVersion != 0 { t.Errorf("expected the new email and an unchanged token version, got %+v", f.marta()) } assertNothingPending(t, f) if len(f.recorder.events) != 1 || f.recorder.events[0].Action != "user.confirm_email_change" || !f.recorder.events[0].Security { t.Errorf("expected one security user.confirm_email_change event, got %+v", f.recorder.events) } assertChangeEmailResponse(t, f.confirm(t, 7, code), http.StatusForbidden, "invalid_email_change_code") }) t.Run("A code that does not work answers one 403: nothing is pending", func(t *testing.T) { f := newChangeEmailFixture(t) assertChangeEmailResponse(t, f.confirm(t, 7, "any"), http.StatusForbidden, "invalid_email_change_code") if f.marta().Email != changeEmailOld { t.Error("users.email must be unchanged") } }) t.Run("A code that does not work answers one 403: the code expired 1 second ago", func(t *testing.T) { f := newChangeEmailFixture(t) f.requestFor(t, changeEmailNew) code := f.lastVerificationCode(t) marta := f.marta() marta.EmailChangeExpiration = time.Now().Add(-time.Second) f.users.users[7] = marta assertChangeEmailResponse(t, f.confirm(t, 7, code), http.StatusForbidden, "invalid_email_change_code") if f.marta().Email != changeEmailOld { t.Error("users.email must be unchanged") } }) t.Run("A code that does not work answers one 403: a wrong code", func(t *testing.T) { f := newChangeEmailFixture(t) f.requestFor(t, changeEmailNew) assertChangeEmailResponse(t, f.confirm(t, 7, "wrong"), http.StatusForbidden, "invalid_email_change_code") if f.marta().Email != changeEmailOld { t.Error("users.email must be unchanged") } }) t.Run("A code that does not work answers one 403: the code was issued to another user", func(t *testing.T) { f := newChangeEmailFixture(t) f.requestFor(t, changeEmailNew) code := f.lastVerificationCode(t) assertChangeEmailResponse(t, f.confirm(t, 8, code), http.StatusForbidden, "invalid_email_change_code") if f.marta().Email != changeEmailOld || f.users.users[8].Email != "ines@renew.example" { t.Error("no email may change") } }) t.Run("A code that does not work answers one 403: the change was cancelled", func(t *testing.T) { f := newChangeEmailFixture(t) f.requestFor(t, changeEmailNew) code := f.lastVerificationCode(t) f.cancel(t) assertChangeEmailResponse(t, f.confirm(t, 7, code), http.StatusForbidden, "invalid_email_change_code") }) t.Run("The address was taken in the meantime", func(t *testing.T) { f := newChangeEmailFixture(t) f.requestFor(t, "later@renew.example") code := f.lastVerificationCode(t) ines := f.users.users[8] ines.Email = "later@renew.example" f.users.users[8] = ines rec := f.confirm(t, 7, code) assertChangeEmailResponse(t, rec, http.StatusConflict, "email_unavailable") if f.marta().Email != changeEmailOld { t.Error("users.email must be unchanged") } assertNothingPending(t, f) }) t.Run("An empty code is refused before any lookup", func(t *testing.T) { f := newChangeEmailFixture(t) f.requestFor(t, changeEmailNew) f.users.getCalls = 0 f.confirm(t, 7, "") middlewareLookups := f.users.getCalls f.users.getCalls = 0 rec := f.confirm(t, 7, "") if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "Code") { t.Errorf("expected a 400 with the validator message, got %d %q", rec.Code, rec.Body.String()) } if f.users.getCalls != middlewareLookups { t.Error("the use case must not look the user up") } if f.marta().PendingEmail != changeEmailNew { t.Error("nothing may change") } }) } func TestAccountChangeEmailTimingAndFailuresHTTP(t *testing.T) { t.Run("No mail is sent on the request path, free or taken address alike", func(t *testing.T) { for _, address := range []string{changeEmailNew, "ines@renew.example"} { f := newChangeEmailFixture(t) req := httptest.NewRequest(http.MethodPost, "/v1/auth/email/change", strings.NewReader(`{"new_email": "`+address+`", "current_password": "`+changeEmailPassword+`"}`)) req.AddCookie(displayNameCookie(t, f.token, 7, 0)) rec := httptest.NewRecorder() f.router.ServeHTTP(rec, req) if rec.Code != http.StatusOK || len(f.mail.SentEmails) != 0 || f.dispatcher.pending() != 2 { t.Errorf("%s: status %d, sent %d, queued %d", address, rec.Code, len(f.mail.SentEmails), f.dispatcher.pending()) } f.dispatcher.Drain() } }) t.Run("Every email-change refusal records a specific failure reason", func(t *testing.T) { cases := []struct { name string run func(f *changeEmailFixture) }{ {"same address", func(f *changeEmailFixture) { f.requestFor(t, changeEmailOld) }}, {"nothing pending on resend", func(f *changeEmailFixture) { f.resend(t) }}, {"invalid code", func(f *changeEmailFixture) { f.confirm(t, 7, "nope") }}, {"address unavailable", func(f *changeEmailFixture) { f.requestFor(t, "later@renew.example") code := f.lastVerificationCode(t) ines := f.users.users[8] ines.Email = "later@renew.example" f.users.users[8] = ines f.recorder.events = nil f.confirm(t, 7, code) }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { f := newChangeEmailFixture(t) tc.run(f) last := f.recorder.events[len(f.recorder.events)-1] if last.Outcome != audit.OutcomeFailed || !last.Security || last.FailureReason == nil || *last.FailureReason == useCases.GenericFailureReason { t.Errorf("expected a specific failed security event, got %+v", last) } }) } }) t.Run("Concurrent confirms of one address let exactly one user win", func(t *testing.T) { f := newChangeEmailFixture(t) const contenders = 8 codes := map[uint32]string{} for i := uint32(0); i < contenders; i++ { id := 20 + i f.users.users[id] = user.User{ID: id, Username: "u", Email: "u@renew.example", IsVerified: true, PendingEmail: "shared@renew.example", EmailChangeCode: "hashed:code", EmailChangeExpiration: time.Now().Add(time.Hour)} codes[id] = "code" } confirm := useCases.NewConfirmEmailChangeUseCase(f.users, atomicPlainHasher{}, time.Now, discardRecorder) var mu sync.Mutex var group sync.WaitGroup wins, unavailable := 0, 0 for id, code := range codes { group.Add(1) go func() { defer group.Done() _, err := confirm.ConfirmEmailChange(context.Background(), id, code) mu.Lock() defer mu.Unlock() if err == nil { wins++ } else if errors.Is(err, user.ErrEmailUnavailable) { unavailable++ } }() } group.Wait() if wins != 1 || unavailable != contenders-1 { t.Errorf("expected 1 win and %d unavailable, got %d and %d", contenders-1, wins, unavailable) } }) }