veza/veza-backend-api/internal/handlers/auth_handler_test.go
senke 3ee16bbe23 fix(backend,infra): send real verification emails + fail-loud in prod
Registration was setting `IsVerified: true` at user-create time and the
"send email" block was a `logger.Info("Sending verification email")` — no
SMTP call. On production this meant any attacker-typo or typosquat email
got a fully-verified account because the user never had to prove
ownership. In development the hack let people "log in" without checking
MailHog, masking SMTP misconfiguration.

Changes:

  * `core/auth/service.go`: new users start with `IsVerified: false`. The
    existing `POST /auth/verify-email` flow (unchanged) flips the bit
    when the user clicks the link.
  * Registration now calls `emailService.SendVerificationEmail(...)` for
    real. On SMTP failure the handler returns `500` in production (no
    stuck account with no recovery path) and logs a warning in
    development (local sign-ups keep flowing).
  * Same treatment for `password_reset_handler.RequestPasswordReset` —
    production fails loud instead of returning the generic success
    message after a silent SMTP drop.
  * New helper `isProductionEnv()` centralises the
    `APP_ENV=="production"` check in both `core/auth` and `handlers`.
  * `docker-compose.yml` + `docker-compose.dev.yml` now ship MailHog
    (`mailhog/mailhog:v1.0.1`, SMTP 1025, UI 8025). Backend dev env
    vars `SMTP_HOST=mailhog SMTP_PORT=1025` pre-wired so dev sign-ups
    actually deliver.

Tests: auth test mocks updated (`expectRegister` adds a
`SendVerificationEmail` mock). `TestAuthService_Login_Success` +
`TestAuthHandler_Login_Success` flip `is_verified` directly after
`Register` to simulate the verification click.
`TestLogin_EmailNotVerified` now asserts `403` (previously asserted
`200` — the test was codifying the bug this commit fixes).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 14:52:46 +02:00

599 lines
18 KiB
Go

