package test import ( "RenewCMS/api/handlers/auth" validator "RenewCMS/api/pkg/validator" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/useCases" "bytes" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/go-chi/chi/v5" ) func newRequestPasswordResetRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, publicURL string) *chi.Mux { hasher := &stubHasher{} passwordResetHandler := auth.NewPasswordResetHandler( useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, publicURL, discardRecorder), useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now, discardRecorder), ) r := chi.NewRouter() r.Route("/v1", func(r chi.Router) { r.Route("/auth", func(r chi.Router) { r.Post("/password-reset", passwordResetHandler.RequestPasswordReset) }) }) return r } func sendPasswordResetRequest(router *chi.Mux, body string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, "/v1/auth/password-reset", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, req) return recorder } const requestPasswordResetGenericBody = `{"message":"If an account exists for that email, a password reset link has been sent."}` func TestRequestPasswordResetHTTP(t *testing.T) { validator.InitValidator() t.Run("Requesting a reset for an existing email sends the reset email and returns the generic message", func(t *testing.T) { userRepo := newMockUserRepo() userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com", IsVerified: true} userRepo.nextID = 1 mailRepo := newMockMailRepo() router := newRequestPasswordResetRouter(userRepo, mailRepo, "https://cms.example.com") recorder := sendPasswordResetRequest(router, `{"email": "alice@example.com"}`) if recorder.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", recorder.Code) } if strings.TrimSpace(recorder.Body.String()) != requestPasswordResetGenericBody { t.Errorf("unexpected body: %s", recorder.Body.String()) } if len(mailRepo.SentEmails) != 1 { t.Fatalf("expected exactly one email, got %d", len(mailRepo.SentEmails)) } if mailRepo.SentEmails[0].Address != "alice@example.com" { t.Errorf("expected email to alice@example.com, got %s", mailRepo.SentEmails[0].Address) } if mailRepo.SentEmails[0].Template != "password-reset" { t.Errorf("expected template password-reset, got %s", mailRepo.SentEmails[0].Template) } }) t.Run("Requesting a reset for an email with no account returns the identical response and sends no email", func(t *testing.T) { userRepo := newMockUserRepo() mailRepo := newMockMailRepo() router := newRequestPasswordResetRouter(userRepo, mailRepo, "https://cms.example.com") recorder := sendPasswordResetRequest(router, `{"email": "nobody@example.com"}`) if recorder.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", recorder.Code) } if strings.TrimSpace(recorder.Body.String()) != requestPasswordResetGenericBody { t.Errorf("unexpected body: %s", recorder.Body.String()) } if len(mailRepo.SentEmails) != 0 { t.Errorf("expected no email to be sent, got %d", len(mailRepo.SentEmails)) } }) t.Run("The reset email's Host reflects the injected public URL", func(t *testing.T) { userRepo := newMockUserRepo() userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com"} userRepo.nextID = 1 mailRepo := newMockMailRepo() router := newRequestPasswordResetRouter(userRepo, mailRepo, "https://cms.example.com") sendPasswordResetRequest(router, `{"email": "alice@example.com"}`) if len(mailRepo.SentEmails) != 1 { t.Fatalf("expected exactly one email, got %d", len(mailRepo.SentEmails)) } data, ok := mailRepo.SentEmails[0].Data.(useCases.MailPasswordResetData) if !ok { t.Fatalf("expected data of type MailPasswordResetData, got %T", mailRepo.SentEmails[0].Data) } if data.Host != "https://cms.example.com" { t.Errorf("expected Host https://cms.example.com, got %s", data.Host) } }) t.Run("The stored reset code expires one hour after creation", func(t *testing.T) { userRepo := newMockUserRepo() userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com"} userRepo.nextID = 1 mailRepo := newMockMailRepo() router := newRequestPasswordResetRouter(userRepo, mailRepo, "https://cms.example.com") sendPasswordResetRequest(router, `{"email": "alice@example.com"}`) stored := userRepo.users[1] want := time.Now().Add(time.Hour) if stored.PasswordResetExpiration.Sub(want).Abs() > 5*time.Second { t.Errorf("expected expiration near %v, got %v", want, stored.PasswordResetExpiration) } }) t.Run("A second request overwrites the first code, invalidating it", func(t *testing.T) { userRepo := newMockUserRepo() userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com"} userRepo.nextID = 1 mailRepo := newMockMailRepo() router := newRequestPasswordResetRouter(userRepo, mailRepo, "https://cms.example.com") sendPasswordResetRequest(router, `{"email": "alice@example.com"}`) sendPasswordResetRequest(router, `{"email": "alice@example.com"}`) if len(mailRepo.SentEmails) != 2 { t.Fatalf("expected exactly two emails, got %d", len(mailRepo.SentEmails)) } firstData := mailRepo.SentEmails[0].Data.(useCases.MailPasswordResetData) secondData := mailRepo.SentEmails[1].Data.(useCases.MailPasswordResetData) if firstData.VerificationCode == secondData.VerificationCode { t.Errorf("expected the second code to differ from the first") } hasher := &stubHasher{} stored := userRepo.users[1] if hasher.Compare(stored.PasswordResetCode, firstData.VerificationCode) == nil { t.Errorf("expected the stored code to no longer match the first raw code") } if hasher.Compare(stored.PasswordResetCode, secondData.VerificationCode) != nil { t.Errorf("expected the stored code to match the second raw code") } }) t.Run("A mail-send failure for an existing user still returns the same generic response", func(t *testing.T) { userRepo := newMockUserRepo() userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com"} userRepo.nextID = 1 mailRepo := newMockMailRepo() mailRepo.fail.failNext("Send") router := newRequestPasswordResetRouter(userRepo, mailRepo, "https://cms.example.com") logBuf := captureRequestPasswordResetLog(t) recorder := sendPasswordResetRequest(router, `{"email": "alice@example.com"}`) if recorder.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", recorder.Code) } if strings.TrimSpace(recorder.Body.String()) != requestPasswordResetGenericBody { t.Errorf("unexpected body: %s", recorder.Body.String()) } if recorder.Header().Get("Set-Cookie") != "" { t.Errorf("expected no jwt cookie to be set, got %s", recorder.Header().Get("Set-Cookie")) } if logBuf.Len() == 0 { t.Fatalf("expected a failure to be written to the process log") } if !strings.Contains(strings.ToLower(logBuf.String()), "send") && !strings.Contains(strings.ToLower(logBuf.String()), "mail") { t.Errorf("expected the log to name the mail-send failure, got %q", logBuf.String()) } }) t.Run("An unverified user can request a password reset exactly like a verified one", func(t *testing.T) { userRepo := newMockUserRepo() userRepo.users[1] = user.User{ID: 1, Username: "bob", Email: "bob@example.com", IsVerified: false} userRepo.nextID = 1 mailRepo := newMockMailRepo() router := newRequestPasswordResetRouter(userRepo, mailRepo, "https://cms.example.com") recorder := sendPasswordResetRequest(router, `{"email": "bob@example.com"}`) if recorder.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", recorder.Code) } if strings.TrimSpace(recorder.Body.String()) != requestPasswordResetGenericBody { t.Errorf("unexpected body: %s", recorder.Body.String()) } if len(mailRepo.SentEmails) != 1 { t.Fatalf("expected exactly one email, got %d", len(mailRepo.SentEmails)) } if mailRepo.SentEmails[0].Address != "bob@example.com" { t.Errorf("expected email to bob@example.com, got %s", mailRepo.SentEmails[0].Address) } if mailRepo.SentEmails[0].Template != "password-reset" { t.Errorf("expected template password-reset, got %s", mailRepo.SentEmails[0].Template) } }) t.Run("A malformed request is still rejected, since this is not an enumeration concern", func(t *testing.T) { cases := []struct { name string body string }{ {"missing email", `{}`}, {"malformed email", `{"email": "not-an-email"}`}, {"non-json body", `not-json`}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { userRepo := newMockUserRepo() userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com"} userRepo.nextID = 1 mailRepo := newMockMailRepo() router := newRequestPasswordResetRouter(userRepo, mailRepo, "https://cms.example.com") recorder := sendPasswordResetRequest(router, tc.body) if recorder.Code != http.StatusBadRequest { t.Fatalf("expected status 400, got %d", recorder.Code) } if len(mailRepo.SentEmails) != 0 { t.Errorf("expected no email to be sent, got %d", len(mailRepo.SentEmails)) } }) } }) }