package test import ( "RenewCMS/api/handlers/auth" validator "RenewCMS/api/pkg/validator" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/useCases" "bytes" "errors" "log" "net/http" "net/http/httptest" "reflect" "strings" "testing" "time" "github.com/go-chi/chi/v5" ) type resetMailFailureHasher struct{} func (resetMailFailureHasher) Hash(value string) (string, error) { return "hash-of-" + value, nil } func (resetMailFailureHasher) Compare(hashed string, value string) error { return nil } func captureStandardLog(t *testing.T) *bytes.Buffer { t.Helper() var buffer bytes.Buffer previousOutput, previousFlags := log.Writer(), log.Flags() log.SetOutput(&buffer) t.Cleanup(func() { log.SetOutput(previousOutput) log.SetFlags(previousFlags) }) return &buffer } func resetRequestWith(mailRepo *mockMailRepo, email string) (*httptest.ResponseRecorder, string) { userRepo := newMockUserRepo() userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com", IsVerified: true} userRepo.nextID = 1 hasher := resetMailFailureHasher{} handler := auth.NewPasswordResetHandler( useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder), useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now, discardRecorder), ) router := chi.NewRouter() router.Post("/v1/auth/password-reset", handler.RequestPasswordReset) req := httptest.NewRequest(http.MethodPost, "/v1/auth/password-reset", bytes.NewBufferString(`{"email": "`+email+`"}`)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, req) return recorder, userRepo.lastUpdatePasswordResetCodeCode } func TestRequestPasswordResetMailFailure(t *testing.T) { validator.InitValidator() sendFailure := errors.New("smtp: connection refused") failingMail := func() *mockMailRepo { mailRepo := newMockMailRepo() mailRepo.fail.failNextWith("Send", sendFailure) return mailRepo } t.Run("A failed send is logged and the response is unchanged", func(t *testing.T) { logged := captureStandardLog(t) recorder, _ := resetRequestWith(failingMail(), "alice@example.com") if recorder.Code != http.StatusOK || strings.TrimSpace(recorder.Body.String()) != requestPasswordResetGenericBody { t.Errorf("expected the generic 200, got %d %s", recorder.Code, recorder.Body.String()) } if jwtCookieOf(recorder) != nil { t.Errorf("no jwt cookie may be set") } line := logged.String() if !strings.Contains(line, "Send") || !strings.Contains(line, "smtp: connection refused") { t.Errorf("log must name the failed send and its error, got %q", line) } }) t.Run("The response is identical for a failed send, a successful send and an unknown email", func(t *testing.T) { captureStandardLog(t) failed, _ := resetRequestWith(failingMail(), "alice@example.com") sent, _ := resetRequestWith(newMockMailRepo(), "alice@example.com") unknown, _ := resetRequestWith(newMockMailRepo(), "nobody@example.com") for name, other := range map[string]*httptest.ResponseRecorder{"successful send": sent, "unknown email": unknown} { if failed.Code != other.Code || failed.Body.String() != other.Body.String() { t.Errorf("%s differs: %d %q vs %d %q", name, failed.Code, failed.Body.String(), other.Code, other.Body.String()) } if !reflect.DeepEqual(failed.Header(), other.Header()) { t.Errorf("%s headers differ: %v vs %v", name, failed.Header(), other.Header()) } } }) t.Run("The log does not contain the raw reset code", func(t *testing.T) { logged := captureStandardLog(t) _, storedHash := resetRequestWith(failingMail(), "alice@example.com") rawCode := strings.TrimPrefix(storedHash, "hash-of-") if rawCode == "" || strings.Contains(logged.String(), rawCode) || strings.Contains(logged.String(), storedHash) { t.Errorf("log must contain neither the raw code nor its hash, got %q", logged.String()) } }) }