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{}) 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}) _, 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 TestUpdateUserUseCaseHashesUpdatedValues(t *testing.T) { cases := []struct { method string plaintext string run func(uc *useCases.UpdateUserUseCase, plaintext string) (user.User, error) stored func(u user.User) string }{ { method: "UpdatePassword", plaintext: "newPassw0rd", run: func(uc *useCases.UpdateUserUseCase, plaintext string) (user.User, error) { return uc.UpdatePassword(1, plaintext) }, stored: func(u user.User) string { return u.Password }, }, { method: "UpdatePasswordResetCode", plaintext: "reset-code-123", run: func(uc *useCases.UpdateUserUseCase, plaintext string) (user.User, error) { return uc.UpdatePasswordResetCode(1, plaintext) }, stored: func(u user.User) string { return u.PasswordResetCode }, }, } for _, tc := range cases { t.Run(tc.method+" stores the hashed value", func(t *testing.T) { repo := newMockUserRepo() repo.users[1] = user.User{ID: 1} uc := useCases.NewUpdateUserUseCase(repo, &stubHasher{}) _, err := tc.run(uc, tc.plaintext) if err != nil { t.Fatalf("expected no error, got %v", err) } expected := "hashed:" + tc.plaintext if got := tc.stored(repo.users[1]); got != expected { t.Errorf("expected stored value %q, got %q", expected, got) } }) t.Run(tc.method+" a hashing failure persists nothing", func(t *testing.T) { repo := newMockUserRepo() repo.users[1] = user.User{ID: 1} uc := useCases.NewUpdateUserUseCase(repo, &stubHasher{hashErr: errStubHashFailure}) _, err := tc.run(uc, tc.plaintext) if !errors.Is(err, errStubHashFailure) { t.Errorf("expected the hashing error, got %v", err) } if got := tc.stored(repo.users[1]); got != "" { t.Errorf("expected the repository update to never be called, got %q", got) } }) } } 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), useCases.NewAuthenticateUserUseCase(userRepo, hasher), ) 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), useCases.NewAuthenticateUserUseCase(userRepo, hasher), ) 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")) } }