package test import ( "RenewCMS/api/handlers/auth" "RenewCMS/api/pkg/apperr" validator "RenewCMS/api/pkg/validator" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/hashing" "RenewCMS/internal/infrastructure/useCases" "bytes" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "github.com/go-chi/chi/v5" "github.com/go-chi/jwtauth/v5" ) var errStubHashFailure = errors.New("stub hash failure") type spyHasher struct { stubHasher compareCalls int } func (s *spyHasher) Compare(hashed string, value string) error { s.compareCalls++ return s.stubHasher.Compare(hashed, value) } func TestCreateUserUseCaseHashesPasswordAndVerificationCode(t *testing.T) { t.Run("stores hashed password and hashed verification code, returns raw code", func(t *testing.T) { repo := newMockUserRepo() uc := useCases.NewCreateUserUseCase(repo, &stubHasher{}, newMockMailRepo(), "https://cms.example.com") result, err := uc.CreateUser(useCases.CreateUserCommand{ Username: "alice", Password: "s3cretPassw0rd", Email: "alice@example.com", }) if err != nil { t.Fatalf("expected no error, got %v", err) } stored := repo.users[result.ID] if stored.Password != "hashed:s3cretPassw0rd" { t.Errorf("expected stored password %q, got %q", "hashed:s3cretPassw0rd", stored.Password) } if stored.VerificationCode == "" || stored.VerificationCode == result.VerificationCode { t.Errorf("expected stored verification code to be hashed, got %q", stored.VerificationCode) } if stored.VerificationCode != "hashed:"+result.VerificationCode { t.Errorf("expected stored verification code %q, got %q", "hashed:"+result.VerificationCode, stored.VerificationCode) } }) t.Run("a hashing failure persists nothing", func(t *testing.T) { repo := newMockUserRepo() uc := useCases.NewCreateUserUseCase(repo, &stubHasher{hashErr: errStubHashFailure}, newMockMailRepo(), "https://cms.example.com") _, err := uc.CreateUser(useCases.CreateUserCommand{ Username: "alice", Password: "s3cretPassw0rd", Email: "alice@example.com", }) if !errors.Is(err, errStubHashFailure) { t.Errorf("expected the hashing error, got %v", err) } if len(repo.users) != 0 { t.Errorf("expected no user stored, got %d", len(repo.users)) } }) } func TestAuthenticateUserUseCase(t *testing.T) { newRepo := func() *mockUserRepo { repo := newMockUserRepo() repo.users[1] = user.User{ID: 1, Username: "alice", Password: "hashed:hunter2example"} return repo } t.Run("correct password returns the user with no error", func(t *testing.T) { repo := newRepo() uc := useCases.NewAuthenticateUserUseCase(repo, &stubHasher{}) result, err := uc.Authenticate("alice", "hunter2example") if err != nil { t.Fatalf("expected no error, got %v", err) } if result.ID != 1 { t.Errorf("expected user id 1, got %d", result.ID) } }) t.Run("wrong password returns user.ErrInvalidCredentials", func(t *testing.T) { repo := newRepo() uc := useCases.NewAuthenticateUserUseCase(repo, &stubHasher{}) _, err := uc.Authenticate("alice", "wrongpassword12") if !errors.Is(err, user.ErrInvalidCredentials) { t.Errorf("expected user.ErrInvalidCredentials, got %v", err) } }) t.Run("unknown username never calls the hasher", func(t *testing.T) { repo := newMockUserRepo() hasher := &spyHasher{} uc := useCases.NewAuthenticateUserUseCase(repo, hasher) _, err := uc.Authenticate("bob", "hunter2example") if !errors.Is(err, user.ErrUserNotFound) { t.Errorf("expected user.ErrUserNotFound, got %v", err) } if hasher.compareCalls != 0 { t.Errorf("expected the hasher's Compare to never be called, got %d calls", hasher.compareCalls) } }) } func TestBcryptHasherHashAndCompare(t *testing.T) { hasher := hashing.NewBcryptHasher() t.Run("hashing then comparing the same value succeeds", func(t *testing.T) { hashed, err := hasher.Hash("hunter2example") if err != nil { t.Fatalf("expected no error, got %v", err) } if err := hasher.Compare(hashed, "hunter2example"); err != nil { t.Errorf("expected the comparison to succeed, got %v", err) } }) t.Run("comparing against the wrong value fails", func(t *testing.T) { hashed, err := hasher.Hash("hunter2example") if err != nil { t.Fatalf("expected no error, got %v", err) } if err := hasher.Compare(hashed, "wrongpassword12"); err == nil { t.Errorf("expected the comparison to fail") } }) t.Run("hashing a value over 72 bytes fails", func(t *testing.T) { tooLong := strings.Repeat("a", 73) if _, err := hasher.Hash(tooLong); err == nil { t.Errorf("expected an error for a value over 72 bytes") } }) } func newHasherRouter(userRepo *mockUserRepo, hasher *stubHasher) *chi.Mux { token := jwtauth.New("HS256", []byte("test-secret"), nil) authHandler := auth.NewHandler( token, useCases.NewGetUserUseCase(userRepo), useCases.NewListUsersUseCase(userRepo), useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"), useCases.NewAuthenticateUserUseCase(userRepo, hasher), useCases.NewVerifyUserUseCase(userRepo, hasher), useCases.NewCancelRegistrationUseCase(userRepo), useCases.NewRequestPasswordResetUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"), useCases.NewResetPasswordUseCase(userRepo, hasher), true, ) r := chi.NewRouter() r.Route("/v1/auth", func(r chi.Router) { r.Post("/login", authHandler.Login) r.Post("/register", authHandler.Register) }) return r } func newRealHasherRouter(userRepo *mockUserRepo) *chi.Mux { token := jwtauth.New("HS256", []byte("test-secret"), nil) hasher := hashing.NewBcryptHasher() authHandler := auth.NewHandler( token, useCases.NewGetUserUseCase(userRepo), useCases.NewListUsersUseCase(userRepo), useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"), useCases.NewAuthenticateUserUseCase(userRepo, hasher), useCases.NewVerifyUserUseCase(userRepo, hasher), useCases.NewCancelRegistrationUseCase(userRepo), useCases.NewRequestPasswordResetUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"), useCases.NewResetPasswordUseCase(userRepo, hasher), true, ) r := chi.NewRouter() r.Route("/v1/auth", func(r chi.Router) { r.Post("/login", authHandler.Login) r.Post("/register", authHandler.Register) }) return r } func TestLoginOverHttpCannotDistinguishBadPasswordFromUnknownUsername(t *testing.T) { validator.InitValidator() newAliceRepo := func() *mockUserRepo { repo := newMockUserRepo() hashed, err := hashing.NewBcryptHasher().Hash("hunter2example") if err != nil { t.Fatalf("failed to hash password: %v", err) } repo.users[1] = user.User{ID: 1, Username: "alice", Password: hashed} return repo } cases := []struct { name string username string password string }{ {"wrong password for existing user", "alice", "wrongpassword12"}, {"nonexistent username", "bob", "hunter2example"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { router := newRealHasherRouter(newAliceRepo()) body, err := json.Marshal(map[string]string{"username": tc.username, "password": tc.password}) if err != nil { t.Fatalf("failed to marshal body: %v", err) } req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected status 403, got %d", rec.Code) } if rec.Body.String() != apperr.LogsErrorMessage+"\n" { t.Errorf("unexpected body: %q", rec.Body.String()) } if rec.Header().Get("Set-Cookie") != "" { t.Errorf("expected no Set-Cookie header, got %q", rec.Header().Get("Set-Cookie")) } }) } } func TestRegisteringAUserStoresTheHashedPasswordNeverThePlaintext(t *testing.T) { validator.InitValidator() repo := newMockUserRepo() router := newRealHasherRouter(repo) body, err := json.Marshal(map[string]string{ "username": "alice", "password": "s3cretPassw0rd", "email": "alice@example.com", }) if err != nil { t.Fatalf("failed to marshal body: %v", err) } req := httptest.NewRequest(http.MethodPost, "/v1/auth/register", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) } if rec.Header().Get("Set-Cookie") == "" { t.Errorf("expected a jwt cookie to be set") } stored, err := repo.GetByUsername("alice") if err != nil { t.Fatalf("expected the user to be stored, got %v", err) } if stored.Password == "s3cretPassw0rd" { t.Errorf("expected the stored password to not be the plaintext") } hasher := hashing.NewBcryptHasher() if err := hasher.Compare(stored.Password, "s3cretPassw0rd"); err != nil { t.Errorf("expected the stored password to match the plaintext once compared, got %v", err) } } func TestAHashingFailureDuringRegistrationSurfacesAs500AndSetsNoCookie(t *testing.T) { validator.InitValidator() repo := newMockUserRepo() router := newHasherRouter(repo, &stubHasher{hashErr: errStubHashFailure}) body, err := json.Marshal(map[string]string{ "username": "alice", "password": "s3cretPassw0rd", "email": "alice@example.com", }) if err != nil { t.Fatalf("failed to marshal body: %v", err) } req := httptest.NewRequest(http.MethodPost, "/v1/auth/register", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusInternalServerError { t.Fatalf("expected status 500, got %d", rec.Code) } if rec.Header().Get("Set-Cookie") != "" { t.Errorf("expected no Set-Cookie header, got %q", rec.Header().Get("Set-Cookie")) } }