package test import ( "RenewCMS/api/handlers/auth" "RenewCMS/api/pkg/apperr" validator "RenewCMS/api/pkg/validator" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/useCases" "bytes" "net/http" "net/http/httptest" "testing" "time" "github.com/go-chi/chi/v5" ) func newResetPasswordRouter(userRepo *mockUserRepo, hasher *stubHasher) *chi.Mux { mailRepo := newMockMailRepo() passwordResetHandler := auth.NewPasswordResetHandler( useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", 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/confirm", passwordResetHandler.ResetPassword) }) }) return r } func sendResetPasswordRequest(router *chi.Mux, body string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, "/v1/auth/password-reset/confirm", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) return rec } func newAliceResetPasswordRepo(expiration time.Time) *mockUserRepo { repo := newMockUserRepo() repo.users[1] = user.User{ ID: 1, Username: "alice", Email: "alice@example.com", Password: "hashed:oldpassword", PasswordResetCode: "hashed:reset-code-1", PasswordResetExpiration: expiration, } repo.nextID = 1 return repo } func TestResetPasswordHTTP(t *testing.T) { validator.InitValidator() t.Run("A correct, unexpired code sets the new password and cannot be reused", func(t *testing.T) { userRepo := newAliceResetPasswordRepo(time.Now().Add(time.Hour)) hasher := &stubHasher{} router := newResetPasswordRouter(userRepo, hasher) firstBody := `{"email": "alice@example.com", "code": "reset-code-1", "password": "newpassword1"}` first := sendResetPasswordRequest(router, firstBody) if first.Code != http.StatusOK { t.Fatalf("expected status 200, got %d: %s", first.Code, first.Body.String()) } if first.Body.String() != `{"message":"Your password has been reset. You can now log in."}` { t.Errorf("unexpected body: %s", first.Body.String()) } if first.Header().Get("Set-Cookie") != "" { t.Errorf("expected no jwt cookie, got %s", first.Header().Get("Set-Cookie")) } stored := userRepo.users[1] if stored.Password != "hashed:newpassword1" { t.Errorf("expected stored password %q, got %q", "hashed:newpassword1", stored.Password) } if stored.PasswordResetCode != "" { t.Errorf("expected empty PasswordResetCode, got %q", stored.PasswordResetCode) } if !stored.PasswordResetExpiration.IsZero() { t.Errorf("expected zero PasswordResetExpiration, got %v", stored.PasswordResetExpiration) } second := sendResetPasswordRequest(router, firstBody) if second.Code != http.StatusForbidden { t.Fatalf("expected status 403, got %d: %s", second.Code, second.Body.String()) } if second.Body.String() != apperr.PasswordResetFailedErrorMessage+"\n" { t.Errorf("unexpected body: %q", second.Body.String()) } if userRepo.users[1].Password != "hashed:newpassword1" { t.Errorf("expected password to remain from the first request, got %q", userRepo.users[1].Password) } }) t.Run("A wrong code is refused with the generic message and nothing changes", func(t *testing.T) { userRepo := newAliceResetPasswordRepo(time.Now().Add(time.Hour)) hasher := &stubHasher{} router := newResetPasswordRouter(userRepo, hasher) rec := sendResetPasswordRequest(router, `{"email": "alice@example.com", "code": "wrong-code", "password": "newpassword1"}`) if rec.Code != http.StatusForbidden { t.Fatalf("expected status 403, got %d: %s", rec.Code, rec.Body.String()) } if rec.Body.String() != apperr.PasswordResetFailedErrorMessage+"\n" { t.Errorf("unexpected body: %q", rec.Body.String()) } stored := userRepo.users[1] if stored.PasswordResetCode != "hashed:reset-code-1" || stored.Password != "hashed:oldpassword" { t.Errorf("expected record unchanged, got %+v", stored) } }) t.Run("An expired code is refused with the same generic message and nothing changes", func(t *testing.T) { userRepo := newAliceResetPasswordRepo(time.Now().Add(-time.Hour)) hasher := &stubHasher{} router := newResetPasswordRouter(userRepo, hasher) rec := sendResetPasswordRequest(router, `{"email": "alice@example.com", "code": "reset-code-1", "password": "newpassword1"}`) if rec.Code != http.StatusForbidden { t.Fatalf("expected status 403, got %d: %s", rec.Code, rec.Body.String()) } if rec.Body.String() != apperr.PasswordResetFailedErrorMessage+"\n" { t.Errorf("unexpected body: %q", rec.Body.String()) } stored := userRepo.users[1] if stored.PasswordResetCode != "hashed:reset-code-1" || stored.Password != "hashed:oldpassword" { t.Errorf("expected record unchanged, got %+v", stored) } }) t.Run("An unknown email is refused with the exact same status and body as a wrong code", func(t *testing.T) { userRepo := newMockUserRepo() hasher := &stubHasher{} router := newResetPasswordRouter(userRepo, hasher) rec := sendResetPasswordRequest(router, `{"email": "nobody@example.com", "code": "any-code", "password": "newpassword1"}`) if rec.Code != http.StatusForbidden { t.Fatalf("expected status 403, got %d: %s", rec.Code, rec.Body.String()) } if rec.Body.String() != apperr.PasswordResetFailedErrorMessage+"\n" { t.Errorf("unexpected body: %q", rec.Body.String()) } }) t.Run("A user who never requested a reset gets the same refusal, since the stored code is empty", func(t *testing.T) { userRepo := newMockUserRepo() userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com", PasswordResetCode: ""} userRepo.nextID = 1 hasher := &stubHasher{} router := newResetPasswordRouter(userRepo, hasher) rec := sendResetPasswordRequest(router, `{"email": "alice@example.com", "code": "anything", "password": "newpassword1"}`) if rec.Code != http.StatusForbidden { t.Fatalf("expected status 403, got %d: %s", rec.Code, rec.Body.String()) } if rec.Body.String() != apperr.PasswordResetFailedErrorMessage+"\n" { t.Errorf("unexpected body: %q", rec.Body.String()) } }) t.Run("A hashing failure is reported as an internal server error", func(t *testing.T) { userRepo := newAliceResetPasswordRepo(time.Now().Add(time.Hour)) hasher := &stubHasher{hashErr: errRepositoryFailure} router := newResetPasswordRouter(userRepo, hasher) rec := sendResetPasswordRequest(router, `{"email": "alice@example.com", "code": "reset-code-1", "password": "newpassword1"}`) if rec.Code != http.StatusInternalServerError { t.Fatalf("expected status 500, got %d: %s", rec.Code, rec.Body.String()) } if userRepo.resetPasswordCalls != 0 { t.Errorf("expected ResetPassword to never be called, got %d", userRepo.resetPasswordCalls) } }) t.Run("A repository failure while writing the reset is reported as an internal server error", func(t *testing.T) { userRepo := newAliceResetPasswordRepo(time.Now().Add(time.Hour)) userRepo.fail.failNext("ResetPassword") hasher := &stubHasher{} router := newResetPasswordRouter(userRepo, hasher) rec := sendResetPasswordRequest(router, `{"email": "alice@example.com", "code": "reset-code-1", "password": "newpassword1"}`) if rec.Code != http.StatusInternalServerError { t.Fatalf("expected status 500, got %d: %s", rec.Code, rec.Body.String()) } }) t.Run("A 7-character password is rejected as too short and nothing changes", func(t *testing.T) { userRepo := newAliceResetPasswordRepo(time.Now().Add(time.Hour)) hasher := &stubHasher{} router := newResetPasswordRouter(userRepo, hasher) rec := sendResetPasswordRequest(router, `{"email": "alice@example.com", "code": "reset-code-1", "password": "1234567"}`) if rec.Code != http.StatusBadRequest { t.Fatalf("expected status 400, got %d: %s", rec.Code, rec.Body.String()) } if userRepo.resetPasswordCalls != 0 { t.Errorf("expected ResetPassword to never be called, got %d", userRepo.resetPasswordCalls) } stored := userRepo.users[1] if stored.PasswordResetCode != "hashed:reset-code-1" || stored.Password != "hashed:oldpassword" { t.Errorf("expected record unchanged, got %+v", stored) } }) t.Run("An exactly-8-character password with a valid code succeeds", func(t *testing.T) { userRepo := newAliceResetPasswordRepo(time.Now().Add(time.Hour)) hasher := &stubHasher{} router := newResetPasswordRouter(userRepo, hasher) rec := sendResetPasswordRequest(router, `{"email": "alice@example.com", "code": "reset-code-1", "password": "12345678"}`) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) } stored := userRepo.users[1] if stored.Password != "hashed:12345678" { t.Errorf("expected stored password %q, got %q", "hashed:12345678", stored.Password) } if stored.PasswordResetCode != "" { t.Errorf("expected empty PasswordResetCode, got %q", stored.PasswordResetCode) } }) t.Run("A malformed or too-short request is rejected before any lookup happens", func(t *testing.T) { cases := []struct { name string body string }{ {"empty body", `{}`}, {"malformed email", `{"email": "not-an-email", "code": "c", "password": "newpassword1"}`}, {"empty code", `{"email": "alice@example.com", "code": "", "password": "newpassword1"}`}, {"short password", `{"email": "alice@example.com", "code": "reset-code-1", "password": "short"}`}, {"non-json body", `not-json`}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { userRepo := newAliceResetPasswordRepo(time.Now().Add(time.Hour)) hasher := &stubHasher{} router := newResetPasswordRouter(userRepo, hasher) rec := sendResetPasswordRequest(router, tc.body) if rec.Code != http.StatusBadRequest { t.Fatalf("expected status 400, got %d: %s", rec.Code, rec.Body.String()) } if userRepo.resetPasswordCalls != 0 { t.Errorf("expected ResetPassword to never be called, got %d", userRepo.resetPasswordCalls) } stored := userRepo.users[1] if stored.PasswordResetCode != "hashed:reset-code-1" || stored.Password != "hashed:oldpassword" { t.Errorf("expected record unchanged, got %+v", stored) } }) } }) }