Files
RenewCMS/test/registration_gate_and_verification_email_test.go
T

335 lines
12 KiB
Go

package test
import (
"RenewCMS/api/handlers/auth"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/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{}
loginHandler := auth.NewLoginHandler(
token,
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", loginHandler.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)
}
})
}