Files
RenewCMS/test/account_change_email_http_test.go
T

699 lines
25 KiB
Go

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)
}
})
}