1566 lines
49 KiB
Go
1566 lines
49 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"goyco/internal/config"
|
|
"goyco/internal/database"
|
|
"goyco/internal/middleware"
|
|
"goyco/internal/repositories"
|
|
"goyco/internal/services"
|
|
"goyco/internal/testutils"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func newAuthHandler(repo repositories.UserRepository) *AuthHandler {
|
|
return newAuthHandlerWithSender(repo, &testutils.EmailSenderStub{})
|
|
}
|
|
|
|
func newAuthHandlerWithSender(repo repositories.UserRepository, sender services.EmailSender) *AuthHandler {
|
|
cfg := &config.Config{
|
|
JWT: config.JWTConfig{Secret: "secret", Expiration: 1},
|
|
App: config.AppConfig{BaseURL: "https://test.example.com"},
|
|
}
|
|
|
|
mockRefreshRepo := &mockRefreshTokenRepository{}
|
|
authService, err := services.NewAuthFacadeForTest(cfg, repo, nil, nil, mockRefreshRepo, sender)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("Failed to create auth service: %v", err))
|
|
}
|
|
return NewAuthHandler(authService, repo)
|
|
}
|
|
|
|
type mockAuthService struct {
|
|
loginFunc func(string, string) (*services.AuthResult, error)
|
|
registerFunc func(string, string, string) (*services.RegistrationResult, error)
|
|
confirmEmailFunc func(string) (*database.User, error)
|
|
resendVerificationFunc func(string) error
|
|
requestPasswordResetFunc func(string) error
|
|
resetPasswordFunc func(string, string) error
|
|
updateEmailFunc func(uint, string) (*database.User, error)
|
|
updateUsernameFunc func(uint, string) (*database.User, error)
|
|
updatePasswordFunc func(uint, string, string) (*database.User, error)
|
|
deleteAccountFunc func(uint) error
|
|
confirmAccountDeletionWithPostsFunc func(string, bool) error
|
|
refreshAccessTokenFunc func(string) (*services.AuthResult, error)
|
|
revokeRefreshTokenFunc func(string) error
|
|
revokeAllUserTokensFunc func(uint) error
|
|
invalidateAllSessionsFunc func(uint) error
|
|
getAdminEmailFunc func() string
|
|
verifyTokenFunc func(string) (uint, error)
|
|
getUserIDFromDeletionTokenFunc func(string) (uint, error)
|
|
userHasPostsFunc func(uint) (bool, int64, error)
|
|
}
|
|
|
|
func (m *mockAuthService) Login(username, password string) (*services.AuthResult, error) {
|
|
if m.loginFunc != nil {
|
|
return m.loginFunc(username, password)
|
|
}
|
|
return &services.AuthResult{
|
|
User: &database.User{ID: 1, Username: username},
|
|
AccessToken: "access_token",
|
|
RefreshToken: "refresh_token",
|
|
}, nil
|
|
}
|
|
|
|
func (m *mockAuthService) Register(username, email, password string) (*services.RegistrationResult, error) {
|
|
if m.registerFunc != nil {
|
|
return m.registerFunc(username, email, password)
|
|
}
|
|
return &services.RegistrationResult{
|
|
User: &database.User{ID: 1, Username: username, Email: email},
|
|
VerificationSent: true,
|
|
}, nil
|
|
}
|
|
|
|
func (m *mockAuthService) ConfirmEmail(token string) (*database.User, error) {
|
|
if m.confirmEmailFunc != nil {
|
|
return m.confirmEmailFunc(token)
|
|
}
|
|
return &database.User{ID: 1}, nil
|
|
}
|
|
|
|
func (m *mockAuthService) ResendVerificationEmail(email string) error {
|
|
if m.resendVerificationFunc != nil {
|
|
return m.resendVerificationFunc(email)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockAuthService) RequestPasswordReset(usernameOrEmail string) error {
|
|
if m.requestPasswordResetFunc != nil {
|
|
return m.requestPasswordResetFunc(usernameOrEmail)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockAuthService) ResetPassword(token, newPassword string) error {
|
|
if m.resetPasswordFunc != nil {
|
|
return m.resetPasswordFunc(token, newPassword)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockAuthService) UpdateEmail(userID uint, email string) (*database.User, error) {
|
|
if m.updateEmailFunc != nil {
|
|
return m.updateEmailFunc(userID, email)
|
|
}
|
|
return &database.User{ID: userID, Email: email}, nil
|
|
}
|
|
|
|
func (m *mockAuthService) UpdateUsername(userID uint, username string) (*database.User, error) {
|
|
if m.updateUsernameFunc != nil {
|
|
return m.updateUsernameFunc(userID, username)
|
|
}
|
|
return &database.User{ID: userID, Username: username}, nil
|
|
}
|
|
|
|
func (m *mockAuthService) UpdatePassword(userID uint, currentPassword, newPassword string) (*database.User, error) {
|
|
if m.updatePasswordFunc != nil {
|
|
return m.updatePasswordFunc(userID, currentPassword, newPassword)
|
|
}
|
|
return &database.User{ID: userID}, nil
|
|
}
|
|
|
|
func (m *mockAuthService) RequestAccountDeletion(userID uint) error {
|
|
if m.deleteAccountFunc != nil {
|
|
return m.deleteAccountFunc(userID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockAuthService) ConfirmAccountDeletionWithPosts(token string, deletePosts bool) error {
|
|
if m.confirmAccountDeletionWithPostsFunc != nil {
|
|
return m.confirmAccountDeletionWithPostsFunc(token, deletePosts)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockAuthService) RefreshAccessToken(refreshToken string) (*services.AuthResult, error) {
|
|
if m.refreshAccessTokenFunc != nil {
|
|
return m.refreshAccessTokenFunc(refreshToken)
|
|
}
|
|
return &services.AuthResult{
|
|
User: &database.User{ID: 1, Username: "testuser"},
|
|
AccessToken: "new_access_token",
|
|
RefreshToken: refreshToken,
|
|
}, nil
|
|
}
|
|
|
|
func (m *mockAuthService) RevokeRefreshToken(refreshToken string) error {
|
|
if m.revokeRefreshTokenFunc != nil {
|
|
return m.revokeRefreshTokenFunc(refreshToken)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockAuthService) RevokeAllUserTokens(userID uint) error {
|
|
if m.revokeAllUserTokensFunc != nil {
|
|
return m.revokeAllUserTokensFunc(userID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockAuthService) InvalidateAllSessions(userID uint) error {
|
|
if m.invalidateAllSessionsFunc != nil {
|
|
return m.invalidateAllSessionsFunc(userID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockAuthService) GetAdminEmail() string {
|
|
if m.getAdminEmailFunc != nil {
|
|
return m.getAdminEmailFunc()
|
|
}
|
|
return "admin@example.com"
|
|
}
|
|
|
|
func (m *mockAuthService) VerifyToken(tokenString string) (uint, error) {
|
|
if m.verifyTokenFunc != nil {
|
|
return m.verifyTokenFunc(tokenString)
|
|
}
|
|
return 1, nil
|
|
}
|
|
|
|
func (m *mockAuthService) GetUserIDFromDeletionToken(token string) (uint, error) {
|
|
if m.getUserIDFromDeletionTokenFunc != nil {
|
|
return m.getUserIDFromDeletionTokenFunc(token)
|
|
}
|
|
return 1, nil
|
|
}
|
|
|
|
func (m *mockAuthService) UserHasPosts(userID uint) (bool, int64, error) {
|
|
if m.userHasPostsFunc != nil {
|
|
return m.userHasPostsFunc(userID)
|
|
}
|
|
return false, 0, nil
|
|
}
|
|
|
|
type mockRefreshTokenRepository struct{}
|
|
|
|
func (m *mockRefreshTokenRepository) Create(token *database.RefreshToken) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockRefreshTokenRepository) GetByTokenHash(tokenHash string) (*database.RefreshToken, error) {
|
|
return nil, gorm.ErrRecordNotFound
|
|
}
|
|
|
|
func (m *mockRefreshTokenRepository) DeleteByUserID(userID uint) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockRefreshTokenRepository) DeleteExpired() error {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockRefreshTokenRepository) DeleteByID(id uint) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockRefreshTokenRepository) GetByUserID(userID uint) ([]database.RefreshToken, error) {
|
|
return []database.RefreshToken{}, nil
|
|
}
|
|
|
|
func (m *mockRefreshTokenRepository) CountByUserID(userID uint) (int64, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
func newMockAuthHandler(repo repositories.UserRepository, mockService *mockAuthService) *AuthHandler {
|
|
return &AuthHandler{
|
|
authService: mockService,
|
|
userRepo: repo,
|
|
}
|
|
}
|
|
|
|
func TestAuthHandlerLoginSuccess(t *testing.T) {
|
|
hashed, _ := bcrypt.GenerateFromPassword([]byte("Password123!"), bcrypt.DefaultCost)
|
|
repo := &testutils.UserRepositoryStub{
|
|
GetByUsernameFn: func(username string) (*database.User, error) {
|
|
return &database.User{ID: 1, Username: username, Password: string(hashed), EmailVerified: true}, nil
|
|
},
|
|
}
|
|
handler := newAuthHandler(repo)
|
|
|
|
body := bytes.NewBufferString(`{"username":"user","password":"Password123!"}`)
|
|
request := httptest.NewRequest(http.MethodPost, "/api/auth/login", body)
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler.Login(recorder, request)
|
|
|
|
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
|
|
|
|
var resp AuthResponse
|
|
if err := json.NewDecoder(recorder.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode error: %v", err)
|
|
}
|
|
|
|
if !resp.Success || resp.Data == nil {
|
|
t.Fatalf("expected success response, got %+v", resp)
|
|
}
|
|
}
|
|
|
|
func TestAuthHandlerLoginErrors(t *testing.T) {
|
|
handler := newAuthHandler(&testutils.UserRepositoryStub{})
|
|
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBufferString("invalid"))
|
|
handler.Login(recorder, request)
|
|
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
|
|
|
|
recorder = httptest.NewRecorder()
|
|
request = httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBufferString(`{"username":" ","password":""}`))
|
|
handler.Login(recorder, request)
|
|
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
|
|
|
|
recorder = httptest.NewRecorder()
|
|
request = httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBufferString(`{"username":"user","password":"WrongPass123!"}`))
|
|
handler.Login(recorder, request)
|
|
testutils.AssertHTTPStatus(t, recorder, http.StatusUnauthorized)
|
|
|
|
hashed, _ := bcrypt.GenerateFromPassword([]byte("Password123!"), bcrypt.DefaultCost)
|
|
repo := &testutils.UserRepositoryStub{GetByUsernameFn: func(string) (*database.User, error) {
|
|
return &database.User{ID: 1, Username: "user", Password: string(hashed), EmailVerified: false}, nil
|
|
}}
|
|
handler = newAuthHandler(repo)
|
|
recorder = httptest.NewRecorder()
|
|
request = httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBufferString(`{"username":"user","password":"Password123!"}`))
|
|
handler.Login(recorder, request)
|
|
testutils.AssertHTTPStatus(t, recorder, http.StatusForbidden)
|
|
|
|
repo = &testutils.UserRepositoryStub{GetByUsernameFn: func(string) (*database.User, error) {
|
|
return nil, errors.New("database offline")
|
|
}}
|
|
handler = newAuthHandler(repo)
|
|
|
|
recorder = httptest.NewRecorder()
|
|
request = httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBufferString(`{"username":"user","password":"Password123!"}`))
|
|
handler.Login(recorder, request)
|
|
testutils.AssertHTTPStatus(t, recorder, http.StatusInternalServerError)
|
|
|
|
if !strings.Contains(recorder.Body.String(), "Authentication failed") {
|
|
t.Fatalf("expected response to include generic error message, got %q", recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAuthHandlerRegisterSuccess(t *testing.T) {
|
|
repo := &testutils.UserRepositoryStub{
|
|
GetByUsernameFn: func(string) (*database.User, error) {
|
|
return nil, gorm.ErrRecordNotFound
|
|
},
|
|
CreateFn: func(user *database.User) error {
|
|
user.ID = 1
|
|
return nil
|
|
},
|
|
}
|
|
|
|
sent := false
|
|
handler := newAuthHandlerWithSender(repo, &testutils.EmailSenderStub{SendFn: func(to, subject, body string) error {
|
|
sent = true
|
|
return nil
|
|
}})
|
|
|
|
body := bytes.NewBufferString(`{"username":"newuser","email":"new@example.com","password":"Password123!"}`)
|
|
request := httptest.NewRequest(http.MethodPost, "/api/auth/register", body)
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler.Register(recorder, request)
|
|
|
|
testutils.AssertHTTPStatus(t, recorder, http.StatusCreated)
|
|
|
|
var resp AuthResponse
|
|
_ = json.NewDecoder(recorder.Body).Decode(&resp)
|
|
if !resp.Success {
|
|
t.Fatalf("expected success response, got %v", resp)
|
|
}
|
|
|
|
if !sent {
|
|
t.Fatalf("expected verification email to be sent")
|
|
}
|
|
}
|
|
|
|
func TestAuthHandlerRegisterErrors(t *testing.T) {
|
|
repo := &testutils.UserRepositoryStub{}
|
|
handler := newAuthHandler(repo)
|
|
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewBufferString("invalid"))
|
|
handler.Register(recorder, request)
|
|
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
|
|
|
|
recorder = httptest.NewRecorder()
|
|
request = httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewBufferString(`{"username":"","email":"","password":""}`))
|
|
handler.Register(recorder, request)
|
|
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
|
|
|
|
repo = &testutils.UserRepositoryStub{GetByUsernameFn: func(string) (*database.User, error) {
|
|
return &database.User{ID: 1}, nil
|
|
}}
|
|
handler = newAuthHandler(repo)
|
|
recorder = httptest.NewRecorder()
|
|
request = httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewBufferString(`{"username":"new","email":"taken@example.com","password":"Password123!"}`))
|
|
handler.Register(recorder, request)
|
|
testutils.AssertHTTPStatus(t, recorder, http.StatusConflict)
|
|
|
|
repo = &testutils.UserRepositoryStub{
|
|
GetByUsernameFn: func(string) (*database.User, error) {
|
|
return nil, gorm.ErrRecordNotFound
|
|
},
|
|
GetByEmailFn: func(string) (*database.User, error) {
|
|
return &database.User{ID: 2}, nil
|
|
},
|
|
}
|
|
handler = newAuthHandler(repo)
|
|
recorder = httptest.NewRecorder()
|
|
request = httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewBufferString(`{"username":"another","email":"taken@example.com","password":"Password123!"}`))
|
|
handler.Register(recorder, request)
|
|
testutils.AssertHTTPStatus(t, recorder, http.StatusConflict)
|
|
}
|
|
|
|
func TestAuthHandlerMe(t *testing.T) {
|
|
repo := &testutils.UserRepositoryStub{
|
|
GetByIDFn: func(id uint) (*database.User, error) {
|
|
return &database.User{
|
|
ID: id,
|
|
Username: "user",
|
|
Email: "user@example.com",
|
|
Password: "secret",
|
|
EmailVerified: true,
|
|
}, nil
|
|
},
|
|
}
|
|
handler := newAuthHandler(repo)
|
|
|
|
request := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
|
recorder := httptest.NewRecorder()
|
|
handler.Me(recorder, request)
|
|
testutils.AssertHTTPStatus(t, recorder, http.StatusUnauthorized)
|
|
|
|
request = httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
|
ctx := context.WithValue(request.Context(), middleware.UserIDKey, uint(7))
|
|
request = request.WithContext(ctx)
|
|
recorder = httptest.NewRecorder()
|
|
|
|
handler.Me(recorder, request)
|
|
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
|
|
|
|
var resp AuthResponse
|
|
if err := json.NewDecoder(recorder.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
user, ok := resp.Data.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("Expected user to be map[string]any, got %T", resp.Data)
|
|
}
|
|
|
|
if _, ok := user["password"]; ok {
|
|
t.Fatalf("expected password field to be omitted, got %+v", user)
|
|
}
|
|
}
|
|
|
|
func TestAuthHandlerConfirmEmail(t *testing.T) {
|
|
repo := &testutils.UserRepositoryStub{}
|
|
handler := newAuthHandler(repo)
|
|
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(http.MethodGet, "/api/auth/confirm", nil)
|
|
handler.ConfirmEmail(recorder, request)
|
|
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
|
|
|
|
verified := false
|
|
repo = &testutils.UserRepositoryStub{
|
|
GetByVerificationFn: func(token string) (*database.User, error) {
|
|
if token == "" {
|
|
t.Fatalf("expected hashed token to be provided")
|
|
}
|
|
return &database.User{ID: 3, Username: "user", EmailVerified: false}, nil
|
|
},
|
|
UpdateFn: func(u *database.User) error {
|
|
verified = u.EmailVerified
|
|
return nil
|
|
},
|
|
}
|
|
handler = newAuthHandler(repo)
|
|
|
|
recorder = httptest.NewRecorder()
|
|
request = httptest.NewRequest(http.MethodGet, "/api/auth/confirm?token=abc123", nil)
|
|
handler.ConfirmEmail(recorder, request)
|
|
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
|
|
|
|
if !verified {
|
|
t.Fatalf("expected update to mark user as verified")
|
|
}
|
|
}
|
|
|
|
func TestAuthHandlerRequestPasswordReset(t *testing.T) {
|
|
repo := &testutils.UserRepositoryStub{
|
|
GetByEmailFn: func(email string) (*database.User, error) {
|
|
if email == "user@example.com" {
|
|
return &database.User{ID: 1, Username: "user", Email: "user@example.com"}, nil
|
|
}
|
|
return nil, gorm.ErrRecordNotFound
|
|
},
|
|
}
|
|
handler := newAuthHandlerWithSender(repo, &testutils.EmailSenderStub{SendFn: func(to, subject, body string) error {
|
|
return nil
|
|
}})
|
|
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password", bytes.NewBufferString(`{"username_or_email":"user@example.com"}`))
|
|
handler.RequestPasswordReset(recorder, request)
|
|
|
|
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
|
|
|
|
repo = &testutils.UserRepositoryStub{
|
|
GetByUsernameFn: func(username string) (*database.User, error) {
|
|
if username == "user" {
|
|
return &database.User{ID: 1, Username: "user", Email: "user@example.com"}, nil
|
|
}
|
|
return nil, gorm.ErrRecordNotFound
|
|
},
|
|
}
|
|
handler = newAuthHandlerWithSender(repo, &testutils.EmailSenderStub{SendFn: func(to, subject, body string) error {
|
|
return nil
|
|
}})
|
|
|
|
recorder = httptest.NewRecorder()
|
|
request = httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password", bytes.NewBufferString(`{"username_or_email":"user"}`))
|
|
handler.RequestPasswordReset(recorder, request)
|
|
|
|
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
|
|
|
|
recorder = httptest.NewRecorder()
|
|
request = httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password", bytes.NewBufferString(`{"username_or_email":""}`))
|
|
handler.RequestPasswordReset(recorder, request)
|
|
|
|
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
|
|
|
|
recorder = httptest.NewRecorder()
|
|
request = httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password", bytes.NewBufferString(`invalid json`))
|
|
handler.RequestPasswordReset(recorder, request)
|
|
|
|
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
|
|
}
|
|
|
|
func TestAuthHandlerResetPassword(t *testing.T) {
|
|
repo := &testutils.UserRepositoryStub{}
|
|
handler := newAuthHandler(repo)
|
|
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(http.MethodPost, "/api/auth/reset-password", bytes.NewBufferString(`{"new_password":"NewPassword123!"}`))
|
|
handler.ResetPassword(recorder, request)
|
|
|
|
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
|
|
|
|
recorder = httptest.NewRecorder()
|
|
request = httptest.NewRequest(http.MethodPost, "/api/auth/reset-password", bytes.NewBufferString(`{"token":"valid_token"}`))
|
|
handler.ResetPassword(recorder, request)
|
|
|
|
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
|
|
|
|
recorder = httptest.NewRecorder()
|
|
request = httptest.NewRequest(http.MethodPost, "/api/auth/reset-password", bytes.NewBufferString(`{"token":"valid_token","new_password":"short"}`))
|
|
handler.ResetPassword(recorder, request)
|
|
|
|
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
|
|
|
|
recorder = httptest.NewRecorder()
|
|
request = httptest.NewRequest(http.MethodPost, "/api/auth/reset-password", bytes.NewBufferString(`invalid json`))
|
|
handler.ResetPassword(recorder, request)
|
|
|
|
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
|
|
}
|
|
|
|
func TestAuthHandlerResetPasswordServiceOutcomes(t *testing.T) {
|
|
repo := &testutils.UserRepositoryStub{}
|
|
tests := []struct {
|
|
name string
|
|
setup func(*mockAuthService)
|
|
expectedStatus int
|
|
expectedError string
|
|
expectedMsg string
|
|
}{
|
|
{
|
|
name: "expired token",
|
|
setup: func(ms *mockAuthService) {
|
|
ms.resetPasswordFunc = func(token, newPassword string) error {
|
|
return fmt.Errorf("token expired")
|
|
}
|
|
},
|
|
expectedStatus: http.StatusBadRequest,
|
|
expectedError: "The reset link has expired",
|
|
},
|
|
{
|
|
name: "invalid token",
|
|
setup: func(ms *mockAuthService) {
|
|
ms.resetPasswordFunc = func(token, newPassword string) error {
|
|
return fmt.Errorf("token invalid")
|
|
}
|
|
},
|
|
expectedStatus: http.StatusBadRequest,
|
|
expectedError: "The reset link is invalid",
|
|
},
|
|
{
|
|
name: "unexpected error",
|
|
setup: func(ms *mockAuthService) {
|
|
ms.resetPasswordFunc = func(token, newPassword string) error {
|
|
return fmt.Errorf("smtp outage")
|
|
}
|
|
},
|
|
expectedStatus: http.StatusInternalServerError,
|
|
expectedError: "Unable to reset password",
|
|
},
|
|
{
|
|
name: "success",
|
|
setup: func(ms *mockAuthService) {
|
|
ms.resetPasswordFunc = func(token, newPassword string) error {
|
|
return nil
|
|
}
|
|
},
|
|
expectedStatus: http.StatusOK,
|
|
expectedMsg: "Password reset successfully",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
mockService := &mockAuthService{}
|
|
if tt.setup != nil {
|
|
tt.setup(mockService)
|
|
}
|
|
|
|
handler := newMockAuthHandler(repo, mockService)
|
|
|
|
request := httptest.NewRequest(http.MethodPost, "/api/auth/reset-password", bytes.NewBufferString(`{"token":"abc","new_password":"Password123!"}`))
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler.ResetPassword(recorder, request)
|
|
|
|
testutils.AssertHTTPStatus(t, recorder, tt.expectedStatus)
|
|
|
|
var resp AuthResponse
|
|
_ = json.NewDecoder(recorder.Body).Decode(&resp)
|
|
|
|
if tt.expectedError != "" {
|
|
if !strings.Contains(resp.Error, tt.expectedError) {
|
|
t.Fatalf("expected error to contain %q, got %q", tt.expectedError, resp.Error)
|
|
}
|
|
return
|
|
}
|
|
|
|
if resp.Message == "" || !strings.Contains(resp.Message, tt.expectedMsg) {
|
|
t.Fatalf("expected success message containing %q, got %q", tt.expectedMsg, resp.Message)
|
|
}
|
|
if !resp.Success {
|
|
t.Fatalf("expected success response")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAuthHandlerUpdateEmail(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
requestBody string
|
|
userID uint
|
|
mockSetup func(*testutils.UserRepositoryStub)
|
|
expectedStatus int
|
|
expectedError string
|
|
}{
|
|
{
|
|
name: "valid email update",
|
|
requestBody: `{"email": "new@example.com"}`,
|
|
userID: 1,
|
|
mockSetup: func(repo *testutils.UserRepositoryStub) {
|
|
repo.GetByIDFn = func(id uint) (*database.User, error) {
|
|
return &database.User{ID: id, Email: "old@example.com"}, nil
|
|
}
|
|
repo.UpdateFn = func(user *database.User) error { return nil }
|
|
},
|
|
expectedStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "missing user context",
|
|
requestBody: `{"email": "new@example.com"}`,
|
|
userID: 0,
|
|
mockSetup: func(repo *testutils.UserRepositoryStub) {},
|
|
expectedStatus: http.StatusUnauthorized,
|
|
expectedError: "Authentication required",
|
|
},
|
|
{
|
|
name: "invalid JSON",
|
|
requestBody: `invalid json`,
|
|
userID: 1,
|
|
mockSetup: func(repo *testutils.UserRepositoryStub) {},
|
|
expectedStatus: http.StatusBadRequest,
|
|
expectedError: "Invalid request body",
|
|
},
|
|
{
|
|
name: "empty email",
|
|
requestBody: `{"email": ""}`,
|
|
userID: 1,
|
|
mockSetup: func(repo *testutils.UserRepositoryStub) {},
|
|
expectedStatus: http.StatusBadRequest,
|
|
expectedError: "Email is required",
|
|
},
|
|
{
|
|
name: "email already taken",
|
|
requestBody: `{"email": "taken@example.com"}`,
|
|
userID: 1,
|
|
mockSetup: func(repo *testutils.UserRepositoryStub) {
|
|
repo.GetByIDFn = func(id uint) (*database.User, error) {
|
|
return &database.User{ID: id, Email: "old@example.com"}, nil
|
|
}
|
|
},
|
|
expectedStatus: http.StatusConflict,
|
|
expectedError: "That email is already in use",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
repo := &testutils.UserRepositoryStub{}
|
|
tt.mockSetup(repo)
|
|
|
|
mockService := &mockAuthService{}
|
|
if tt.name == "email already taken" {
|
|
mockService.updateEmailFunc = func(userID uint, email string) (*database.User, error) {
|
|
return nil, services.ErrEmailTaken
|
|
}
|
|
}
|
|
|
|
handler := newMockAuthHandler(repo, mockService)
|
|
|
|
request := httptest.NewRequest(http.MethodPut, "/api/auth/email", bytes.NewBufferString(tt.requestBody))
|
|
if tt.userID > 0 {
|
|
ctx := context.WithValue(request.Context(), middleware.UserIDKey, tt.userID)
|
|
request = request.WithContext(ctx)
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler.UpdateEmail(recorder, request)
|
|
|
|
testutils.AssertHTTPStatus(t, recorder, tt.expectedStatus)
|
|
|
|
if tt.expectedError != "" {
|
|
var resp AuthResponse
|
|
json.NewDecoder(recorder.Body).Decode(&resp)
|
|
if !strings.Contains(resp.Error, tt.expectedError) {
|
|
t.Fatalf("expected error to contain %q, got %q", tt.expectedError, resp.Error)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAuthHandlerUpdateUsername(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
requestBody string
|
|
userID uint
|
|
mockSetup func(*testutils.UserRepositoryStub)
|
|
expectedStatus int
|
|
expectedError string
|
|
}{
|
|
{
|
|
name: "valid username update",
|
|
requestBody: `{"username": "newusername"}`,
|
|
userID: 1,
|
|
mockSetup: func(repo *testutils.UserRepositoryStub) {
|
|
repo.GetByIDFn = func(id uint) (*database.User, error) {
|
|
return &database.User{ID: id, Username: "oldusername"}, nil
|
|
}
|
|
repo.UpdateFn = func(user *database.User) error { return nil }
|
|
},
|
|
expectedStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "missing user context",
|
|
requestBody: `{"username": "newusername"}`,
|
|
userID: 0,
|
|
mockSetup: func(repo *testutils.UserRepositoryStub) {},
|
|
expectedStatus: http.StatusUnauthorized,
|
|
expectedError: "Authentication required",
|
|
},
|
|
{
|
|
name: "empty username",
|
|
requestBody: `{"username": ""}`,
|
|
userID: 1,
|
|
mockSetup: func(repo *testutils.UserRepositoryStub) {},
|
|
expectedStatus: http.StatusBadRequest,
|
|
expectedError: "Username is required",
|
|
},
|
|
{
|
|
name: "username already taken",
|
|
requestBody: `{"username": "takenuser"}`,
|
|
userID: 1,
|
|
mockSetup: func(repo *testutils.UserRepositoryStub) {
|
|
repo.GetByIDFn = func(id uint) (*database.User, error) {
|
|
return &database.User{ID: id, Username: "oldusername"}, nil
|
|
}
|
|
},
|
|
expectedStatus: http.StatusConflict,
|
|
expectedError: "That username is already taken",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
repo := &testutils.UserRepositoryStub{}
|
|
tt.mockSetup(repo)
|
|
|
|
mockService := &mockAuthService{}
|
|
if tt.name == "username already taken" {
|
|
mockService.updateUsernameFunc = func(userID uint, username string) (*database.User, error) {
|
|
return nil, services.ErrUsernameTaken
|
|
}
|
|
}
|
|
|
|
handler := newMockAuthHandler(repo, mockService)
|
|
|
|
request := httptest.NewRequest(http.MethodPut, "/api/auth/username", bytes.NewBufferString(tt.requestBody))
|
|
if tt.userID > 0 {
|
|
ctx := context.WithValue(request.Context(), middleware.UserIDKey, tt.userID)
|
|
request = request.WithContext(ctx)
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler.UpdateUsername(recorder, request)
|
|
|
|
testutils.AssertHTTPStatus(t, recorder, tt.expectedStatus)
|
|
|
|
if tt.expectedError != "" {
|
|
var resp AuthResponse
|
|
json.NewDecoder(recorder.Body).Decode(&resp)
|
|
if !strings.Contains(resp.Error, tt.expectedError) {
|
|
t.Fatalf("expected error to contain %q, got %q", tt.expectedError, resp.Error)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAuthHandlerUpdatePassword(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
requestBody string
|
|
userID uint
|
|
mockSetup func(*testutils.UserRepositoryStub)
|
|
expectedStatus int
|
|
expectedError string
|
|
}{
|
|
{
|
|
name: "valid password update",
|
|
requestBody: `{"current_password": "OldPass123!", "new_password": "NewPassword123!"}`,
|
|
userID: 1,
|
|
mockSetup: func(repo *testutils.UserRepositoryStub) {
|
|
repo.GetByIDFn = func(id uint) (*database.User, error) {
|
|
hashed, _ := bcrypt.GenerateFromPassword([]byte("OldPass123!"), bcrypt.DefaultCost)
|
|
return &database.User{ID: id, Password: string(hashed)}, nil
|
|
}
|
|
repo.UpdateFn = func(user *database.User) error { return nil }
|
|
},
|
|
expectedStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "missing user context",
|
|
requestBody: `{"current_password": "OldPass123!", "new_password": "NewPassword123!"}`,
|
|
userID: 0,
|
|
mockSetup: func(repo *testutils.UserRepositoryStub) {},
|
|
expectedStatus: http.StatusUnauthorized,
|
|
expectedError: "Authentication required",
|
|
},
|
|
{
|
|
name: "empty current password",
|
|
requestBody: `{"current_password": "", "new_password": "NewPassword123!"}`,
|
|
userID: 1,
|
|
mockSetup: func(repo *testutils.UserRepositoryStub) {},
|
|
expectedStatus: http.StatusBadRequest,
|
|
expectedError: "Current password is required",
|
|
},
|
|
{
|
|
name: "empty new password",
|
|
requestBody: `{"current_password": "OldPass123!", "new_password": ""}`,
|
|
userID: 1,
|
|
mockSetup: func(repo *testutils.UserRepositoryStub) {},
|
|
expectedStatus: http.StatusBadRequest,
|
|
expectedError: "Password is required",
|
|
},
|
|
{
|
|
name: "short new password",
|
|
requestBody: `{"current_password": "OldPass123!", "new_password": "short"}`,
|
|
userID: 1,
|
|
mockSetup: func(repo *testutils.UserRepositoryStub) {},
|
|
expectedStatus: http.StatusBadRequest,
|
|
expectedError: "Password must be at least 8 characters long",
|
|
},
|
|
{
|
|
name: "incorrect current password",
|
|
requestBody: `{"current_password": "WrongPass123!", "new_password": "NewPassword123!"}`,
|
|
userID: 1,
|
|
mockSetup: func(repo *testutils.UserRepositoryStub) {
|
|
repo.GetByIDFn = func(id uint) (*database.User, error) {
|
|
hashed, _ := bcrypt.GenerateFromPassword([]byte("CorrectPass123!"), bcrypt.DefaultCost)
|
|
return &database.User{ID: id, Password: string(hashed)}, nil
|
|
}
|
|
},
|
|
expectedStatus: http.StatusBadRequest,
|
|
expectedError: "Current password is incorrect",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
repo := &testutils.UserRepositoryStub{}
|
|
tt.mockSetup(repo)
|
|
handler := newAuthHandler(repo)
|
|
|
|
request := httptest.NewRequest(http.MethodPut, "/api/auth/password", bytes.NewBufferString(tt.requestBody))
|
|
if tt.userID > 0 {
|
|
ctx := context.WithValue(request.Context(), middleware.UserIDKey, tt.userID)
|
|
request = request.WithContext(ctx)
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler.UpdatePassword(recorder, request)
|
|
|
|
testutils.AssertHTTPStatus(t, recorder, tt.expectedStatus)
|
|
|
|
if tt.expectedError != "" {
|
|
var resp AuthResponse
|
|
json.NewDecoder(recorder.Body).Decode(&resp)
|
|
if !strings.Contains(resp.Error, tt.expectedError) {
|
|
t.Fatalf("expected error to contain %q, got %q", tt.expectedError, resp.Error)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAuthHandlerDeleteAccount(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
userID uint
|
|
mockSetup func(*testutils.UserRepositoryStub)
|
|
expectedStatus int
|
|
expectedError string
|
|
}{
|
|
{
|
|
name: "valid account deletion request",
|
|
userID: 1,
|
|
mockSetup: func(repo *testutils.UserRepositoryStub) {
|
|
repo.GetByIDFn = func(id uint) (*database.User, error) {
|
|
return &database.User{ID: id, Username: "user", Email: "user@example.com"}, nil
|
|
}
|
|
},
|
|
expectedStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "missing user context",
|
|
userID: 0,
|
|
mockSetup: func(repo *testutils.UserRepositoryStub) {},
|
|
expectedStatus: http.StatusUnauthorized,
|
|
expectedError: "Authentication required",
|
|
},
|
|
{
|
|
name: "email service unavailable",
|
|
userID: 1,
|
|
mockSetup: func(repo *testutils.UserRepositoryStub) {
|
|
repo.GetByIDFn = func(id uint) (*database.User, error) {
|
|
return &database.User{ID: id, Username: "user", Email: "user@example.com"}, nil
|
|
}
|
|
},
|
|
expectedStatus: http.StatusServiceUnavailable,
|
|
expectedError: "Account deletion isn't available right now",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
repo := &testutils.UserRepositoryStub{}
|
|
tt.mockSetup(repo)
|
|
|
|
mockService := &mockAuthService{}
|
|
if tt.name == "email service unavailable" {
|
|
mockService.deleteAccountFunc = func(userID uint) error {
|
|
return services.ErrEmailSenderUnavailable
|
|
}
|
|
}
|
|
|
|
handler := newMockAuthHandler(repo, mockService)
|
|
|
|
request := httptest.NewRequest(http.MethodDelete, "/api/auth/account", nil)
|
|
if tt.userID > 0 {
|
|
ctx := context.WithValue(request.Context(), middleware.UserIDKey, tt.userID)
|
|
request = request.WithContext(ctx)
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler.DeleteAccount(recorder, request)
|
|
|
|
testutils.AssertHTTPStatus(t, recorder, tt.expectedStatus)
|
|
|
|
if tt.expectedError != "" {
|
|
var resp AuthResponse
|
|
json.NewDecoder(recorder.Body).Decode(&resp)
|
|
if !strings.Contains(resp.Error, tt.expectedError) {
|
|
t.Fatalf("expected error to contain %q, got %q", tt.expectedError, resp.Error)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAuthHandlerResendVerificationEmail(t *testing.T) {
|
|
makeRequest := func(body string, setup func(*mockAuthService)) (*httptest.ResponseRecorder, AuthResponse) {
|
|
request := httptest.NewRequest(http.MethodPost, "/api/auth/resend-verification", bytes.NewBufferString(body))
|
|
request = request.WithContext(context.Background())
|
|
|
|
repo := &testutils.UserRepositoryStub{}
|
|
mockService := &mockAuthService{}
|
|
if setup != nil {
|
|
setup(mockService)
|
|
}
|
|
|
|
handler := newMockAuthHandler(repo, mockService)
|
|
recorder := httptest.NewRecorder()
|
|
handler.ResendVerificationEmail(recorder, request)
|
|
|
|
var resp AuthResponse
|
|
_ = json.NewDecoder(recorder.Body).Decode(&resp)
|
|
return recorder, resp
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
setup func(*mockAuthService)
|
|
expectedStatus int
|
|
expectedError string
|
|
expectedMsg string
|
|
}{
|
|
{
|
|
name: "invalid json",
|
|
body: "not-json",
|
|
expectedStatus: http.StatusBadRequest,
|
|
expectedError: "Invalid request body",
|
|
},
|
|
{
|
|
name: "missing email",
|
|
body: `{}`,
|
|
expectedStatus: http.StatusBadRequest,
|
|
expectedError: "Email address is required",
|
|
},
|
|
{
|
|
name: "account not found",
|
|
body: `{"email":"user@example.com"}`,
|
|
setup: func(ms *mockAuthService) {
|
|
ms.resendVerificationFunc = func(email string) error {
|
|
return services.ErrInvalidCredentials
|
|
}
|
|
},
|
|
expectedStatus: http.StatusNotFound,
|
|
expectedError: "No account found with this email address",
|
|
},
|
|
{
|
|
name: "invalid email format",
|
|
body: `{"email":"user@example.com"}`,
|
|
setup: func(ms *mockAuthService) {
|
|
ms.resendVerificationFunc = func(email string) error {
|
|
return services.ErrInvalidEmail
|
|
}
|
|
},
|
|
expectedStatus: http.StatusBadRequest,
|
|
expectedError: "Invalid email address format",
|
|
},
|
|
{
|
|
name: "email already verified",
|
|
body: `{"email":"user@example.com"}`,
|
|
setup: func(ms *mockAuthService) {
|
|
ms.resendVerificationFunc = func(email string) error {
|
|
return fmt.Errorf("email already verified")
|
|
}
|
|
},
|
|
expectedStatus: http.StatusConflict,
|
|
expectedError: "This email address is already verified",
|
|
},
|
|
{
|
|
name: "rate limited",
|
|
body: `{"email":"user@example.com"}`,
|
|
setup: func(ms *mockAuthService) {
|
|
ms.resendVerificationFunc = func(email string) error {
|
|
return fmt.Errorf("verification email sent recently, please wait before requesting another")
|
|
}
|
|
},
|
|
expectedStatus: http.StatusTooManyRequests,
|
|
expectedError: "Please wait 5 minutes before requesting another verification email",
|
|
},
|
|
{
|
|
name: "email service unavailable",
|
|
body: `{"email":"user@example.com"}`,
|
|
setup: func(ms *mockAuthService) {
|
|
ms.resendVerificationFunc = func(email string) error {
|
|
return services.ErrEmailSenderUnavailable
|
|
}
|
|
},
|
|
expectedStatus: http.StatusServiceUnavailable,
|
|
expectedError: "We couldn't send the verification email. Try again later.",
|
|
},
|
|
{
|
|
name: "unexpected error",
|
|
body: `{"email":"user@example.com"}`,
|
|
setup: func(ms *mockAuthService) {
|
|
ms.resendVerificationFunc = func(email string) error {
|
|
return fmt.Errorf("smtp failed")
|
|
}
|
|
},
|
|
expectedStatus: http.StatusInternalServerError,
|
|
expectedError: "Unable to resend verification email",
|
|
},
|
|
{
|
|
name: "success",
|
|
body: `{"email":"user@example.com"}`,
|
|
expectedStatus: http.StatusOK,
|
|
expectedMsg: "Verification email sent successfully",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
rr, resp := makeRequest(tt.body, tt.setup)
|
|
|
|
if rr.Code != tt.expectedStatus {
|
|
t.Fatalf("expected status %d, got %d", tt.expectedStatus, rr.Code)
|
|
}
|
|
|
|
if tt.expectedError != "" {
|
|
if !strings.Contains(resp.Error, tt.expectedError) {
|
|
t.Fatalf("expected error to contain %q, got %q", tt.expectedError, resp.Error)
|
|
}
|
|
return
|
|
}
|
|
|
|
if resp.Message != tt.expectedMsg {
|
|
t.Fatalf("expected message %q, got %q", tt.expectedMsg, resp.Message)
|
|
}
|
|
|
|
if !resp.Success {
|
|
t.Fatalf("expected success response")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAuthHandlerConfirmAccountDeletion(t *testing.T) {
|
|
repo := &testutils.UserRepositoryStub{}
|
|
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
setup func(*mockAuthService)
|
|
expectedStatus int
|
|
expectedError string
|
|
expectedMessage string
|
|
expectedSuccess bool
|
|
expectedPostsFlag *bool
|
|
}{
|
|
{
|
|
name: "invalid json",
|
|
body: "not-json",
|
|
expectedStatus: http.StatusBadRequest,
|
|
expectedError: "Invalid request body",
|
|
},
|
|
{
|
|
name: "missing token",
|
|
body: `{}`,
|
|
expectedStatus: http.StatusBadRequest,
|
|
expectedError: "Deletion token is required",
|
|
},
|
|
{
|
|
name: "invalid token from service",
|
|
body: `{"token":"abc"}`,
|
|
setup: func(ms *mockAuthService) {
|
|
ms.confirmAccountDeletionWithPostsFunc = func(token string, deletePosts bool) error {
|
|
return services.ErrInvalidDeletionToken
|
|
}
|
|
},
|
|
expectedStatus: http.StatusBadRequest,
|
|
expectedError: "This deletion link is invalid or has expired.",
|
|
},
|
|
{
|
|
name: "email sender unavailable",
|
|
body: `{"token":"abc"}`,
|
|
setup: func(ms *mockAuthService) {
|
|
ms.confirmAccountDeletionWithPostsFunc = func(token string, deletePosts bool) error {
|
|
return services.ErrEmailSenderUnavailable
|
|
}
|
|
},
|
|
expectedStatus: http.StatusServiceUnavailable,
|
|
expectedError: "Account deletion isn't available right now because email delivery is disabled.",
|
|
},
|
|
{
|
|
name: "deletion succeeds but notification fails",
|
|
body: `{"token":"abc","delete_posts":true}`,
|
|
setup: func(ms *mockAuthService) {
|
|
ms.confirmAccountDeletionWithPostsFunc = func(token string, deletePosts bool) error {
|
|
return services.ErrDeletionEmailFailed
|
|
}
|
|
},
|
|
expectedStatus: http.StatusOK,
|
|
expectedMessage: "Your account has been deleted, but we couldn't send the confirmation email.",
|
|
expectedSuccess: true,
|
|
expectedPostsFlag: func() *bool { b := true; return &b }(),
|
|
},
|
|
{
|
|
name: "successful confirmation",
|
|
body: `{"token":"abc","delete_posts":false}`,
|
|
setup: func(ms *mockAuthService) {
|
|
ms.confirmAccountDeletionWithPostsFunc = func(token string, deletePosts bool) error {
|
|
if token != "abc" || deletePosts {
|
|
return fmt.Errorf("unexpected arguments")
|
|
}
|
|
return nil
|
|
}
|
|
},
|
|
expectedStatus: http.StatusOK,
|
|
expectedMessage: "Your account has been deleted.",
|
|
expectedSuccess: true,
|
|
expectedPostsFlag: func() *bool { b := false; return &b }(),
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
mockService := &mockAuthService{}
|
|
if tt.setup != nil {
|
|
tt.setup(mockService)
|
|
}
|
|
|
|
handler := newMockAuthHandler(repo, mockService)
|
|
|
|
request := httptest.NewRequest(http.MethodPost, "/api/auth/account/confirm", bytes.NewBufferString(tt.body))
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler.ConfirmAccountDeletion(recorder, request)
|
|
|
|
testutils.AssertHTTPStatus(t, recorder, tt.expectedStatus)
|
|
|
|
var resp AuthResponse
|
|
_ = json.NewDecoder(recorder.Body).Decode(&resp)
|
|
|
|
if tt.expectedError != "" {
|
|
if !strings.Contains(resp.Error, tt.expectedError) {
|
|
t.Fatalf("expected error to contain %q, got %q", tt.expectedError, resp.Error)
|
|
}
|
|
return
|
|
}
|
|
|
|
if tt.expectedSuccess != resp.Success {
|
|
t.Fatalf("expected success=%t, got %t", tt.expectedSuccess, resp.Success)
|
|
}
|
|
|
|
if tt.expectedMessage != "" && tt.expectedMessage != resp.Message {
|
|
t.Fatalf("expected message %q, got %q", tt.expectedMessage, resp.Message)
|
|
}
|
|
|
|
if tt.expectedPostsFlag != nil {
|
|
data, ok := resp.Data.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected data map, got %#v", resp.Data)
|
|
}
|
|
postsDeleted, ok := data["posts_deleted"].(bool)
|
|
if !ok || postsDeleted != *tt.expectedPostsFlag {
|
|
t.Fatalf("expected posts_deleted=%t, got %#v", *tt.expectedPostsFlag, data["posts_deleted"])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAuthHandlerLogout(t *testing.T) {
|
|
handler := newAuthHandler(&testutils.UserRepositoryStub{})
|
|
|
|
request := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
|
|
recorder := httptest.NewRecorder()
|
|
|
|
handler.Logout(recorder, request)
|
|
|
|
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
|
|
|
|
var resp AuthResponse
|
|
json.NewDecoder(recorder.Body).Decode(&resp)
|
|
if !resp.Success || resp.Message != "Logged out successfully" {
|
|
t.Fatalf("expected success logout response, got %+v", resp)
|
|
}
|
|
}
|
|
|
|
func TestAuthHandler_EdgeCases(t *testing.T) {
|
|
authService := &mockAuthService{}
|
|
userRepo := testutils.NewUserRepositoryStub()
|
|
handler := NewAuthHandler(authService, userRepo)
|
|
|
|
t.Run("Login with empty username", func(t *testing.T) {
|
|
body := bytes.NewBufferString(`{"username":"","password":"Password123!"}`)
|
|
req := httptest.NewRequest("POST", "/api/auth/login", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.Login(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected status 400 for empty username, got %d", w.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("Login with empty password", func(t *testing.T) {
|
|
body := bytes.NewBufferString(`{"username":"testuser","password":""}`)
|
|
req := httptest.NewRequest("POST", "/api/auth/login", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.Login(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected status 400 for empty password, got %d", w.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("Register with very long username", func(t *testing.T) {
|
|
longUsername := strings.Repeat("a", 100)
|
|
body := bytes.NewBufferString(fmt.Sprintf(`{"username":"%s","email":"test@example.com","password":"Password123!"}`, longUsername))
|
|
req := httptest.NewRequest("POST", "/api/auth/register", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.Register(w, req)
|
|
|
|
testutils.AssertHTTPStatus(t, w, http.StatusBadRequest)
|
|
})
|
|
|
|
t.Run("Register with invalid email format", func(t *testing.T) {
|
|
body := bytes.NewBufferString(`{"username":"testuser","email":"invalid-email","password":"Password123!"}`)
|
|
req := httptest.NewRequest("POST", "/api/auth/register", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.Register(w, req)
|
|
|
|
testutils.AssertHTTPStatus(t, w, http.StatusBadRequest)
|
|
})
|
|
}
|
|
|
|
func TestAuthHandler_ConcurrentAccess(t *testing.T) {
|
|
authService := &mockAuthService{
|
|
loginFunc: func(username, password string) (*services.AuthResult, error) {
|
|
return &services.AuthResult{
|
|
User: &database.User{ID: 1, Username: username},
|
|
AccessToken: "access_token",
|
|
}, nil
|
|
},
|
|
}
|
|
userRepo := testutils.NewUserRepositoryStub()
|
|
handler := NewAuthHandler(authService, userRepo)
|
|
|
|
t.Run("Concurrent login attempts", func(t *testing.T) {
|
|
concurrency := 10
|
|
done := make(chan bool, concurrency)
|
|
|
|
for i := 0; i < concurrency; i++ {
|
|
go func() {
|
|
body := bytes.NewBufferString(`{"username":"testuser","password":"Password123!"}`)
|
|
req := httptest.NewRequest("POST", "/api/auth/login", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.Login(w, req)
|
|
|
|
testutils.AssertHTTPStatus(t, w, http.StatusOK)
|
|
done <- true
|
|
}()
|
|
}
|
|
|
|
for i := 0; i < concurrency; i++ {
|
|
<-done
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAuthHandler_RefreshToken(t *testing.T) {
|
|
authService := &mockAuthService{}
|
|
userRepo := testutils.NewUserRepositoryStub()
|
|
handler := NewAuthHandler(authService, userRepo)
|
|
|
|
t.Run("Successful_Refresh", func(t *testing.T) {
|
|
authService.refreshAccessTokenFunc = func(refreshToken string) (*services.AuthResult, error) {
|
|
return &services.AuthResult{
|
|
User: &database.User{ID: 1, Username: "testuser"},
|
|
AccessToken: "new_access_token",
|
|
RefreshToken: refreshToken,
|
|
}, nil
|
|
}
|
|
|
|
body := bytes.NewBufferString(`{"refresh_token":"valid_refresh_token"}`)
|
|
req := httptest.NewRequest("POST", "/api/auth/refresh", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.RefreshToken(w, req)
|
|
|
|
testutils.AssertSuccessResponse(t, w)
|
|
})
|
|
|
|
t.Run("Invalid_Request_Body", func(t *testing.T) {
|
|
body := bytes.NewBufferString(`invalid json`)
|
|
req := httptest.NewRequest("POST", "/api/auth/refresh", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.RefreshToken(w, req)
|
|
|
|
testutils.AssertHTTPStatus(t, w, http.StatusBadRequest)
|
|
})
|
|
|
|
t.Run("Missing_Refresh_Token", func(t *testing.T) {
|
|
body := bytes.NewBufferString(`{"refresh_token":""}`)
|
|
req := httptest.NewRequest("POST", "/api/auth/refresh", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.RefreshToken(w, req)
|
|
|
|
testutils.AssertHTTPStatus(t, w, http.StatusBadRequest)
|
|
})
|
|
|
|
t.Run("Expired_Refresh_Token", func(t *testing.T) {
|
|
authService.refreshAccessTokenFunc = func(refreshToken string) (*services.AuthResult, error) {
|
|
return nil, services.ErrRefreshTokenExpired
|
|
}
|
|
|
|
body := bytes.NewBufferString(`{"refresh_token":"expired_token"}`)
|
|
req := httptest.NewRequest("POST", "/api/auth/refresh", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.RefreshToken(w, req)
|
|
|
|
testutils.AssertHTTPStatus(t, w, http.StatusUnauthorized)
|
|
})
|
|
|
|
t.Run("Invalid_Refresh_Token", func(t *testing.T) {
|
|
authService.refreshAccessTokenFunc = func(refreshToken string) (*services.AuthResult, error) {
|
|
return nil, services.ErrRefreshTokenInvalid
|
|
}
|
|
|
|
body := bytes.NewBufferString(`{"refresh_token":"invalid_token"}`)
|
|
req := httptest.NewRequest("POST", "/api/auth/refresh", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.RefreshToken(w, req)
|
|
|
|
testutils.AssertHTTPStatus(t, w, http.StatusUnauthorized)
|
|
})
|
|
|
|
t.Run("Account_Locked", func(t *testing.T) {
|
|
authService.refreshAccessTokenFunc = func(refreshToken string) (*services.AuthResult, error) {
|
|
return nil, services.ErrAccountLocked
|
|
}
|
|
|
|
body := bytes.NewBufferString(`{"refresh_token":"locked_token"}`)
|
|
req := httptest.NewRequest("POST", "/api/auth/refresh", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.RefreshToken(w, req)
|
|
|
|
testutils.AssertHTTPStatus(t, w, http.StatusForbidden)
|
|
})
|
|
|
|
t.Run("Internal_Error", func(t *testing.T) {
|
|
authService.refreshAccessTokenFunc = func(refreshToken string) (*services.AuthResult, error) {
|
|
return nil, fmt.Errorf("internal error")
|
|
}
|
|
|
|
body := bytes.NewBufferString(`{"refresh_token":"error_token"}`)
|
|
req := httptest.NewRequest("POST", "/api/auth/refresh", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.RefreshToken(w, req)
|
|
|
|
testutils.AssertHTTPStatus(t, w, http.StatusInternalServerError)
|
|
})
|
|
}
|
|
|
|
func TestAuthHandler_RevokeToken(t *testing.T) {
|
|
authService := &mockAuthService{}
|
|
userRepo := testutils.NewUserRepositoryStub()
|
|
handler := NewAuthHandler(authService, userRepo)
|
|
|
|
t.Run("Successful_Revoke", func(t *testing.T) {
|
|
authService.revokeRefreshTokenFunc = func(refreshToken string) error {
|
|
return nil
|
|
}
|
|
|
|
body := bytes.NewBufferString(`{"refresh_token":"token_to_revoke"}`)
|
|
req := httptest.NewRequest("POST", "/api/auth/revoke", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.RevokeToken(w, req)
|
|
|
|
testutils.AssertSuccessResponse(t, w)
|
|
})
|
|
|
|
t.Run("Invalid_Request_Body", func(t *testing.T) {
|
|
body := bytes.NewBufferString(`invalid json`)
|
|
req := httptest.NewRequest("POST", "/api/auth/revoke", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.RevokeToken(w, req)
|
|
|
|
testutils.AssertHTTPStatus(t, w, http.StatusBadRequest)
|
|
})
|
|
|
|
t.Run("Missing_Refresh_Token", func(t *testing.T) {
|
|
body := bytes.NewBufferString(`{"refresh_token":""}`)
|
|
req := httptest.NewRequest("POST", "/api/auth/revoke", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.RevokeToken(w, req)
|
|
|
|
testutils.AssertHTTPStatus(t, w, http.StatusBadRequest)
|
|
})
|
|
|
|
t.Run("Revoke_Error", func(t *testing.T) {
|
|
authService.revokeRefreshTokenFunc = func(refreshToken string) error {
|
|
return fmt.Errorf("revoke failed")
|
|
}
|
|
|
|
body := bytes.NewBufferString(`{"refresh_token":"token"}`)
|
|
req := httptest.NewRequest("POST", "/api/auth/revoke", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.RevokeToken(w, req)
|
|
|
|
testutils.AssertHTTPStatus(t, w, http.StatusInternalServerError)
|
|
})
|
|
}
|
|
|
|
func TestAuthHandler_RevokeAllTokens(t *testing.T) {
|
|
authService := &mockAuthService{}
|
|
userRepo := testutils.NewUserRepositoryStub()
|
|
handler := NewAuthHandler(authService, userRepo)
|
|
|
|
t.Run("Successful_Revoke_All", func(t *testing.T) {
|
|
authService.revokeAllUserTokensFunc = func(userID uint) error {
|
|
return nil
|
|
}
|
|
|
|
req := httptest.NewRequest("POST", "/api/auth/revoke-all", nil)
|
|
req = req.WithContext(context.WithValue(req.Context(), middleware.UserIDKey, uint(1)))
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.RevokeAllTokens(w, req)
|
|
|
|
testutils.AssertSuccessResponse(t, w)
|
|
})
|
|
|
|
t.Run("Unauthenticated_Request", func(t *testing.T) {
|
|
req := httptest.NewRequest("POST", "/api/auth/revoke-all", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.RevokeAllTokens(w, req)
|
|
|
|
testutils.AssertHTTPStatus(t, w, http.StatusUnauthorized)
|
|
})
|
|
|
|
t.Run("Revoke_Error", func(t *testing.T) {
|
|
authService.revokeAllUserTokensFunc = func(userID uint) error {
|
|
return fmt.Errorf("revoke failed")
|
|
}
|
|
|
|
req := httptest.NewRequest("POST", "/api/auth/revoke-all", nil)
|
|
req = req.WithContext(context.WithValue(req.Context(), middleware.UserIDKey, uint(1)))
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.RevokeAllTokens(w, req)
|
|
|
|
testutils.AssertHTTPStatus(t, w, http.StatusInternalServerError)
|
|
})
|
|
}
|