Refac/domain driven #1
No files matched your search
@@ -80,7 +80,7 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo),
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, repos.ImageRepo),
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
|
||||
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher),
|
||||
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL),
|
||||
UpdateUserUseCase: useCases.NewUpdateUserUseCase(repos.UserRepo, hasher),
|
||||
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher),
|
||||
DeleteUserUseCase: useCases.NewDeleteUserUseCase(repos.UserRepo),
|
||||
|
||||
@@ -3,8 +3,10 @@ package auth
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -70,16 +72,6 @@ func (h *Handler) SetJwtCookie(w *http.ResponseWriter, userId uint32) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) SomeUsersVerified() bool {
|
||||
users := h.ListUseCase.ListUsers()
|
||||
for _, localUser := range users {
|
||||
if localUser.IsVerified {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Handler) IsUserTableEmpty() bool {
|
||||
users := h.ListUseCase.ListUsers()
|
||||
return len(users) == 0
|
||||
@@ -138,11 +130,6 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
if h.SomeUsersVerified() {
|
||||
http.Error(w, "You are not allowed to create a user. Log in or reset database.", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var credentials RegisterCredentials
|
||||
err := json.NewDecoder(r.Body).Decode(&credentials)
|
||||
if err != nil {
|
||||
@@ -162,7 +149,11 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
Email: credentials.Email,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
if errors.Is(err, user.ErrRegistrationClosed) {
|
||||
http.Error(w, apperr.RegistrationClosedErrorMessage, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
http.Error(w, apperr.RegistrationFailedErrorMessage, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -5,3 +5,5 @@ const BodyErrorMessage = "The request cannot be processed due to a mismatch in t
|
||||
const IdUint32ErrorMessage = "The server expects the ID to be in the format of an unsigned 32-bit integer (uint32)."
|
||||
const ArticleNotFoundErrorMessage = "Article not found"
|
||||
const ArticleTitleErrorMessage = "Article title must be between 3 and 200 characters."
|
||||
const RegistrationClosedErrorMessage = "You are not allowed to create a user. Log in or reset database."
|
||||
const RegistrationFailedErrorMessage = "Registration failed. Please try again later."
|
||||
@@ -0,0 +1,112 @@
|
||||
Feature: Registration is only open while no verified account exists, and it sends a time-limited verification email
|
||||
|
||||
As the maintainer of RenewCMS
|
||||
I want the "is registration still open" rule enforced by the create-user use case instead of the
|
||||
auth handler, and a verification email sent on every successful registration whose link uses the
|
||||
configured public URL and whose code expires two hours after creation
|
||||
So that the business rule is testable independently of HTTP, and a newly registered user always
|
||||
receives a working, time-limited verification link built from the server's own configuration
|
||||
|
||||
Background:
|
||||
Given POST /v1/auth/register is public, decodes RegisterCredentials (username, password, email)
|
||||
and validates them with validator.Validate.Struct before anything else
|
||||
And today, the gate lives in api/handlers/auth.Handler.SomeUsersVerified, which calls
|
||||
ListUsersUseCase.ListUsers and loops over IsVerified before the body is even decoded; this slice
|
||||
moves that check into CreateUserUseCase.CreateUser, which consults user.Repository.GetAll itself
|
||||
and returns the sentinel user.ErrRegistrationClosed when at least one existing user already has
|
||||
IsVerified true
|
||||
And the handler stops performing the check itself and stops calling ListUsersUseCase for this
|
||||
purpose: it calls CreateUserUseCase.CreateUser unconditionally and maps user.ErrRegistrationClosed
|
||||
to a 403 response with the same text it returns today, "You are not allowed to create a user. Log
|
||||
in or reset database.", now defined as a named constant in api/pkg/apperr (e.g.
|
||||
apperr.RegistrationClosedErrorMessage) instead of an inline literal in the handler
|
||||
And CreateUserUseCase.CreateUser already sets VerificationExpiration to time.Now().Add(2 * time.Hour)
|
||||
and returns the raw (unhashed) verification code while storing only its hash; this slice does not
|
||||
change that expiry computation
|
||||
And the email send moves into CreateUserUseCase itself: NewCreateUserUseCase gains a mail.Repository
|
||||
(the existing domain interface) and the configured public URL string as constructor parameters,
|
||||
both injected from config.Settings.PublicURL in api/dependecyInjection.go; the handler no longer
|
||||
sends mail and no longer holds a SendMailUseCase or PublicURL itself
|
||||
And after user.Repository.Create succeeds, CreateUserUseCase.CreateUser calls
|
||||
mail.Repository.Send with the user's email, template "mailValidation"
|
||||
(internal/infrastructure/mailer/templates/mailValidation.html, which renders a link from
|
||||
"{{.Host}}/register/validate?c={{.VerificationCode}}"), and data whose Host is the injected public
|
||||
URL and whose VerificationCode is the raw (unhashed) code
|
||||
And if mail.Repository.Send returns an error, CreateUserUseCase.CreateUser deletes the
|
||||
just-created user through user.Repository.Delete and returns an error wrapping the send failure,
|
||||
so no half-registered user is left behind and the same username and email can be used to register
|
||||
again
|
||||
And the handler maps that CreateUser error to a 500 response and never sets the jwt cookie in that
|
||||
case, since CreateUser itself never returned a created user
|
||||
|
||||
Scenario: Registration succeeds when no verified user exists and sends the verification email
|
||||
Given the user repository is empty
|
||||
When the client sends POST /v1/auth/register with username "alice", password "hunter2example12",
|
||||
email "alice@example.com"
|
||||
Then the response status is 200
|
||||
And a jwt cookie is set
|
||||
And exactly one email was sent to "alice@example.com" using template "mailValidation"
|
||||
And that email's data has VerificationCode equal to the raw code returned by CreateUser, not its
|
||||
stored hash
|
||||
|
||||
Scenario: The verification email's Host reflects the injected public URL
|
||||
Given a config.Settings value with PublicURL "https://cms.example.com"
|
||||
And the registration flow is built from that settings value
|
||||
When the client sends POST /v1/auth/register with username "alice", password "hunter2example12",
|
||||
email "alice@example.com"
|
||||
Then the email sent to "alice@example.com" has data.Host equal to "https://cms.example.com"
|
||||
And changing the process environment variable PUBLIC_URL afterward does not change that value on a
|
||||
second registration built from the same settings value
|
||||
|
||||
Scenario: The stored verification code expires two hours after creation
|
||||
Given the user repository is empty
|
||||
When the client sends POST /v1/auth/register with username "alice", password "hunter2example12",
|
||||
email "alice@example.com"
|
||||
Then the created user's VerificationExpiration is within 5 seconds of time.Now().Add(2 * time.Hour)
|
||||
|
||||
Scenario: Registration is rejected once a verified user exists, and no email is sent
|
||||
Given the user repository contains a user "alice" with IsVerified true
|
||||
When the client sends POST /v1/auth/register with username "bob", password "hunter2example12",
|
||||
email "bob@example.com"
|
||||
Then the response status is 403
|
||||
And the response body is "You are not allowed to create a user. Log in or reset database."
|
||||
And the user repository still contains only "alice"
|
||||
And no email was sent
|
||||
|
||||
Scenario: A failed verification email rolls back the just-created user
|
||||
Given the user repository is empty
|
||||
And the mail repository is set to fail on its next Send call
|
||||
When the client sends POST /v1/auth/register with username "alice", password "hunter2example12",
|
||||
email "alice@example.com"
|
||||
Then the response status is 500
|
||||
And no jwt cookie is set
|
||||
And the user repository no longer contains a user named "alice"
|
||||
When the client then sends POST /v1/auth/register again with username "alice",
|
||||
password "hunter2example12", email "alice@example.com", and the mail repository now succeeds
|
||||
Then the response status is 200
|
||||
And a jwt cookie is set
|
||||
|
||||
Scenario: The create-user use case itself enforces the gate, independently of any handler
|
||||
Given a user.Repository fake containing one user with IsVerified true
|
||||
And a CreateUserUseCase built directly over that fake, a hasher and a mail.Repository fake
|
||||
When CreateUser is called with username "bob", password "hunter2example12", email "bob@example.com"
|
||||
Then the call returns user.ErrRegistrationClosed, asserted with errors.Is
|
||||
And the fake repository's Create method was never invoked
|
||||
And no email was sent
|
||||
|
||||
Scenario: The create-user use case allows registration while every existing user is unverified
|
||||
Given a user.Repository fake containing two users, both with IsVerified false
|
||||
And a CreateUserUseCase built directly over that fake, a hasher and a mail.Repository fake
|
||||
When CreateUser is called with username "carol", password "hunter2example12",
|
||||
email "carol@example.com"
|
||||
Then the call succeeds and returns a user with IsVerified false
|
||||
And exactly one email was sent to "carol@example.com" using template "mailValidation"
|
||||
|
||||
Scenario: The create-user use case rolls back the user when the mail repository fails
|
||||
Given an empty user.Repository fake
|
||||
And a CreateUserUseCase built directly over that fake, a hasher and a mail.Repository fake set to
|
||||
fail on its next Send call
|
||||
When CreateUser is called with username "alice", password "hunter2example12",
|
||||
email "alice@example.com"
|
||||
Then the call returns an error wrapping the mail repository's failure
|
||||
And the fake repository no longer contains a user named "alice"
|
||||
@@ -0,0 +1,68 @@
|
||||
# QA procedure: registration_gate_and_verification_email
|
||||
|
||||
Package: `test` (black-box), extending `test/user_test.go` (mockUserRepo, stubHasher) and
|
||||
`test/mail_test.go` (mockMailRepo, recordedMail, failSwitch).
|
||||
|
||||
## Setup shared by every case
|
||||
1. Call `validator.InitValidator()` once.
|
||||
2. Use-case-level checks build:
|
||||
`useCases.NewCreateUserUseCase(repo, &stubHasher{}, mailRepo, "https://cms.example.com")`
|
||||
where `repo` is a `mockUserRepo` and `mailRepo` is a `mockMailRepo`. Use `mailRepo.fail.failNext("Send")`
|
||||
to force the next `Send` call to fail for the rollback cases.
|
||||
3. HTTP-level checks wire `mockUserRepo`, `mockMailRepo`,
|
||||
`useCases.NewCreateUserUseCase(repo, &stubHasher{}, mailRepo, publicURL)`,
|
||||
`useCases.NewListUsersUseCase(repo)`, `useCases.NewGetUserUseCase(repo)`,
|
||||
`useCases.NewAuthenticateUserUseCase(repo, ...)` into `auth.NewHandler(...)` (which no longer takes a
|
||||
mail use case or a public URL) with a test `jwtauth.New("HS256", []byte("test-secret"), nil)`. Mount
|
||||
`POST /v1/auth/register` unprotected on a fresh `chi.NewRouter()` mirroring `api/router.go`. No live
|
||||
server, no real SMTP. Default `publicURL` for cases that don't care about its value is
|
||||
`"https://cms.example.com"`.
|
||||
|
||||
## Use-case-level checks (no HTTP)
|
||||
4. Repository pre-populated with one verified user (`IsVerified: true`). Call `CreateUser` with a new
|
||||
username/email/password. Assert `errors.Is(err, user.ErrRegistrationClosed)`. Assert the repository's
|
||||
`Create` was never called (e.g. repo's user count is unchanged, or a `fail.failNext("Create")`
|
||||
sentinel that would only trip if reached). Assert `mailRepo.SentEmails` is empty.
|
||||
5. Repository pre-populated with two unverified users. Call `CreateUser` with a new username. Assert
|
||||
`err == nil`, the returned user has `IsVerified == false`, and `mailRepo.SentEmails` has exactly one
|
||||
entry for the new user's email with `Template == "mailValidation"`.
|
||||
6. On an empty repository, call `CreateUser`. Assert `err == nil` and
|
||||
`abs(result.VerificationExpiration.Sub(time.Now().Add(2*time.Hour))) < 5*time.Second`.
|
||||
7. On an empty repository, set `mailRepo.fail.failNext("Send")`. Call `CreateUser` with
|
||||
username "alice"/email "alice@example.com". Assert `err != nil` (wrapping the mail repository's
|
||||
failure, e.g. `errors.Is(err, errRepositoryFailure)` per the existing `failSwitch` convention). Assert
|
||||
`repo.GetByUsername("alice")` returns `user.ErrUserNotFound` (the created row was deleted). Then clear
|
||||
the failure and call `CreateUser` again with the same username/email → assert `err == nil`.
|
||||
|
||||
## HTTP-level checks (fakes, per the shared setup above)
|
||||
8. `POST /v1/auth/register` with `{"username":"alice","password":"hunter2example12","email":"alice@example.com"}`
|
||||
against an empty `mockUserRepo` → assert status `200`, a `Set-Cookie: jwt=...` header present.
|
||||
Assert `mailRepo.SentEmails` has exactly one entry with `Address == "alice@example.com"` and
|
||||
`Template == "mailValidation"`.
|
||||
9. Inspect that entry's `Data` and assert it carries the raw verification code (compare against the
|
||||
`VerificationCode` field on the user returned by `CreateUser`/stored via the mock repo lookup) — not
|
||||
the hashed value the repository stores.
|
||||
10. Build the handler from a `CreateUserUseCase` constructed with `publicURL = "https://cms.example.com"`.
|
||||
Repeat the registration call. Assert the recorded mail data's `Host` field equals
|
||||
`"https://cms.example.com"`. Then set the process env var `PUBLIC_URL` to a different value and
|
||||
register a second user with the same handler instance; assert the second email's `Host` is still
|
||||
`"https://cms.example.com"` (construction-time value, not read again from the environment).
|
||||
11. Seed `mockUserRepo` with one user having `IsVerified: true`. `POST /v1/auth/register` with a new
|
||||
username/email → assert status `403`, body exactly
|
||||
`"You are not allowed to create a user. Log in or reset database."` (the value of the new
|
||||
`apperr` constant), `mockUserRepo` still holds only the original user, and `mailRepo.SentEmails` is
|
||||
still empty.
|
||||
12. On an empty `mockUserRepo`, set `mailRepo.fail.failNext("Send")`. `POST /v1/auth/register` with
|
||||
`{"username":"alice","password":"hunter2example12","email":"alice@example.com"}` → assert status
|
||||
`500`, no `Set-Cookie: jwt=...` header present, and `mockUserRepo` does not contain a user named
|
||||
"alice". Then, without re-arming the failure, `POST /v1/auth/register` again with the same body →
|
||||
assert status `200` and a `Set-Cookie: jwt=...` header present (the username and email are free
|
||||
again).
|
||||
|
||||
## Manual verification (not exercised by the Go test)
|
||||
- The real SMTP send (`internal/infrastructure/mailer.MailRepository.Send` actually dialing a server) is
|
||||
never exercised automatically; verify by hand against a real or sandboxed SMTP server that the
|
||||
rendered `mailValidation.html` link matches `PUBLIC_URL` from the deployed `.env` and that clicking it
|
||||
reaches the SPA's `/register/validate` route.
|
||||
- The SPA's handling of an expired or already-used verification code (any UI/UX around
|
||||
`/register/validate?c=...`) is out of scope for this slice and not covered here.
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
var ErrUserNotFound = errors.New("user not found")
|
||||
var ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
var ErrRegistrationClosed = errors.New("registration closed")
|
||||
|
||||
type User struct {
|
||||
ID uint32 `json:"id"`
|
||||
|
||||
@@ -2,7 +2,10 @@ package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/hash"
|
||||
"RenewCMS/internal/domain/mail"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -11,6 +14,8 @@ import (
|
||||
type CreateUserUseCase struct {
|
||||
userRepository user.Repository
|
||||
hasher hash.Hasher
|
||||
mailRepository mail.Repository
|
||||
publicURL string
|
||||
}
|
||||
|
||||
type CreateUserCommand struct {
|
||||
@@ -19,11 +24,20 @@ type CreateUserCommand struct {
|
||||
Email string
|
||||
}
|
||||
|
||||
func NewCreateUserUseCase(userRepository user.Repository, hasher hash.Hasher) *CreateUserUseCase {
|
||||
return &CreateUserUseCase{userRepository, hasher}
|
||||
type MailValidationData struct {
|
||||
Host string
|
||||
VerificationCode string
|
||||
}
|
||||
|
||||
func NewCreateUserUseCase(userRepository user.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string) *CreateUserUseCase {
|
||||
return &CreateUserUseCase{userRepository, hasher, mailRepository, publicURL}
|
||||
}
|
||||
|
||||
func (g *CreateUserUseCase) CreateUser(createUser CreateUserCommand) (user.User, error) {
|
||||
if g.registrationClosed() {
|
||||
return user.User{}, user.ErrRegistrationClosed
|
||||
}
|
||||
|
||||
rawUuid := uuid.NewString()
|
||||
|
||||
hashedPassword, err := g.hasher.Hash(createUser.Password)
|
||||
@@ -50,5 +64,26 @@ func (g *CreateUserUseCase) CreateUser(createUser CreateUserCommand) (user.User,
|
||||
}
|
||||
|
||||
createdUser.VerificationCode = rawUuid
|
||||
|
||||
if err := g.mailRepository.Send(createdUser.Email, "mailValidation", MailValidationData{
|
||||
Host: g.publicURL,
|
||||
VerificationCode: rawUuid,
|
||||
}); err != nil {
|
||||
sendErr := fmt.Errorf("failed to send verification email: %w", err)
|
||||
if deleteErr := g.userRepository.Delete(createdUser.ID); deleteErr != nil {
|
||||
return user.User{}, errors.Join(sendErr, deleteErr)
|
||||
}
|
||||
return user.User{}, sendErr
|
||||
}
|
||||
|
||||
return createdUser, nil
|
||||
}
|
||||
|
||||
func (g *CreateUserUseCase) registrationClosed() bool {
|
||||
for _, existingUser := range g.userRepository.GetAll() {
|
||||
if existingUser.IsVerified {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
+4
-4
@@ -34,7 +34,7 @@ func (s *spyHasher) Compare(hashed string, value string) error {
|
||||
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{})
|
||||
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{}, newMockMailRepo(), "https://cms.example.com")
|
||||
|
||||
result, err := uc.CreateUser(useCases.CreateUserCommand{
|
||||
Username: "alice",
|
||||
@@ -59,7 +59,7 @@ func TestCreateUserUseCaseHashesPasswordAndVerificationCode(t *testing.T) {
|
||||
|
||||
t.Run("a hashing failure persists nothing", func(t *testing.T) {
|
||||
repo := newMockUserRepo()
|
||||
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{hashErr: errStubHashFailure})
|
||||
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{hashErr: errStubHashFailure}, newMockMailRepo(), "https://cms.example.com")
|
||||
|
||||
_, err := uc.CreateUser(useCases.CreateUserCommand{
|
||||
Username: "alice",
|
||||
@@ -216,7 +216,7 @@ func newHasherRouter(userRepo *mockUserRepo, hasher *stubHasher) *chi.Mux {
|
||||
token,
|
||||
useCases.NewGetUserUseCase(userRepo),
|
||||
useCases.NewListUsersUseCase(userRepo),
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher),
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||
true,
|
||||
)
|
||||
@@ -236,7 +236,7 @@ func newRealHasherRouter(userRepo *mockUserRepo) *chi.Mux {
|
||||
token,
|
||||
useCases.NewGetUserUseCase(userRepo),
|
||||
useCases.NewListUsersUseCase(userRepo),
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher),
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||
true,
|
||||
)
|
||||
|
||||
@@ -136,7 +136,7 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
|
||||
token,
|
||||
useCases.NewGetUserUseCase(userRepo),
|
||||
useCases.NewListUsersUseCase(userRepo),
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher),
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||
true,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
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"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
type sequentialHasher struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *sequentialHasher) Hash(value string) (string, error) {
|
||||
s.calls++
|
||||
if s.calls == 2 {
|
||||
return "", errStubHashFailure
|
||||
}
|
||||
return "hashed:" + value, nil
|
||||
}
|
||||
|
||||
func (s *sequentialHasher) Compare(hashed string, value string) error {
|
||||
if hashed == "hashed:"+value {
|
||||
return nil
|
||||
}
|
||||
return errors.New("hash mismatch")
|
||||
}
|
||||
|
||||
func newRegistrationGateRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, publicURL string) *chi.Mux {
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
hasher := &stubHasher{}
|
||||
authHandler := auth.NewHandler(
|
||||
token,
|
||||
useCases.NewGetUserUseCase(userRepo),
|
||||
useCases.NewListUsersUseCase(userRepo),
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, publicURL),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||
true,
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Route("/v1/auth", func(r chi.Router) {
|
||||
r.Post("/register", authHandler.Register)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func registerRequest(t *testing.T, router *chi.Mux, username, password, email string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(map[string]string{"username": username, "password": password, "email": email})
|
||||
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)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestRegistrationGateAndVerificationEmailHttp(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
|
||||
t.Run("Registration succeeds when no verified user exists and sends the verification email", func(t *testing.T) {
|
||||
userRepo := newMockUserRepo()
|
||||
mailRepo := newMockMailRepo()
|
||||
router := newRegistrationGateRouter(userRepo, mailRepo, "https://cms.example.com")
|
||||
|
||||
rec := registerRequest(t, router, "alice", "hunter2example12", "alice@example.com")
|
||||
|
||||
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")
|
||||
}
|
||||
if len(mailRepo.SentEmails) != 1 {
|
||||
t.Fatalf("expected exactly one email to be sent, got %d", len(mailRepo.SentEmails))
|
||||
}
|
||||
sent := mailRepo.SentEmails[0]
|
||||
if sent.Address != "alice@example.com" {
|
||||
t.Errorf("expected email sent to alice@example.com, got %q", sent.Address)
|
||||
}
|
||||
if sent.Template != "mailValidation" {
|
||||
t.Errorf("expected template mailValidation, got %q", sent.Template)
|
||||
}
|
||||
|
||||
storedUser, err := userRepo.GetByUsername("alice")
|
||||
if err != nil {
|
||||
t.Fatalf("expected alice to be stored: %v", err)
|
||||
}
|
||||
|
||||
data, ok := sent.Data.(useCases.MailValidationData)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected mail data type %T", sent.Data)
|
||||
}
|
||||
if data.VerificationCode == "" || data.VerificationCode == storedUser.VerificationCode {
|
||||
t.Errorf("expected the raw verification code, not the stored hash, got %q vs stored %q", data.VerificationCode, storedUser.VerificationCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The verification email's Host reflects the injected public URL", func(t *testing.T) {
|
||||
userRepo := newMockUserRepo()
|
||||
mailRepo := newMockMailRepo()
|
||||
router := newRegistrationGateRouter(userRepo, mailRepo, "https://cms.example.com")
|
||||
|
||||
rec := registerRequest(t, router, "alice", "hunter2example12", "alice@example.com")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
data := mailRepo.SentEmails[0].Data.(useCases.MailValidationData)
|
||||
if data.Host != "https://cms.example.com" {
|
||||
t.Errorf("expected Host https://cms.example.com, got %q", data.Host)
|
||||
}
|
||||
|
||||
t.Setenv("PUBLIC_URL", "https://attacker.example.com")
|
||||
|
||||
secondRec := registerRequest(t, router, "bob", "hunter2example12", "bob@example.com")
|
||||
if secondRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200 for bob, got %d: %s", secondRec.Code, secondRec.Body.String())
|
||||
}
|
||||
secondData := mailRepo.SentEmails[1].Data.(useCases.MailValidationData)
|
||||
if secondData.Host != "https://cms.example.com" {
|
||||
t.Errorf("expected Host to remain https://cms.example.com after env change, got %q", secondData.Host)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The stored verification code expires two hours after creation", func(t *testing.T) {
|
||||
userRepo := newMockUserRepo()
|
||||
mailRepo := newMockMailRepo()
|
||||
router := newRegistrationGateRouter(userRepo, mailRepo, "https://cms.example.com")
|
||||
|
||||
rec := registerRequest(t, router, "alice", "hunter2example12", "alice@example.com")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
storedUser, err := userRepo.GetByUsername("alice")
|
||||
if err != nil {
|
||||
t.Fatalf("expected alice to be stored: %v", err)
|
||||
}
|
||||
|
||||
expected := time.Now().Add(2 * time.Hour)
|
||||
diff := storedUser.VerificationExpiration.Sub(expected)
|
||||
if diff < -5*time.Second || diff > 5*time.Second {
|
||||
t.Errorf("expected VerificationExpiration within 5 seconds of %v, got %v", expected, storedUser.VerificationExpiration)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Registration is rejected once a verified user exists, and no email is sent", func(t *testing.T) {
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com", IsVerified: true}
|
||||
userRepo.nextID = 1
|
||||
mailRepo := newMockMailRepo()
|
||||
router := newRegistrationGateRouter(userRepo, mailRepo, "https://cms.example.com")
|
||||
|
||||
rec := registerRequest(t, router, "bob", "hunter2example12", "bob@example.com")
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected status 403, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Body.String() != apperr.RegistrationClosedErrorMessage+"\n" {
|
||||
t.Errorf("unexpected body %q", rec.Body.String())
|
||||
}
|
||||
if len(userRepo.users) != 1 {
|
||||
t.Errorf("expected the user repository to still hold only alice, got %d users", len(userRepo.users))
|
||||
}
|
||||
if len(mailRepo.SentEmails) != 0 {
|
||||
t.Errorf("expected no email to be sent, got %d", len(mailRepo.SentEmails))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failed verification email rolls back the just-created user", func(t *testing.T) {
|
||||
userRepo := newMockUserRepo()
|
||||
mailRepo := newMockMailRepo()
|
||||
mailRepo.fail.failNext("Send")
|
||||
router := newRegistrationGateRouter(userRepo, mailRepo, "https://cms.example.com")
|
||||
|
||||
rec := registerRequest(t, router, "alice", "hunter2example12", "alice@example.com")
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected status 500, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Body.String() != apperr.RegistrationFailedErrorMessage+"\n" {
|
||||
t.Errorf("unexpected body %q", rec.Body.String())
|
||||
}
|
||||
if rec.Header().Get("Set-Cookie") != "" {
|
||||
t.Errorf("expected no jwt cookie to be set")
|
||||
}
|
||||
if _, err := userRepo.GetByUsername("alice"); !errors.Is(err, user.ErrUserNotFound) {
|
||||
t.Errorf("expected alice to no longer exist, got %v", err)
|
||||
}
|
||||
|
||||
secondRec := registerRequest(t, router, "alice", "hunter2example12", "alice@example.com")
|
||||
if secondRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200 on retry, got %d: %s", secondRec.Code, secondRec.Body.String())
|
||||
}
|
||||
if secondRec.Header().Get("Set-Cookie") == "" {
|
||||
t.Errorf("expected a jwt cookie to be set on the successful retry")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegistrationGateAndVerificationEmailUseCase(t *testing.T) {
|
||||
t.Run("The create-user use case itself enforces the gate, independently of any handler", func(t *testing.T) {
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com", IsVerified: true}
|
||||
userRepo.nextID = 1
|
||||
userRepo.fail.failNext("Create")
|
||||
mailRepo := newMockMailRepo()
|
||||
uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com")
|
||||
|
||||
_, err := uc.CreateUser(useCases.CreateUserCommand{
|
||||
Username: "bob",
|
||||
Password: "hunter2example12",
|
||||
Email: "bob@example.com",
|
||||
})
|
||||
|
||||
if !errors.Is(err, user.ErrRegistrationClosed) {
|
||||
t.Fatalf("expected user.ErrRegistrationClosed, got %v", err)
|
||||
}
|
||||
if len(mailRepo.SentEmails) != 0 {
|
||||
t.Errorf("expected no email to be sent, got %d", len(mailRepo.SentEmails))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The create-user use case allows registration while every existing user is unverified", func(t *testing.T) {
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com", IsVerified: false}
|
||||
userRepo.users[2] = user.User{ID: 2, Username: "bob", Email: "bob@example.com", IsVerified: false}
|
||||
userRepo.nextID = 2
|
||||
mailRepo := newMockMailRepo()
|
||||
uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com")
|
||||
|
||||
result, err := uc.CreateUser(useCases.CreateUserCommand{
|
||||
Username: "carol",
|
||||
Password: "hunter2example12",
|
||||
Email: "carol@example.com",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if result.IsVerified {
|
||||
t.Errorf("expected the new user to be unverified")
|
||||
}
|
||||
if len(mailRepo.SentEmails) != 1 {
|
||||
t.Fatalf("expected exactly one email sent, got %d", len(mailRepo.SentEmails))
|
||||
}
|
||||
if mailRepo.SentEmails[0].Address != "carol@example.com" {
|
||||
t.Errorf("expected email sent to carol@example.com, got %q", mailRepo.SentEmails[0].Address)
|
||||
}
|
||||
if mailRepo.SentEmails[0].Template != "mailValidation" {
|
||||
t.Errorf("expected template mailValidation, got %q", mailRepo.SentEmails[0].Template)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failure hashing the verification code persists nothing", func(t *testing.T) {
|
||||
userRepo := newMockUserRepo()
|
||||
mailRepo := newMockMailRepo()
|
||||
uc := useCases.NewCreateUserUseCase(userRepo, &sequentialHasher{}, mailRepo, "https://cms.example.com")
|
||||
|
||||
_, err := uc.CreateUser(useCases.CreateUserCommand{
|
||||
Username: "alice",
|
||||
Password: "hunter2example12",
|
||||
Email: "alice@example.com",
|
||||
})
|
||||
|
||||
if !errors.Is(err, errStubHashFailure) {
|
||||
t.Fatalf("expected the hashing error, got %v", err)
|
||||
}
|
||||
if len(userRepo.users) != 0 {
|
||||
t.Errorf("expected no user stored, got %d", len(userRepo.users))
|
||||
}
|
||||
if len(mailRepo.SentEmails) != 0 {
|
||||
t.Errorf("expected no email to be sent, got %d", len(mailRepo.SentEmails))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The create-user use case rolls back the user when the mail repository fails", func(t *testing.T) {
|
||||
userRepo := newMockUserRepo()
|
||||
mailRepo := newMockMailRepo()
|
||||
mailRepo.fail.failNext("Send")
|
||||
uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com")
|
||||
|
||||
_, err := uc.CreateUser(useCases.CreateUserCommand{
|
||||
Username: "alice",
|
||||
Password: "hunter2example12",
|
||||
Email: "alice@example.com",
|
||||
})
|
||||
|
||||
if !errors.Is(err, errRepositoryFailure) {
|
||||
t.Fatalf("expected an error wrapping errRepositoryFailure, got %v", err)
|
||||
}
|
||||
if _, err := userRepo.GetByUsername("alice"); !errors.Is(err, user.ErrUserNotFound) {
|
||||
t.Errorf("expected alice to no longer exist, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The create-user use case reports both failures when the rollback delete also fails", func(t *testing.T) {
|
||||
userRepo := newMockUserRepo()
|
||||
mailRepo := newMockMailRepo()
|
||||
mailRepo.fail.failNext("Send")
|
||||
userRepo.fail.failNext("Delete")
|
||||
uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com")
|
||||
|
||||
_, err := uc.CreateUser(useCases.CreateUserCommand{
|
||||
Username: "alice",
|
||||
Password: "hunter2example12",
|
||||
Email: "alice@example.com",
|
||||
})
|
||||
|
||||
if !errors.Is(err, errRepositoryFailure) {
|
||||
t.Fatalf("expected an error wrapping errRepositoryFailure, got %v", err)
|
||||
}
|
||||
joined, ok := err.(interface{ Unwrap() []error })
|
||||
if !ok {
|
||||
t.Fatalf("expected a joined error, got %T: %v", err, err)
|
||||
}
|
||||
if len(joined.Unwrap()) != 2 {
|
||||
t.Fatalf("expected the joined error to wrap both the send and delete failures, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func newSecureCookieAuthRouter(t *testing.T, isProduction bool) (*chi.Mux, *mock
|
||||
token,
|
||||
useCases.NewGetUserUseCase(userRepo),
|
||||
useCases.NewListUsersUseCase(userRepo),
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher),
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||
isProduction,
|
||||
)
|
||||
@@ -140,7 +140,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo),
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo, imageRepo),
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
|
||||
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher),
|
||||
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||
UpdateUserUseCase: useCases.NewUpdateUserUseCase(userRepo, hasher),
|
||||
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||
DeleteUserUseCase: useCases.NewDeleteUserUseCase(userRepo),
|
||||
|
||||
@@ -156,7 +156,7 @@ func newUnverifiedRegistrationRouter(userRepo *mockUserRepo, token *jwtauth.JWTA
|
||||
token,
|
||||
useCases.NewGetUserUseCase(userRepo),
|
||||
useCases.NewListUsersUseCase(userRepo),
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher),
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||
true,
|
||||
)
|
||||
|
||||
@@ -440,7 +440,7 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
|
||||
|
||||
func TestCreateUserAssignsVerificationCode(t *testing.T) {
|
||||
repo := newMockUserRepo()
|
||||
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{})
|
||||
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{}, newMockMailRepo(), "https://cms.example.com")
|
||||
|
||||
before := time.Now()
|
||||
result, err := uc.CreateUser(useCases.CreateUserCommand{
|
||||
@@ -487,7 +487,7 @@ func TestCreateUserAssignsVerificationCode(t *testing.T) {
|
||||
func TestCreateUserPropagatesRepositoryFailure(t *testing.T) {
|
||||
repo := newMockUserRepo()
|
||||
repo.fail.failNext("Create")
|
||||
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{})
|
||||
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{}, newMockMailRepo(), "https://cms.example.com")
|
||||
|
||||
_, err := uc.CreateUser(useCases.CreateUserCommand{
|
||||
Username: "alice",
|
||||
|
||||
+1
-1
@@ -154,7 +154,7 @@ var _ user.Repository = &mockUserRepo{}
|
||||
|
||||
func TestCreateUser(t *testing.T) {
|
||||
repo := newMockUserRepo()
|
||||
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{})
|
||||
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{}, newMockMailRepo(), "https://cms.example.com")
|
||||
|
||||
cmd := useCases.CreateUserCommand{
|
||||
Username: "testuser",
|
||||
|
||||
Reference in new issue
Block a user