Refac/domain driven #1

Merged
fsylvain merged 40 commits from refac/domain-driven into main 2026-09-25 16:15:15 +02:00
14 changed files with 575 additions and 30 deletions
Showing only changes of commit 6d510d2805 - Show all commits

No files matched your search

+1 -1
View File
@@ -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),
+7 -16
View File
@@ -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
}
+2
View File
@@ -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.
+1
View File
@@ -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"`
+37 -2
View File
@@ -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
View File
@@ -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,
)
+1 -1
View File
@@ -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)
}
})
}
+2 -2
View File
@@ -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,
)
+2 -2
View File
@@ -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
View File
@@ -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",