veza/veza-backend-api/internal/handlers/auth_handler_test.go
senke 083b5718a7 feat(auth): defer JWT to post-verify + verify-email header (v1.0.9 items 1.3+1.4)
Item 1.4 — Register no longer issues an access+refresh token pair. The
prior flow set httpOnly cookies at register but the AuthMiddleware
refused them on every protected route until the user had verified
their email (`core/auth/service.go:527`). Users ended up with dead
credentials and a "logged in but locked out" UX. Register now returns
{user, verification_required: true, message} and the SPA's existing
"check your email" notice fires naturally.

Item 1.3 — `POST /auth/verify-email` reads the token from the
`X-Verify-Token` header in preference to the `?token=…` query param.
Query param logged a deprecation warning but stays accepted so emails
dispatched before this release still work. Headers don't leak through
proxy/CDN access logs that record URL but not headers.

Tests: 18 test files updated (sed `_, _, err :=` → `_, err :=` for the
new Register signature). `core/auth/handler_test.go` gets a
`registerVerifyLogin` helper for tests that exercise post-login flows
(refresh, logout). Two new E2E `@critical` specs lock in the defer-JWT
contract and the header read-path.

OpenAPI + orval regenerated to reflect the new RegisterResponse shape
and the verify-email header parameter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 22:56:31 +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)
}