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, discardRecorder), useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), 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 != "verification" { t.Errorf("expected template verification, 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", discardRecorder) _, err := uc.CreateUser(testCtx, 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", discardRecorder) result, err := uc.CreateUser(testCtx, 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 != "verification" { t.Errorf("expected template verification, 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", discardRecorder) _, err := uc.CreateUser(testCtx, 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", discardRecorder) _, err := uc.CreateUser(testCtx, 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", discardRecorder) _, err := uc.CreateUser(testCtx, 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) } }) }