Files
RenewCMS/test/hasher_test.go
T
2026-10-06 22:25:23 +02:00

303 lines
9.3 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/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", discardRecorder)
result, err := uc.CreateUser(testCtx, 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", discardRecorder)
_, err := uc.CreateUser(testCtx, 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{}, discardRecorder)
result, err := uc.Authenticate(testCtx, "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{}, discardRecorder)
_, err := uc.Authenticate(testCtx, "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, discardRecorder)
_, err := uc.Authenticate(testCtx, "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)
loginHandler := auth.NewLoginHandler(
token,
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
true,
)
r := chi.NewRouter()
r.Route("/v1/auth", func(r chi.Router) {
r.Post("/login", loginHandler.Login)
r.Post("/register", loginHandler.Register)
})
return r
}
func newRealHasherRouter(userRepo *mockUserRepo) *chi.Mux {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
hasher := hashing.NewBcryptHasher()
loginHandler := auth.NewLoginHandler(
token,
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
true,
)
r := chi.NewRouter()
r.Route("/v1/auth", func(r chi.Router) {
r.Post("/login", loginHandler.Login)
r.Post("/register", loginHandler.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"))
}
}