veza/veza-backend-api/internal/core/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

263 lines
7.9 KiB
Go

package auth
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"veza-backend-api/internal/dto"
"veza-backend-api/internal/models"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"go.uber.org/zap/zaptest"
"gorm.io/gorm"
)
func setupTestAuthHandler(t *testing.T) (*AuthHandler, *gin.Engine, *TestMocks, *gorm.DB, func()) {
service, db, mocks, cleanupService := setupTestAuthService(t)
gin.SetMode(gin.TestMode)
router := gin.New()
handler := NewAuthHandler(
service,
nil, // sessionService
zaptest.NewLogger(t),
)
return handler, router, mocks, db, func() {
cleanupService()
}
}
func expectRegister(mocks *TestMocks) {
mocks.EmailVerification.On("GenerateToken").Return("verification-token", nil).Maybe()
mocks.EmailVerification.On("StoreToken", mock.Anything, mock.Anything, "verification-token").Return(nil).Maybe()
mocks.Email.On("SendVerificationEmail", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(nil).Maybe()
mocks.JWT.On("GenerateAccessToken", mock.AnythingOfType("*models.User")).Return("access-token", nil).Once()
mocks.JWT.On("GenerateRefreshToken", mock.AnythingOfType("*models.User")).Return("refresh-token", nil).Once()
mocks.RefreshToken.On("Store", mock.Anything, "refresh-token", mock.Anything).Return(nil).Once()
}
func TestAuthHandler_Register_Success(t *testing.T) {
handler, router, mocks, _, cleanup := setupTestAuthHandler(t)
defer cleanup()
router.POST("/register", handler.Register)
reqBody := dto.RegisterRequest{
Email: "handler@example.com",
Username: "handleruser",
Password: "StrongPassword123!",
PasswordConfirm: "StrongPassword123!",
}
body, _ := json.Marshal(reqBody)
expectRegister(mocks)
req, _ := http.NewRequest("POST", "/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 resp dto.RegisterResponse
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, reqBody.Email, resp.User.Email)
assert.Equal(t, reqBody.Username, resp.User.Username)
assert.NotEmpty(t, resp.Token.AccessToken)
}
func TestAuthHandler_Login_Success(t *testing.T) {
handler, router, mocks, db, cleanup := setupTestAuthHandler(t)
defer cleanup()
router.POST("/login", handler.Login)
// Pre-register user directly via service
ctx := context.Background()
expectRegister(mocks)
registeredUser, _, err := handler.authService.Register(ctx, "login_h@example.com", "login_h", "StrongPassword123!")
require.NoError(t, err)
// Simulate the user clicking the verification link — Register now leaves
// is_verified=false and Login refuses unverified users.
require.NoError(t, db.Model(&models.User{}).Where("id = ?", registeredUser.ID).Update("is_verified", true).Error)
reqBody := dto.LoginRequest{
Email: "login_h@example.com",
Password: "StrongPassword123!",
}
body, _ := json.Marshal(reqBody)
req, _ := http.NewRequest("POST", "/login", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
// Login expectations
mocks.JWT.On("GenerateAccessToken", mock.AnythingOfType("*models.User")).Return("new-access-token", nil).Once()
mocks.JWT.On("GenerateRefreshToken", mock.AnythingOfType("*models.User")).Return("new-refresh-token", nil).Once()
mocks.RefreshToken.On("Store", mock.Anything, "new-refresh-token", mock.Anything).Return(nil).Once()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp dto.LoginResponse
err = json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, reqBody.Email, resp.User.Email)
assert.NotEmpty(t, resp.Token.AccessToken)
}
func TestAuthHandler_Login_InvalidCredentials(t *testing.T) {
handler, router, _, _, cleanup := setupTestAuthHandler(t)
defer cleanup()
router.POST("/login", handler.Login)
reqBody := dto.LoginRequest{
Email: "nonexistent@example.com",
Password: "StrongPassword123!",
}
body, _ := json.Marshal(reqBody)
req, _ := http.NewRequest("POST", "/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)
}
func TestAuthHandler_Refresh_Success(t *testing.T) {
handler, _, mocks, _, cleanup := setupTestAuthHandler(t)
defer cleanup()
expectRegister(mocks)
// Register via service
ctx := context.Background()
user, tokenPair, err := handler.authService.Register(ctx, "refresh_h@example.com", "refresh_h", "StrongPassword123!")
require.NoError(t, err)
reqBody := dto.RefreshRequest{
RefreshToken: tokenPair.RefreshToken,
}
body, _ := json.Marshal(reqBody)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request, _ = http.NewRequest("POST", "/refresh", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
// Refresh expectations
claims := &models.CustomClaims{UserID: user.ID, IsRefresh: true}
mocks.JWT.On("ValidateToken", tokenPair.RefreshToken).Return(claims, nil)
mocks.RefreshToken.On("Validate", user.ID, tokenPair.RefreshToken).Return(nil)
mocks.JWT.On("GenerateAccessToken", mock.AnythingOfType("*models.User")).Return("refreshed-access-token", nil)
mocks.JWT.On("GenerateRefreshToken", mock.AnythingOfType("*models.User")).Return("refreshed-refresh-token", nil)
mocks.RefreshToken.On("Rotate", user.ID, tokenPair.RefreshToken, "refreshed-refresh-token", mock.Anything).Return(nil)
handler.Refresh(c)
assert.Equal(t, http.StatusOK, w.Code)
var resp dto.TokenResponse
err = json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.NotEmpty(t, resp.AccessToken)
}
func TestAuthHandler_CheckUsername_Available(t *testing.T) {
handler, _, _, _, cleanup := setupTestAuthHandler(t)
defer cleanup()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request, _ = http.NewRequest("GET", "/check-username?username=newuser_check", nil)
handler.CheckUsername(c)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
val, ok := resp["available"]
assert.True(t, ok)
assert.Equal(t, true, val)
}
func TestAuthHandler_GetMe_Success(t *testing.T) {
handler, _, mocks, _, cleanup := setupTestAuthHandler(t)
defer cleanup()
ctx := context.Background()
expectRegister(mocks)
user, _, err := handler.authService.Register(ctx, "me@example.com", "meuser", "StrongPassword123!")
require.NoError(t, err)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request, _ = http.NewRequest("GET", "/me", nil)
c.Set("user_id", user.ID)
c.Set("email", user.Email)
c.Set("role", user.Role)
handler.GetMe(c)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, user.Email, resp["email"])
}
func TestAuthHandler_Logout_Success(t *testing.T) {
handler, _, mocks, _, cleanup := setupTestAuthHandler(t)
defer cleanup()
ctx := context.Background()
expectRegister(mocks)
user, tokenPair, err := handler.authService.Register(ctx, "logout_h@example.com", "logout_h", "StrongPassword123!")
require.NoError(t, err)
reqBody := struct {
RefreshToken string `json:"refresh_token"`
}{
RefreshToken: tokenPair.RefreshToken,
}
body, _ := json.Marshal(reqBody)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request, _ = http.NewRequest("POST", "/logout", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Set("user_id", user.ID)
// Logout expectations
claims := &models.CustomClaims{UserID: user.ID}
mocks.JWT.On("ValidateToken", tokenPair.RefreshToken).Return(claims, nil)
mocks.RefreshToken.On("Revoke", user.ID, tokenPair.RefreshToken).Return(nil)
handler.Logout(c)
assert.Equal(t, http.StatusOK, w.Code)
}