package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"veza-backend-api/internal/config"
"veza-backend-api/internal/core/auth"
"veza-backend-api/internal/database"
"veza-backend-api/internal/dto"
"veza-backend-api/internal/models"
"veza-backend-api/internal/repositories"
"veza-backend-api/internal/services"
"veza-backend-api/internal/validators"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"go.uber.org/zap/zaptest"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// setupAuthTestRouter creates a test router with real auth service and mocked dependencies.
// Optional authMiddlewares are applied to the /auth group before routes (for tests needing user_id in context).
func setupAuthTestRouter(t *testing.T, authMiddlewares ...gin.HandlerFunc) (*gin.Engine, *auth.AuthService, *services.SessionService, *services.TwoFactorService, *services.UserService, *zap.Logger, func(), *gorm.DB) {
gin.SetMode(gin.TestMode)
logger := zaptest.NewLogger(t)
// Setup in-memory database
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
// Enable foreign keys for SQLite
db.Exec("PRAGMA foreign_keys = ON")
// Auto-migrate models
err = db.AutoMigrate(
&models.User{},
&models.RefreshToken{},
&models.Session{},
)
require.NoError(t, err)
// Create email_verification_tokens table manually (no GORM model)
err = db.Exec(`
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
token_hash TEXT NOT NULL,
email TEXT NOT NULL,
verified INTEGER NOT NULL DEFAULT 0,
used INTEGER NOT NULL DEFAULT 0,
verified_at TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`).Error
require.NoError(t, err)
// Create database wrapper
dbWrapper := &database.Database{}
dbWrapper.GormDB = db
// Get underlying SQL DB for services that need it (like EmailVerificationService)
sqlDB, err := db.DB()
require.NoError(t, err)
dbWrapper.DB = sqlDB
// Add 2FA columns to users (SQLite test DB; GORM User model may not have them)
// Ignore errors if columns already exist (e.g. from a future GORM migration)
_, _ = sqlDB.Exec("ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN DEFAULT 0")
_, _ = sqlDB.Exec("ALTER TABLE users ADD COLUMN two_factor_secret TEXT DEFAULT ''")
_, _ = sqlDB.Exec("ALTER TABLE users ADD COLUMN backup_codes TEXT DEFAULT '[]'")
// Setup services
emailValidator := validators.NewEmailValidator(db)
passwordValidator := validators.NewPasswordValidator()
passwordService := services.NewPasswordService(dbWrapper, logger)
jwtService, err := services.NewJWTService("", "", "test-secret-key-must-be-32-chars-long", "test-issuer", "test-audience")
require.NoError(t, err)
refreshTokenService := services.NewRefreshTokenService(db)
emailVerificationService := services.NewEmailVerificationService(dbWrapper, logger)
passwordResetService := services.NewPasswordResetService(dbWrapper, logger)
emailService := services.NewEmailService(dbWrapper, logger)
// Create AuthService
authService := auth.NewAuthService(
db,
emailValidator,
passwordValidator,
passwordService,
jwtService,
refreshTokenService,
emailVerificationService,
passwordResetService,
emailService,
nil, // jobWorker - not needed for unit tests
nil, // refreshLock - not needed for unit tests
logger,
)
// Create other services
sessionService := services.NewSessionService(dbWrapper, logger)
twoFactorService := services.NewTwoFactorService(dbWrapper, logger)
// Create UserService with GORM repository
userRepo := repositories.NewGormUserRepository(db)
userService := services.NewUserServiceWithDB(userRepo, db)
// Create default config
cfg := &config.Config{}
router := gin.New()
authGroup := router.Group("/auth")
for _, mw := range authMiddlewares {
authGroup.Use(mw)
}
{
authGroup.POST("/login", Login(authService, sessionService, twoFactorService, logger, cfg))
authGroup.POST("/register", Register(authService, sessionService, logger, cfg))
authGroup.POST("/refresh", Refresh(authService, sessionService, logger, cfg))
authGroup.POST("/logout", Logout(authService, sessionService, logger, cfg))
authGroup.POST("/verify-email", VerifyEmail(authService))
authGroup.POST("/resend-verification", ResendVerification(authService, logger))
authGroup.GET("/check-username", CheckUsername(authService))
authGroup.GET("/me", GetMe(userService))
}
cleanup := func() {
// Database cleanup handled by test
}
return router, authService, sessionService, twoFactorService, userService, logger, cleanup, db
}
func TestLogin_Success(t *testing.T) {
router, authService, _, _, _, _, cleanup, db := setupAuthTestRouter(t)
defer cleanup()
// Create a test user first
ctx := context.Background()
user, _, err := authService.Register(ctx, "test@example.com", "testuser", "SecurePassword123!")
require.NoError(t, err)
require.NotNil(t, user)
// Verify email to allow login
db.Model(&models.User{}).Where("id = ?", user.ID).Update("is_verified", true)
reqBody := dto.LoginRequest{
Email: "test@example.com",
Password: "SecurePassword123!",
RememberMe: false,
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response APIResponse
json.Unmarshal(w.Body.Bytes(), &response)
assert.True(t, response.Success)
}
func TestLogin_InvalidCredentials(t *testing.T) {
router, authService, _, _, _, _, cleanup, db := setupAuthTestRouter(t)
defer cleanup()
// Create a test user first
ctx := context.Background()
user, _, err := authService.Register(ctx, "test@example.com", "testuser", "SecurePassword123!")
require.NoError(t, err)
require.NotNil(t, user)
// Verify email
db.Model(&models.User{}).Where("id = ?", user.ID).Update("is_verified", true)
reqBody := dto.LoginRequest{
Email: "test@example.com",
Password: "wrongpassword",
RememberMe: false,
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code) // invalid credentials -> 401 (auth.go returns 401)
}
func TestLogin_EmailNotVerified(t *testing.T) {
router, authService, _, _, _, _, cleanup, _ := setupAuthTestRouter(t)
defer cleanup()
// Create a test user but don't verify email
ctx := context.Background()
user, _, err := authService.Register(ctx, "test@example.com", "testuser", "SecurePassword123!")
require.NoError(t, err)
require.NotNil(t, user)
// User is not verified by default (v1.0.4: Register leaves is_verified=false).
reqBody := dto.LoginRequest{
Email: "test@example.com",
Password: "SecurePassword123!",
RememberMe: false,
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Login refuses unverified users; the user must POST /auth/verify-email first.
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestLogin_Requires2FA(t *testing.T) {
router, authService, _, twoFactorService, _, _, cleanup, db := setupAuthTestRouter(t)
defer cleanup()
// Create a test user
ctx := context.Background()
user, _, err := authService.Register(ctx, "test@example.com", "testuser", "SecurePassword123!")
require.NoError(t, err)
require.NotNil(t, user)
// Verify email - use GORM directly
db.Model(&models.User{}).Where("id = ?", user.ID).Update("is_verified", true)
// Enable 2FA for user - generate secret and enable
setup, err := twoFactorService.GenerateSecret(user)
require.NoError(t, err)
err = twoFactorService.EnableTwoFactor(ctx, user.ID, setup.Secret, setup.RecoveryCodes)
require.NoError(t, err)
reqBody := dto.LoginRequest{
Email: "test@example.com",
Password: "SecurePassword123!",
RememberMe: false,
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response APIResponse
json.Unmarshal(w.Body.Bytes(), &response)
assert.True(t, response.Success)
// Check that response contains requires_2fa flag
var loginResponse dto.LoginResponse
responseData, _ := json.Marshal(response.Data)
json.Unmarshal(responseData, &loginResponse)
assert.True(t, loginResponse.Requires2FA)
}
func TestLogin_InvalidRequest(t *testing.T) {
router, _, _, _, _, _, cleanup, _ := setupAuthTestRouter(t)
defer cleanup()
// Missing email
reqBody := map[string]interface{}{
"password": "password123",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestRegister_Success(t *testing.T) {
router, _, _, _, _, _, cleanup, _ := setupAuthTestRouter(t)
defer cleanup()
reqBody := dto.RegisterRequest{
Email: "newuser@example.com",
Username: "newuser",
Password: "SecurePassword123!",
PasswordConfirm: "SecurePassword123!",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/auth/register", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
var response APIResponse
json.Unmarshal(w.Body.Bytes(), &response)
assert.True(t, response.Success)
}
func TestRegister_UserAlreadyExists(t *testing.T) {
router, authService, _, _, _, _, cleanup, _ := setupAuthTestRouter(t)
defer cleanup()
// Create a user first
ctx := context.Background()
_, _, err := authService.Register(ctx, "existing@example.com", "existinguser", "SecurePassword123!")
require.NoError(t, err)
// Try to register again with same email
reqBody := dto.RegisterRequest{
Email: "existing@example.com",
Username: "existinguser2",
Password: "SecurePassword123!",
PasswordConfirm: "SecurePassword123!",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/auth/register", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusConflict, w.Code)
}
func TestRegister_InvalidEmail(t *testing.T) {
router, _, _, _, _, _, cleanup, _ := setupAuthTestRouter(t)
defer cleanup()
// Invalid email format will be caught by validation
reqBody := dto.RegisterRequest{
Email: "invalid-email",
Username: "newuser",
Password: "SecurePassword123!",
PasswordConfirm: "SecurePassword123!",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/auth/register", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Should fail validation before reaching service
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestRegister_WeakPassword(t *testing.T) {
router, _, _, _, _, _, cleanup, _ := setupAuthTestRouter(t)
defer cleanup()
// Weak password will be caught by validation (min=12)
reqBody := dto.RegisterRequest{
Email: "newuser@example.com",
Username: "newuser",
Password: "weak",
PasswordConfirm: "weak",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/auth/register", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Should fail validation before reaching service
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestRegister_InvalidRequest(t *testing.T) {
router, _, _, _, _, _, cleanup, _ := setupAuthTestRouter(t)
defer cleanup()
// Missing required fields
reqBody := map[string]interface{}{
"email": "test@example.com",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/auth/register", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestRefresh_InvalidRequest(t *testing.T) {
router, _, _, _, _, _, cleanup, _ := setupAuthTestRouter(t)
defer cleanup()
// Missing refresh_token
reqBody := map[string]interface{}{}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/auth/refresh", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestLogout_Success(t *testing.T) {
userID := uuid.New()
router, _, _, _, _, _, cleanup, _ := setupAuthTestRouter(t, func(c *gin.Context) {
if c.Request.URL.Path == "/auth/logout" {
c.Set("user_id", userID)
}
c.Next()
})
defer cleanup()
reqBody := map[string]interface{}{
"refresh_token": "refresh_token",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/auth/logout", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer access_token")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestLogout_Unauthorized(t *testing.T) {
router, _, _, _, _, _, cleanup, _ := setupAuthTestRouter(t)
defer cleanup()
reqBody := map[string]interface{}{
"refresh_token": "refresh_token",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/auth/logout", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestVerifyEmail_MissingToken(t *testing.T) {
router, _, _, _, _, _, cleanup, _ := setupAuthTestRouter(t)
defer cleanup()
req := httptest.NewRequest(http.MethodPost, "/auth/verify-email", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestResendVerification_Success(t *testing.T) {
router, authService, _, _, _, _, cleanup, db := setupAuthTestRouter(t)
defer cleanup()
// Create a test user (not verified) - Register creates with is_verified=true by default, so we set false
ctx := context.Background()
user, _, err := authService.Register(ctx, "test@example.com", "testuser", "SecurePassword123!")
require.NoError(t, err)
db.Model(&models.User{}).Where("id = ?", user.ID).Update("is_verified", false)
reqBody := dto.ResendVerificationRequest{
Email: "test@example.com",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/auth/resend-verification", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestCheckUsername_Available(t *testing.T) {
router, _, _, _, _, _, cleanup, _ := setupAuthTestRouter(t)
defer cleanup()
req := httptest.NewRequest(http.MethodGet, "/auth/check-username?username=newusername", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response APIResponse
json.Unmarshal(w.Body.Bytes(), &response)
assert.True(t, response.Success)
var data map[string]interface{}
responseData, _ := json.Marshal(response.Data)
json.Unmarshal(responseData, &data)
assert.True(t, data["available"].(bool))
}
func TestCheckUsername_Taken(t *testing.T) {
router, authService, _, _, _, _, cleanup, _ := setupAuthTestRouter(t)
defer cleanup()
// Create a user with username
ctx := context.Background()
_, _, err := authService.Register(ctx, "test@example.com", "existinguser", "SecurePassword123!")
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/auth/check-username?username=existinguser", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response APIResponse
json.Unmarshal(w.Body.Bytes(), &response)
assert.True(t, response.Success)
var data map[string]interface{}
responseData, _ := json.Marshal(response.Data)
json.Unmarshal(responseData, &data)
assert.False(t, data["available"].(bool))
}
func TestCheckUsername_MissingUsername(t *testing.T) {
router, _, _, _, _, _, cleanup, _ := setupAuthTestRouter(t)
defer cleanup()
req := httptest.NewRequest(http.MethodGet, "/auth/check-username", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestGetMe_Success(t *testing.T) {
var userID uuid.UUID
router, authService, _, _, _, _, cleanup, _ := setupAuthTestRouter(t, func(c *gin.Context) {
if c.Request.URL.Path == "/auth/me" {
c.Set("user_id", userID)
}
c.Next()
})
defer cleanup()
// Create a test user
ctx := context.Background()
user, _, err := authService.Register(ctx, "test@example.com", "testuser", "SecurePassword123!")
require.NoError(t, err)
userID = user.ID
req := httptest.NewRequest(http.MethodGet, "/auth/me", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestGetMe_Unauthorized(t *testing.T) {
router, _, _, _, _, _, cleanup, _ := setupAuthTestRouter(t)
defer cleanup()
req := httptest.NewRequest(http.MethodGet, "/auth/me", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}