refac: register only open while no verified acc exists, enforced by a use case rather than handler & sends verification email whose link uses the conf pub URL whose code expires after 2h
This commit is contained in:
1 parent
6e9e108033
commit
6d510d2805
14 files changed
+575
-30
No files matched your search
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user