refac: password and code hashing & checking go through a domain hasher interface used by the use cases instead of bcrypt in the user repository and the auth handler

This commit is contained in:
fsylvain committed 2026-09-23 19:29:20 +02:00
1 parent 8460a078e3
commit ff8cd5f0ec
16 files changed
+725 -88

No files matched your search

+33 -28
View File
@@ -5,6 +5,7 @@ import (
domainImage "RenewCMS/internal/domain/image" domainImage "RenewCMS/internal/domain/image"
domainMail "RenewCMS/internal/domain/mail" domainMail "RenewCMS/internal/domain/mail"
domainUser "RenewCMS/internal/domain/user" domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/mailer" "RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/persistence/models" "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories" persistence "RenewCMS/internal/infrastructure/persistence/repositories"
@@ -18,20 +19,21 @@ import (
) )
type UseCases struct { type UseCases struct {
Token *jwtauth.JWTAuth Token *jwtauth.JWTAuth
CreateArticleUseCase *useCases.CreateArticleUseCase CreateArticleUseCase *useCases.CreateArticleUseCase
GetArticleUseCase *useCases.GetArticleUseCase GetArticleUseCase *useCases.GetArticleUseCase
ListArticlesUseCase *useCases.ListArticlesUseCase ListArticlesUseCase *useCases.ListArticlesUseCase
UpdateArticleUseCase *useCases.UpdateArticleUseCase UpdateArticleUseCase *useCases.UpdateArticleUseCase
DeleteArticleUseCase *useCases.DeleteArticleUseCase DeleteArticleUseCase *useCases.DeleteArticleUseCase
GetUserUseCase *useCases.GetUserUseCase GetUserUseCase *useCases.GetUserUseCase
CreateUserUseCase *useCases.CreateUserUseCase CreateUserUseCase *useCases.CreateUserUseCase
UpdateUserUseCase *useCases.UpdateUserUseCase UpdateUserUseCase *useCases.UpdateUserUseCase
DeleteUserUseCase *useCases.DeleteUserUseCase AuthenticateUserUseCase *useCases.AuthenticateUserUseCase
ListUsersUseCase *useCases.ListUsersUseCase DeleteUserUseCase *useCases.DeleteUserUseCase
SendMailUseCase *useCases.SendMailUseCase ListUsersUseCase *useCases.ListUsersUseCase
CreateImageUseCase *useCases.CreateImageUseCase SendMailUseCase *useCases.SendMailUseCase
DeleteImageUseCase *useCases.DeleteImageUseCase CreateImageUseCase *useCases.CreateImageUseCase
DeleteImageUseCase *useCases.DeleteImageUseCase
} }
type Repositories struct { type Repositories struct {
@@ -68,21 +70,24 @@ func getRepositories(db *gorm.DB) *Repositories {
} }
func getUseCases(repos *Repositories) *UseCases { func getUseCases(repos *Repositories) *UseCases {
hasher := hashing.NewBcryptHasher()
return &UseCases{ return &UseCases{
Token: jwtauth.New("HS256", []byte(os.Getenv("JWT_SECRET")), nil), Token: jwtauth.New("HS256", []byte(os.Getenv("JWT_SECRET")), nil),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo), CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo), GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo), ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo),
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo), GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo), CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher),
UpdateUserUseCase: useCases.NewUpdateUserUseCase(repos.UserRepo), UpdateUserUseCase: useCases.NewUpdateUserUseCase(repos.UserRepo, hasher),
DeleteUserUseCase: useCases.NewDeleteUserUseCase(repos.UserRepo), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher),
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo), DeleteUserUseCase: useCases.NewDeleteUserUseCase(repos.UserRepo),
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo), ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo), SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
DeleteImageUseCase: useCases.NewDeleteImageUseCase(repos.ImageRepo), CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo),
DeleteImageUseCase: useCases.NewDeleteImageUseCase(repos.ImageRepo),
} }
} }
+12 -26
View File
@@ -3,7 +3,6 @@ package auth
import ( import (
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"encoding/json" "encoding/json"
"net/http" "net/http"
@@ -11,14 +10,14 @@ import (
"time" "time"
"github.com/go-chi/jwtauth/v5" "github.com/go-chi/jwtauth/v5"
"golang.org/x/crypto/bcrypt"
) )
type Handler struct { type Handler struct {
Token *jwtauth.JWTAuth Token *jwtauth.JWTAuth
GetUseCase *useCases.GetUserUseCase GetUseCase *useCases.GetUserUseCase
ListUseCase *useCases.ListUsersUseCase ListUseCase *useCases.ListUsersUseCase
CreateUseCase *useCases.CreateUserUseCase CreateUseCase *useCases.CreateUserUseCase
AuthenticateUseCase *useCases.AuthenticateUserUseCase
} }
type RegisterCredentials struct { type RegisterCredentials struct {
@@ -48,12 +47,13 @@ func removeJwtCookie(w http.ResponseWriter) {
}) })
} }
func NewHandler(token *jwtauth.JWTAuth, get *useCases.GetUserUseCase, list *useCases.ListUsersUseCase, create *useCases.CreateUserUseCase) *Handler { func NewHandler(token *jwtauth.JWTAuth, get *useCases.GetUserUseCase, list *useCases.ListUsersUseCase, create *useCases.CreateUserUseCase, authenticate *useCases.AuthenticateUserUseCase) *Handler {
return &Handler{ return &Handler{
Token: token, Token: token,
GetUseCase: get, GetUseCase: get,
ListUseCase: list, ListUseCase: list,
CreateUseCase: create, CreateUseCase: create,
AuthenticateUseCase: authenticate,
} }
} }
@@ -113,20 +113,6 @@ func (h *Handler) IsVerified(r *http.Request) bool {
return currentUser.IsVerified return currentUser.IsVerified
} }
func (h *Handler) GetUserFromCredentials(credentials LoginCredentials) (user.User, error) {
dbUser, err := h.GetUseCase.GetUserByUsername(credentials.Username)
if err != nil {
return user.User{}, err
}
err = bcrypt.CompareHashAndPassword([]byte(dbUser.Password), []byte(credentials.Password))
if err != nil {
return user.User{}, err
}
return dbUser, nil
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var credentials LoginCredentials var credentials LoginCredentials
@@ -142,7 +128,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
return return
} }
dbUser, err := h.GetUserFromCredentials(credentials) dbUser, err := h.AuthenticateUseCase.Authenticate(credentials.Username, credentials.Password)
if err != nil { if err != nil {
http.Error(w, apperr.LogsErrorMessage, http.StatusForbidden) http.Error(w, apperr.LogsErrorMessage, http.StatusForbidden)
return return
+1 -1
View File
@@ -43,7 +43,7 @@ func NewArticleRouter(container *UseCases) http.Handler {
func NewAuthRouter(container *UseCases) http.Handler { func NewAuthRouter(container *UseCases) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
authHandler := auth.NewHandler(container.Token, container.GetUserUseCase, container.ListUsersUseCase, container.CreateUserUseCase) authHandler := auth.NewHandler(container.Token, container.GetUserUseCase, container.ListUsersUseCase, container.CreateUserUseCase, container.AuthenticateUserUseCase)
r.Post("/register", authHandler.Register) r.Post("/register", authHandler.Register)
r.Post("/login", authHandler.Login) r.Post("/login", authHandler.Login)
+112
View File
@@ -0,0 +1,112 @@
Feature: Password and code hashing go through a domain Hasher interface
As the maintainer of RenewCMS
I want password and verification/reset-code hashing done by a domain hash.Hasher
interface, used only by use cases
So that internal/infrastructure/persistence/repositories/userRepository.go and
api/handlers/auth/authHandler.go stop calling bcrypt directly, and a use case can be
tested against a fake hasher instead of a real bcrypt cost factor
Background:
Given internal/domain/hash defines the interface Hasher with Hash(value string) (string, error)
and Compare(hashed string, value string) error
And internal/domain/user defines the sentinel user.ErrInvalidCredentials alongside the
existing user.ErrUserNotFound
And internal/infrastructure/hashing.BcryptHasher implements hash.Hasher and is asserted
with var _ hash.Hasher = &BcryptHasher{}
And api/dependecyInjection.go constructs one BcryptHasher and passes it to
CreateUserUseCase, UpdateUserUseCase and the new AuthenticateUserUseCase
And UserRepository.Create, UpdatePassword and UpdatePasswordResetCode store the string
they are given verbatim and never call bcrypt
And the auth Handler no longer imports golang.org/x/crypto/bcrypt; Login calls
AuthenticateUserUseCase.Authenticate instead of comparing a hash itself
Scenario: Creating a user stores a hashed password and a hashed verification code
Given a fake hasher that hashes any value "x" to "hashed:x"
When CreateUserUseCase.CreateUser is called with username "alice", password
"s3cretPassw0rd" and email "alice@example.com"
Then the repository's Create is called with a user whose Password is "hashed:s3cretPassw0rd"
And that user's VerificationCode is "hashed:<the generated code>", not the raw code
And the User returned by the use case has VerificationCode equal to the raw generated
code, not the hash
Scenario: A hashing failure while creating a user persists nothing
Given a fake hasher whose Hash always returns an error
When CreateUserUseCase.CreateUser is called with username "alice", password
"s3cretPassw0rd" and email "alice@example.com"
Then the use case returns that error
And the repository's Create is never called
Scenario Outline: Updating a password or a password reset code stores the hashed value
Given a fake hasher that hashes any value "x" to "hashed:x"
When UpdateUserUseCase.<Method> is called with user id 1 and "<plaintext>"
Then the repository's <Method> is called with "hashed:<plaintext>", not "<plaintext>"
Examples:
| Method | plaintext |
| UpdatePassword | newPassw0rd |
| UpdatePasswordResetCode | reset-code-123 |
Scenario Outline: A hashing failure during an update persists nothing
Given a fake hasher whose Hash always returns an error
When UpdateUserUseCase.<Method> is called with user id 1 and "<plaintext>"
Then the use case returns that error
And the repository's <Method> is never called
Examples:
| Method | plaintext |
| UpdatePassword | newPassw0rd |
| UpdatePasswordResetCode | reset-code-123 |
Scenario: Authenticating with the correct password returns the user
Given a user repository containing a user "alice" with stored password "hashed:hunter2example"
And a fake hasher whose Compare returns nil when given "hashed:hunter2example" and "hunter2example"
When AuthenticateUserUseCase.Authenticate is called with username "alice" and password
"hunter2example"
Then the result is that user, with no error
Scenario: Authenticating with the wrong password returns a domain sentinel error
Given a user repository containing a user "alice" with stored password "hashed:hunter2example"
And a fake hasher whose Compare returns an error for that password
When AuthenticateUserUseCase.Authenticate is called with username "alice" and password
"wrongpassword12"
Then the result is the error user.ErrInvalidCredentials
And errors.Is(err, user.ErrInvalidCredentials) is true
Scenario: Authenticating an unknown username never calls the hasher
Given a user repository with no user "bob"
When AuthenticateUserUseCase.Authenticate is called with username "bob" and password
"hunter2example"
Then the result is the error user.ErrUserNotFound
And the hasher's Compare is never called
Scenario Outline: Login over HTTP still can't distinguish a bad password from an unknown username
Given a user repository containing a user "alice" with stored password "hashed:hunter2example"
And a real BcryptHasher
When the client sends POST /v1/auth/login with username "<username>" and password "<password>"
Then the response status is 403
And the response body is the text apperr.LogsErrorMessage
And no jwt cookie is set
Examples:
| username | password | case |
| alice | wrongpassword12 | existing user, bad pass |
| bob | hunter2example | unknown username |
Scenario: Registering a user stores the hashed password, never the plaintext
Given an empty user repository
And a real BcryptHasher
When the client sends POST /v1/auth/register with username "alice", password
"s3cretPassw0rd" and email "alice@example.com"
Then the response status is 200
And a jwt cookie is set
And the user repository's stored Password for "alice" is not "s3cretPassw0rd"
And BcryptHasher.Compare("<the stored value>", "s3cretPassw0rd") returns nil
Scenario: A hashing failure during registration surfaces as 500 and sets no cookie
Given an empty user repository
And a fake hasher whose Hash always returns an error
When the client sends POST /v1/auth/register with username "alice", password
"s3cretPassw0rd" and email "alice@example.com"
Then the response status is 500
And no jwt cookie is set
+69
View File
@@ -0,0 +1,69 @@
# QA procedure: hasher_domain_interface
Package: `test` (black-box, alongside the existing `article_test.go` / `user_test.go` /
`use_case_baseline_test.go` / `not_found_errors_test.go`).
## Fakes needed
1. A `fakeHasher` implementing `hash.Hasher`:
- `Hash(value string) (string, error)`: by default returns `"hashed:" + value, nil`;
configurable to return `("", someErr)` unconditionally.
- `Compare(hashed, value string) error`: by default returns `nil` when
`hashed == "hashed:"+value`, else a sentinel mismatch error; configurable to always
error, and to record whether it was called.
2. Extend the existing `mockUserRepo` (from `test/user_test.go` /
`not_found_errors_test.go`) with call-recording flags already used elsewhere in the
package (`createCalled`, `updatePasswordCalled`, etc.) if not already present, so tests
can assert a hashing failure short-circuits before the repository is touched.
## Use-case-level checks (fake hasher + fake repo, no HTTP)
3. `useCases.NewCreateUserUseCase(fakeRepo, fakeHasher)`, `CreateUser(...)`: assert the
`User` argument the fake repo's `Create` receives has `Password == "hashed:s3cretPassw0rd"`
and a `VerificationCode` that starts with `"hashed:"`; assert the `User` returned by the
use case has a `VerificationCode` that does **not** start with `"hashed:"` (it is the raw
UUID, unchanged from current behaviour).
4. Same use case with `fakeHasher` configured to always error on `Hash`: assert
`CreateUser` returns that error and `fakeRepo.Create` was never called.
5. `useCases.NewUpdateUserUseCase(fakeRepo, fakeHasher)`, call `UpdatePassword(1,
"newPassw0rd")` and separately `UpdatePasswordResetCode(1, "reset-code-123")`: assert the
fake repo receives `"hashed:newPassw0rd"` / `"hashed:reset-code-123"`, not the plaintext.
6. Same two methods with `fakeHasher.Hash` erroring: assert the use case returns the error
and the corresponding fake-repo method (`UpdatePassword` / `UpdatePasswordResetCode`) was
never called.
7. `useCases.NewAuthenticateUserUseCase(fakeRepo, fakeHasher)` (new use case): seed
`fakeRepo` with a user "alice" whose stored `Password` is `"hashed:hunter2example"`.
- `Authenticate("alice", "hunter2example")` with `fakeHasher.Compare` returning `nil` →
assert the returned user matches and `err` is `nil`.
- `Authenticate("alice", "wrongpassword12")` with `fakeHasher.Compare` returning an error
→ assert `errors.Is(err, user.ErrInvalidCredentials)` is `true`.
- `Authenticate("bob", "hunter2example")` with no "bob" in the repo → assert
`errors.Is(err, user.ErrUserNotFound)` is `true` and `fakeHasher`'s `Compare` was never
invoked (record a `compareCalled` flag).
## HTTP checks (real BcryptHasher, in-memory fake repo, mirroring api/router.go)
8. Call `validator.InitValidator()`.
9. Build `hashing.NewBcryptHasher()`, wire `useCases.NewCreateUserUseCase(fakeRepo,
bcryptHasher)` and `useCases.NewAuthenticateUserUseCase(fakeRepo, bcryptHasher)` (plus
the existing `GetUserUseCase` / `ListUsersUseCase` the auth handler still needs), build
`auth.NewHandler(token, get, list, create, authenticate)` (constructor signature grows by
the new use case) with a test `jwtauth.New("HS256", []byte("test-secret"), nil)`, mount
its routes unprotected on a fresh `chi.NewRouter()` as in `api/router.go`.
10. Seed the fake user repo directly with a user "alice" whose `Password` field holds a real
`bcryptHasher.Hash("hunter2example")` result (not `bcrypt.GenerateFromPassword` called
from the test, so the test never imports bcrypt itself).
11. `POST /v1/auth/login` with `{"username":"alice","password":"wrongpassword12"}` → assert
status `403`, body `apperr.LogsErrorMessage + "\n"`, no `Set-Cookie: jwt=...` header.
12. `POST /v1/auth/login` with `{"username":"bob","password":"hunter2example"}` (no such
user) → assert identical status/body/no-cookie outcome to step 11 (regression, same as
`not_found_errors.qa.md` steps 13–14).
13. `POST /v1/auth/register` with `{"username":"alice","password":"s3cretPassw0rd","email":"alice@example.com"}`
against an empty fake repo → assert status `200`, a `Set-Cookie: jwt=...` header is
present, and the `Password` the fake repo's `Create` received is neither empty nor equal
to `"s3cretPassw0rd"`; assert `bcryptHasher.Compare(storedPassword, "s3cretPassw0rd")`
returns `nil`.
14. Rebuild the handler with a `fakeHasher` (from step 1) whose `Hash` always errors in
place of `bcryptHasher`, same empty fake repo. `POST /v1/auth/register` with the same
valid body → assert status `500`, no `Set-Cookie` header, and the fake repo's `Create`
was never called.
## Manual verification (not exercised by the Go test)
- None. This slice has no SPA-visible behaviour and no SMTP involvement.
+6
View File
@@ -0,0 +1,6 @@
package hash
type Hasher interface {
Hash(value string) (string, error)
Compare(hashed string, value string) error
}
+1
View File
@@ -6,6 +6,7 @@ import (
) )
var ErrUserNotFound = errors.New("user not found") var ErrUserNotFound = errors.New("user not found")
var ErrInvalidCredentials = errors.New("invalid credentials")
type User struct { type User struct {
ID uint32 `json:"id"` ID uint32 `json:"id"`
@@ -0,0 +1,29 @@
package hashing
import (
"RenewCMS/internal/domain/hash"
"golang.org/x/crypto/bcrypt"
)
const bcryptCost = 12
type BcryptHasher struct{}
func NewBcryptHasher() *BcryptHasher {
return &BcryptHasher{}
}
func (b *BcryptHasher) Hash(value string) (string, error) {
hashed, err := bcrypt.GenerateFromPassword([]byte(value), bcryptCost)
if err != nil {
return "", err
}
return string(hashed), nil
}
func (b *BcryptHasher) Compare(hashed string, value string) error {
return bcrypt.CompareHashAndPassword([]byte(hashed), []byte(value))
}
var _ hash.Hasher = &BcryptHasher{}
@@ -5,7 +5,6 @@ import (
"RenewCMS/internal/infrastructure/persistence/mappers" "RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models" entity "RenewCMS/internal/infrastructure/persistence/models"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -28,14 +27,11 @@ func (u *UserRepository) Get(id uint32) (domainUser.User, error) {
} }
func (u *UserRepository) Create(user domainUser.User) (domainUser.User, error) { func (u *UserRepository) Create(user domainUser.User) (domainUser.User, error) {
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(user.Password), 12)
hashedVerificationCode, _ := bcrypt.GenerateFromPassword([]byte(user.VerificationCode), 12)
creationResult := u.db.Create(&entity.User{ creationResult := u.db.Create(&entity.User{
Username: user.Username, Username: user.Username,
Password: string(hashedPassword), Password: user.Password,
Email: user.Email, Email: user.Email,
VerificationCode: string(hashedVerificationCode), VerificationCode: user.VerificationCode,
VerificationExpiration: user.VerificationExpiration, VerificationExpiration: user.VerificationExpiration,
}) })
if creationResult.Error != nil { if creationResult.Error != nil {
@@ -108,8 +104,7 @@ func (u *UserRepository) UpdatePassword(userId uint32, password string) (domainU
return domainUser.User{}, err return domainUser.User{}, err
} }
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), 12) localUser.Password = password
localUser.Password = string(hashedPassword)
err = u.db.Save(&localUser).Error err = u.db.Save(&localUser).Error
if err != nil { if err != nil {
return domainUser.User{}, err return domainUser.User{}, err
@@ -125,8 +120,7 @@ func (u *UserRepository) UpdatePasswordResetCode(userId uint32, code string) (do
return domainUser.User{}, err return domainUser.User{}, err
} }
hashedCode, _ := bcrypt.GenerateFromPassword([]byte(code), 12) localUser.PasswordResetCode = code
localUser.PasswordResetCode = string(hashedCode)
err = u.db.Save(&localUser).Error err = u.db.Save(&localUser).Error
if err != nil { if err != nil {
return domainUser.User{}, err return domainUser.User{}, err
@@ -0,0 +1,28 @@
package useCases
import (
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/user"
)
type AuthenticateUserUseCase struct {
userRepository user.Repository
hasher hash.Hasher
}
func NewAuthenticateUserUseCase(userRepository user.Repository, hasher hash.Hasher) *AuthenticateUserUseCase {
return &AuthenticateUserUseCase{userRepository, hasher}
}
func (a *AuthenticateUserUseCase) Authenticate(username string, password string) (user.User, error) {
dbUser, err := a.userRepository.GetByUsername(username)
if err != nil {
return user.User{}, err
}
if err := a.hasher.Compare(dbUser.Password, password); err != nil {
return user.User{}, user.ErrInvalidCredentials
}
return dbUser, nil
}
+16 -4
View File
@@ -1,6 +1,7 @@
package useCases package useCases
import ( import (
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"time" "time"
@@ -9,6 +10,7 @@ import (
type CreateUserUseCase struct { type CreateUserUseCase struct {
userRepository user.Repository userRepository user.Repository
hasher hash.Hasher
} }
type CreateUserCommand struct { type CreateUserCommand struct {
@@ -17,20 +19,30 @@ type CreateUserCommand struct {
Email string Email string
} }
func NewCreateUserUseCase(userRepository user.Repository) *CreateUserUseCase { func NewCreateUserUseCase(userRepository user.Repository, hasher hash.Hasher) *CreateUserUseCase {
return &CreateUserUseCase{userRepository} return &CreateUserUseCase{userRepository, hasher}
} }
func (g *CreateUserUseCase) CreateUser(createUser CreateUserCommand) (user.User, error) { func (g *CreateUserUseCase) CreateUser(createUser CreateUserCommand) (user.User, error) {
rawUuid := uuid.NewString() rawUuid := uuid.NewString()
hashedPassword, err := g.hasher.Hash(createUser.Password)
if err != nil {
return user.User{}, err
}
hashedVerificationCode, err := g.hasher.Hash(rawUuid)
if err != nil {
return user.User{}, err
}
createdUser, err := g.userRepository.Create(user.User{ createdUser, err := g.userRepository.Create(user.User{
Username: createUser.Username, Username: createUser.Username,
Password: createUser.Password, Password: hashedPassword,
PasswordResetCode: "", PasswordResetCode: "",
Email: createUser.Email, Email: createUser.Email,
IsVerified: false, IsVerified: false,
VerificationCode: rawUuid, VerificationCode: hashedVerificationCode,
VerificationExpiration: time.Now().Add(2 * time.Hour), VerificationExpiration: time.Now().Add(2 * time.Hour),
}) })
if err != nil { if err != nil {
+17 -5
View File
@@ -1,13 +1,17 @@
package useCases package useCases
import "RenewCMS/internal/domain/user" import (
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/user"
)
type UpdateUserUseCase struct { type UpdateUserUseCase struct {
userRepository user.Repository userRepository user.Repository
hasher hash.Hasher
} }
func NewUpdateUserUseCase(userRepository user.Repository) *UpdateUserUseCase { func NewUpdateUserUseCase(userRepository user.Repository, hasher hash.Hasher) *UpdateUserUseCase {
return &UpdateUserUseCase{userRepository} return &UpdateUserUseCase{userRepository, hasher}
} }
func (g *UpdateUserUseCase) UpdateVerificationStatus(userId uint32, isVerified bool) (user.User, error) { func (g *UpdateUserUseCase) UpdateVerificationStatus(userId uint32, isVerified bool) (user.User, error) {
@@ -15,9 +19,17 @@ func (g *UpdateUserUseCase) UpdateVerificationStatus(userId uint32, isVerified b
} }
func (g *UpdateUserUseCase) UpdatePasswordResetCode(userId uint32, code string) (user.User, error) { func (g *UpdateUserUseCase) UpdatePasswordResetCode(userId uint32, code string) (user.User, error) {
return g.userRepository.UpdatePasswordResetCode(userId, code) hashedCode, err := g.hasher.Hash(code)
if err != nil {
return user.User{}, err
}
return g.userRepository.UpdatePasswordResetCode(userId, hashedCode)
} }
func (g *UpdateUserUseCase) UpdatePassword(userId uint32, password string) (user.User, error) { func (g *UpdateUserUseCase) UpdatePassword(userId uint32, password string) (user.User, error) {
return g.userRepository.UpdatePassword(userId, password) hashedPassword, err := g.hasher.Hash(password)
if err != nil {
return user.User{}, err
}
return g.userRepository.UpdatePassword(userId, hashedPassword)
} }
+362
View File
@@ -0,0 +1,362 @@
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"))
}
}
+6 -4
View File
@@ -7,6 +7,7 @@ import (
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
domainArticle "RenewCMS/internal/domain/article" domainArticle "RenewCMS/internal/domain/article"
domainUser "RenewCMS/internal/domain/user" domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/persistence/models" "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories" persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
@@ -20,7 +21,6 @@ import (
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5" "github.com/go-chi/jwtauth/v5"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -130,11 +130,13 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
useCases.NewListArticlesUseCase(articleRepo), useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo), useCases.NewDeleteArticleUseCase(articleRepo),
) )
hasher := hashing.NewBcryptHasher()
authHandler := auth.NewHandler( authHandler := auth.NewHandler(
token, token,
useCases.NewGetUserUseCase(userRepo), useCases.NewGetUserUseCase(userRepo),
useCases.NewListUsersUseCase(userRepo), useCases.NewListUsersUseCase(userRepo),
useCases.NewCreateUserUseCase(userRepo), useCases.NewCreateUserUseCase(userRepo, hasher),
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
) )
r := chi.NewRouter() r := chi.NewRouter()
@@ -235,11 +237,11 @@ func TestNotFoundErrorsHttp(t *testing.T) {
newAliceUserRepo := func() *mockUserRepo { newAliceUserRepo := func() *mockUserRepo {
repo := newMockUserRepo() repo := newMockUserRepo()
hashed, err := bcrypt.GenerateFromPassword([]byte("hunter2example"), 12) hashed, err := hashing.NewBcryptHasher().Hash("hunter2example")
if err != nil { if err != nil {
t.Fatalf("failed to hash password: %v", err) t.Fatalf("failed to hash password: %v", err)
} }
repo.users[1] = domainUser.User{ID: 1, Username: "alice", Password: string(hashed)} repo.users[1] = domainUser.User{ID: 1, Username: "alice", Password: hashed}
return repo return repo
} }
+8 -8
View File
@@ -433,7 +433,7 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
func TestCreateUserAssignsVerificationCode(t *testing.T) { func TestCreateUserAssignsVerificationCode(t *testing.T) {
repo := newMockUserRepo() repo := newMockUserRepo()
uc := useCases.NewCreateUserUseCase(repo) uc := useCases.NewCreateUserUseCase(repo, &stubHasher{})
before := time.Now() before := time.Now()
result, err := uc.CreateUser(useCases.CreateUserCommand{ result, err := uc.CreateUser(useCases.CreateUserCommand{
@@ -480,7 +480,7 @@ func TestCreateUserAssignsVerificationCode(t *testing.T) {
func TestCreateUserPropagatesRepositoryFailure(t *testing.T) { func TestCreateUserPropagatesRepositoryFailure(t *testing.T) {
repo := newMockUserRepo() repo := newMockUserRepo()
repo.fail.failNext("Create") repo.fail.failNext("Create")
uc := useCases.NewCreateUserUseCase(repo) uc := useCases.NewCreateUserUseCase(repo, &stubHasher{})
_, err := uc.CreateUser(useCases.CreateUserCommand{ _, err := uc.CreateUser(useCases.CreateUserCommand{
Username: "alice", Username: "alice",
@@ -590,7 +590,7 @@ func TestUpdateUserDelegatesEachOperation(t *testing.T) {
t.Run("UpdateVerificationStatus_id1", func(t *testing.T) { t.Run("UpdateVerificationStatus_id1", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo) uc := useCases.NewUpdateUserUseCase(repo, &stubHasher{})
result, err := uc.UpdateVerificationStatus(1, true) result, err := uc.UpdateVerificationStatus(1, true)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
@@ -602,7 +602,7 @@ func TestUpdateUserDelegatesEachOperation(t *testing.T) {
t.Run("UpdateVerificationStatus_id9", func(t *testing.T) { t.Run("UpdateVerificationStatus_id9", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo) uc := useCases.NewUpdateUserUseCase(repo, &stubHasher{})
_, err := uc.UpdateVerificationStatus(9, true) _, err := uc.UpdateVerificationStatus(9, true)
if !errors.Is(err, gorm.ErrRecordNotFound) { if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err) t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
@@ -611,7 +611,7 @@ func TestUpdateUserDelegatesEachOperation(t *testing.T) {
t.Run("UpdatePassword_id1", func(t *testing.T) { t.Run("UpdatePassword_id1", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo) uc := useCases.NewUpdateUserUseCase(repo, &stubHasher{})
result, err := uc.UpdatePassword(1, "newpassword123") result, err := uc.UpdatePassword(1, "newpassword123")
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
@@ -623,7 +623,7 @@ func TestUpdateUserDelegatesEachOperation(t *testing.T) {
t.Run("UpdatePassword_id9", func(t *testing.T) { t.Run("UpdatePassword_id9", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo) uc := useCases.NewUpdateUserUseCase(repo, &stubHasher{})
_, err := uc.UpdatePassword(9, "newpassword123") _, err := uc.UpdatePassword(9, "newpassword123")
if !errors.Is(err, gorm.ErrRecordNotFound) { if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err) t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
@@ -632,7 +632,7 @@ func TestUpdateUserDelegatesEachOperation(t *testing.T) {
t.Run("UpdatePasswordResetCode_id1", func(t *testing.T) { t.Run("UpdatePasswordResetCode_id1", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo) uc := useCases.NewUpdateUserUseCase(repo, &stubHasher{})
result, err := uc.UpdatePasswordResetCode(1, "reset-code-123") result, err := uc.UpdatePasswordResetCode(1, "reset-code-123")
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
@@ -644,7 +644,7 @@ func TestUpdateUserDelegatesEachOperation(t *testing.T) {
t.Run("UpdatePasswordResetCode_id9", func(t *testing.T) { t.Run("UpdatePasswordResetCode_id9", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewUpdateUserUseCase(repo) uc := useCases.NewUpdateUserUseCase(repo, &stubHasher{})
_, err := uc.UpdatePasswordResetCode(9, "reset-code-123") _, err := uc.UpdatePasswordResetCode(9, "reset-code-123")
if !errors.Is(err, gorm.ErrRecordNotFound) { if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Errorf("expected gorm.ErrRecordNotFound, got %v", err) t.Errorf("expected gorm.ErrRecordNotFound, got %v", err)
+21 -2
View File
@@ -3,12 +3,31 @@ package test
import ( import (
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"errors"
"sort" "sort"
"testing" "testing"
"gorm.io/gorm" "gorm.io/gorm"
) )
type stubHasher struct {
hashErr error
}
func (s *stubHasher) Hash(value string) (string, error) {
if s.hashErr != nil {
return "", s.hashErr
}
return "hashed:" + value, nil
}
func (s *stubHasher) Compare(hashed string, value string) error {
if hashed == "hashed:"+value {
return nil
}
return errors.New("hash mismatch")
}
type mockUserRepo struct { type mockUserRepo struct {
users map[uint32]user.User users map[uint32]user.User
nextID uint32 nextID uint32
@@ -134,7 +153,7 @@ var _ user.Repository = &mockUserRepo{}
func TestCreateUser(t *testing.T) { func TestCreateUser(t *testing.T) {
repo := newMockUserRepo() repo := newMockUserRepo()
uc := useCases.NewCreateUserUseCase(repo) uc := useCases.NewCreateUserUseCase(repo, &stubHasher{})
cmd := useCases.CreateUserCommand{ cmd := useCases.CreateUserCommand{
Username: "testuser", Username: "testuser",
@@ -157,7 +176,7 @@ func TestCreateUser(t *testing.T) {
func TestUpdateUserVerification(t *testing.T) { func TestUpdateUserVerification(t *testing.T) {
repo := newMockUserRepo() repo := newMockUserRepo()
repo.users[1] = user.User{ID: 1, IsVerified: false} repo.users[1] = user.User{ID: 1, IsVerified: false}
uc := useCases.NewUpdateUserUseCase(repo) uc := useCases.NewUpdateUserUseCase(repo, &stubHasher{})
res, err := uc.UpdateVerificationStatus(1, true) res, err := uc.UpdateVerificationStatus(1, true)
if err != nil { if err != nil {