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>
286 lines
9.3 KiB
Go
286 lines
9.3 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()
|
|
}
|
|
}
|
|
|
|
// expectRegister wires the mocks Register touches. v1.0.9 item 1.4 removed
|
|
// JWT issuance from Register, so the JWT/RefreshToken expectations live on
|
|
// the Login or Refresh setup of the calling test instead.
|
|
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()
|
|
}
|
|
|
|
// registerVerifyLogin is the canonical "I need a logged-in user with a
|
|
// real refresh token" recipe for tests that exercise post-login flows
|
|
// (refresh, logout). It replaces the v1.0.6-era pattern where Register
|
|
// returned a tokenPair directly.
|
|
func registerVerifyLogin(t *testing.T, service *AuthService, db *gorm.DB, mocks *TestMocks, email, username, password string) (*models.User, *models.TokenPair) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
user, err := service.Register(ctx, email, username, password)
|
|
require.NoError(t, err)
|
|
require.NoError(t, db.Model(&models.User{}).Where("id = ?", user.ID).Update("is_verified", true).Error)
|
|
|
|
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()
|
|
|
|
loggedInUser, tokens, err := service.Login(ctx, email, password, false)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, tokens)
|
|
return loggedInUser, tokens
|
|
}
|
|
|
|
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)
|
|
// v1.0.9 item 1.4 — Register no longer issues tokens; the response
|
|
// signals the frontend to route the user to "check your email".
|
|
assert.True(t, resp.VerificationRequired)
|
|
assert.NotEmpty(t, resp.Message)
|
|
}
|
|
|
|
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 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, db, cleanup := setupTestAuthHandler(t)
|
|
defer cleanup()
|
|
|
|
expectRegister(mocks)
|
|
|
|
// v1.0.9 item 1.4 — Register no longer issues tokens. We must verify
|
|
// the user and call Login to obtain a refresh token to test refresh.
|
|
user, tokenPair := registerVerifyLogin(t, handler.authService, db, mocks, "refresh_h@example.com", "refresh_h", "StrongPassword123!")
|
|
|
|
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, db, cleanup := setupTestAuthHandler(t)
|
|
defer cleanup()
|
|
|
|
expectRegister(mocks)
|
|
|
|
// v1.0.9 item 1.4 — Register no longer issues tokens; we acquire a
|
|
// refresh token via the post-verification Login path.
|
|
user, tokenPair := registerVerifyLogin(t, handler.authService, db, mocks, "logout_h@example.com", "logout_h", "StrongPassword123!")
|
|
|
|
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)
|
|
}
|