271 lines
10 KiB
Go
271 lines
10 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|