diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go index c866698..6367f4b 100644 --- a/api/dependecyInjection.go +++ b/api/dependecyInjection.go @@ -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), diff --git a/api/handlers/auth/authHandler.go b/api/handlers/auth/authHandler.go index c199b03..b613107 100644 --- a/api/handlers/auth/authHandler.go +++ b/api/handlers/auth/authHandler.go @@ -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 } diff --git a/api/pkg/apperr/messages.go b/api/pkg/apperr/messages.go index 0f84a72..cf12b27 100644 --- a/api/pkg/apperr/messages.go +++ b/api/pkg/apperr/messages.go @@ -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." diff --git a/features/registration_gate_and_verification_email.feature b/features/registration_gate_and_verification_email.feature new file mode 100644 index 0000000..ef3023c --- /dev/null +++ b/features/registration_gate_and_verification_email.feature @@ -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" diff --git a/features/registration_gate_and_verification_email.qa.md b/features/registration_gate_and_verification_email.qa.md new file mode 100644 index 0000000..ef7a94b --- /dev/null +++ b/features/registration_gate_and_verification_email.qa.md @@ -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. diff --git a/internal/domain/user/userDomain.go b/internal/domain/user/userDomain.go index 97c03be..81e1361 100644 --- a/internal/domain/user/userDomain.go +++ b/internal/domain/user/userDomain.go @@ -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"` diff --git a/internal/infrastructure/useCases/CreateUser.go b/internal/infrastructure/useCases/CreateUser.go index c35373a..f5d3f97 100644 --- a/internal/infrastructure/useCases/CreateUser.go +++ b/internal/infrastructure/useCases/CreateUser.go @@ -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 +} diff --git a/test/hasher_test.go b/test/hasher_test.go index 7eaa0b2..9e9d5f6 100644 --- a/test/hasher_test.go +++ b/test/hasher_test.go @@ -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, ) diff --git a/test/not_found_errors_test.go b/test/not_found_errors_test.go index f765f14..ad42172 100644 --- a/test/not_found_errors_test.go +++ b/test/not_found_errors_test.go @@ -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, ) diff --git a/test/registration_gate_and_verification_email_test.go b/test/registration_gate_and_verification_email_test.go new file mode 100644 index 0000000..b697172 --- /dev/null +++ b/test/registration_gate_and_verification_email_test.go @@ -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) + } + }) +} diff --git a/test/runtime_settings_injection_http_test.go b/test/runtime_settings_injection_http_test.go index 7eb2071..1c37dce 100644 --- a/test/runtime_settings_injection_http_test.go +++ b/test/runtime_settings_injection_http_test.go @@ -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), diff --git a/test/unverified_users_and_reregistration_test.go b/test/unverified_users_and_reregistration_test.go index bc9b731..5c4965e 100644 --- a/test/unverified_users_and_reregistration_test.go +++ b/test/unverified_users_and_reregistration_test.go @@ -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, ) diff --git a/test/use_case_baseline_test.go b/test/use_case_baseline_test.go index 56dd4ae..b551a76 100644 --- a/test/use_case_baseline_test.go +++ b/test/use_case_baseline_test.go @@ -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", diff --git a/test/user_test.go b/test/user_test.go index b2eaf30..57f03f9 100644 --- a/test/user_test.go +++ b/test/user_test.go @@ -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",