335 lines
12 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|