package controllers import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" "golang.org/x/oauth2" "gorm.io/driver/sqlite" "gorm.io/gorm" "goaresv3/app/accounts/models" "goaresv3/config" jwtHelper "goaresv3/pkg/jwt" ) func setupTestDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) if err != nil { t.Fatalf("failed to open sqlite db: %v", err) } if err := db.AutoMigrate(&models.User{}); err != nil { t.Fatalf("failed to migrate user model: %v", err) } if err := db.AutoMigrate(&models.SocialAccount{}); err != nil { t.Fatalf("failed to migrate social account model: %v", err) } config.DB = db return db } func boolPtr(v bool) *bool { return &v } func TestVerifyEmailSuccess(t *testing.T) { gin.SetMode(gin.TestMode) db := setupTestDB(t) user := models.User{UserName: "u1", Email: "u1@example.com", EmailVerified: boolPtr(false), EmailVerifyToken: "tok-123"} if err := db.Create(&user).Error; err != nil { t.Fatalf("failed to create user: %v", err) } r := gin.New() r.GET("/api/v1/auth/verify-email", VerifyEmail) req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify-email?token=tok-123", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var updated models.User if err := db.First(&updated, user.ID).Error; err != nil { t.Fatalf("failed to fetch updated user: %v", err) } if !updated.IsEmailVerified() { t.Fatal("expected email_verified=true") } if updated.EmailVerifyToken != "" { t.Fatalf("expected email_verify_token cleared, got %q", updated.EmailVerifyToken) } } func TestVerifyEmailMissingToken(t *testing.T) { gin.SetMode(gin.TestMode) setupTestDB(t) r := gin.New() r.GET("/api/v1/auth/verify-email", VerifyEmail) req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify-email", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestVerifyEmailInvalidToken(t *testing.T) { gin.SetMode(gin.TestMode) setupTestDB(t) r := gin.New() r.GET("/api/v1/auth/verify-email", VerifyEmail) req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify-email?token=missing", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestLoginRejectsUnverifiedUser(t *testing.T) { gin.SetMode(gin.TestMode) db := setupTestDB(t) t.Setenv("JWT_SECRET", "test-secret-1234567890") t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890") hashed, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) user := models.User{UserName: "u2", Email: "u2@example.com", Password: string(hashed), EmailVerified: boolPtr(false)} if err := db.Create(&user).Error; err != nil { t.Fatalf("failed to create user: %v", err) } body, _ := json.Marshal(LoginRequest{Email: "u2@example.com", Password: "password123"}) r := gin.New() r.POST("/api/v1/auth/login", Login) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d", w.Code) } } func TestLoginBadRequest(t *testing.T) { gin.SetMode(gin.TestMode) setupTestDB(t) r := gin.New() r.POST("/api/v1/auth/login", Login) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewBufferString(`{"email":"bad"}`)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestLoginInvalidCredentials(t *testing.T) { gin.SetMode(gin.TestMode) db := setupTestDB(t) t.Setenv("JWT_SECRET", "test-secret-1234567890") t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890") hashed, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) user := models.User{UserName: "u2x", Email: "u2x@example.com", Password: string(hashed), EmailVerified: boolPtr(true)} if err := db.Create(&user).Error; err != nil { t.Fatalf("failed to create user: %v", err) } body, _ := json.Marshal(LoginRequest{Email: "u2x@example.com", Password: "wrong-pass"}) r := gin.New() r.POST("/api/v1/auth/login", Login) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", w.Code) } } func TestLoginVerifiedUserSuccess(t *testing.T) { gin.SetMode(gin.TestMode) db := setupTestDB(t) t.Setenv("JWT_SECRET", "test-secret-1234567890") t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890") hashed, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) user := models.User{UserName: "u3", Email: "u3@example.com", Password: string(hashed), EmailVerified: boolPtr(true)} if err := db.Create(&user).Error; err != nil { t.Fatalf("failed to create user: %v", err) } body, _ := json.Marshal(LoginRequest{Email: "u3@example.com", Password: "password123"}) r := gin.New() r.POST("/api/v1/auth/login", Login) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } } func TestRefreshRejectsUnverifiedUser(t *testing.T) { gin.SetMode(gin.TestMode) db := setupTestDB(t) t.Setenv("JWT_SECRET", "test-secret-1234567890") t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890") user := models.User{UserName: "u4", Email: "u4@example.com", EmailVerified: boolPtr(false)} if err := db.Create(&user).Error; err != nil { t.Fatalf("failed to create user: %v", err) } rt, err := jwtHelper.GenerateRefreshToken(user.ID, user.Email, user.UserName) if err != nil { t.Fatalf("failed to generate refresh token: %v", err) } body, _ := json.Marshal(RefreshRequest{RefreshToken: rt}) r := gin.New() r.POST("/api/v1/auth/refresh", RefreshToken) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d", w.Code) } } func TestRefreshBadRequest(t *testing.T) { gin.SetMode(gin.TestMode) setupTestDB(t) r := gin.New() r.POST("/api/v1/auth/refresh", RefreshToken) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", bytes.NewBufferString(`{"x":1}`)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestRefreshInvalidToken(t *testing.T) { gin.SetMode(gin.TestMode) setupTestDB(t) t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890") r := gin.New() r.POST("/api/v1/auth/refresh", RefreshToken) body := []byte(`{"refresh_token":"not-a-jwt"}`) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", w.Code) } } func TestRefreshInvalidUser(t *testing.T) { gin.SetMode(gin.TestMode) setupTestDB(t) t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890") rt, err := jwtHelper.GenerateRefreshToken(9999, "ghost@example.com", "ghost") if err != nil { t.Fatalf("failed to generate refresh token: %v", err) } body, _ := json.Marshal(RefreshRequest{RefreshToken: rt}) r := gin.New() r.POST("/api/v1/auth/refresh", RefreshToken) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", w.Code) } } func TestRegisterPasswordMismatch(t *testing.T) { gin.SetMode(gin.TestMode) setupTestDB(t) r := gin.New() r.POST("/api/v1/auth/register", Register) body := []byte(`{"username":"u","email":"u@example.com","password":"password123","confirm_password":"different123"}`) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestRegisterDuplicateEmail(t *testing.T) { gin.SetMode(gin.TestMode) db := setupTestDB(t) verified := true hashed, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) seed := models.User{UserName: "seed", Email: "dup@example.com", Password: string(hashed), EmailVerified: &verified} if err := db.Create(&seed).Error; err != nil { t.Fatalf("failed to seed user: %v", err) } r := gin.New() r.POST("/api/v1/auth/register", Register) body := []byte(`{"username":"newuser","email":"dup@example.com","password":"password123","confirm_password":"password123"}`) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusConflict { t.Fatalf("expected 409, got %d", w.Code) } } func TestRegisterMailFailureRollsBackUser(t *testing.T) { gin.SetMode(gin.TestMode) db := setupTestDB(t) t.Setenv("EMAIL_HOST", "") t.Setenv("EMAIL_PORT", "") t.Setenv("EMAIL_FROM", "") r := gin.New() r.POST("/api/v1/auth/register", Register) body := []byte(`{"username":"rollback","email":"rollback@example.com","password":"password123","confirm_password":"password123"}`) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusInternalServerError { t.Fatalf("expected 500, got %d", w.Code) } var count int64 if err := db.Model(&models.User{}).Where("email = ?", "rollback@example.com").Count(&count).Error; err != nil { t.Fatalf("failed to count users: %v", err) } if count != 0 { t.Fatalf("expected rollback delete, user count=%d", count) } } func TestMeIncludesUsernameFallbackFromDB(t *testing.T) { gin.SetMode(gin.TestMode) db := setupTestDB(t) user := models.User{UserName: "fallback-user", Email: "u5@example.com", EmailVerified: boolPtr(true)} if err := db.Create(&user).Error; err != nil { t.Fatalf("failed to create user: %v", err) } w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) c.Set("user_id", user.ID) c.Set("email", user.Email) Me(c) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var resp map[string]any if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to decode response: %v", err) } if resp["username"] != "fallback-user" { t.Fatalf("expected username=fallback-user, got %v", resp["username"]) } } func TestMeUsesContextUsernameWhenProvided(t *testing.T) { gin.SetMode(gin.TestMode) setupTestDB(t) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) c.Set("user_id", uint(99)) c.Set("email", "ctx@example.com") c.Set("username", "ctx-user") Me(c) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var resp map[string]any if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to decode response: %v", err) } if resp["username"] != "ctx-user" { t.Fatalf("expected username=ctx-user, got %v", resp["username"]) } } func TestGoogleLoginMissingConfig(t *testing.T) { gin.SetMode(gin.TestMode) setupTestDB(t) t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY", "") t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET", "") t.Setenv("SOCIAL_AUTH_GOOGLE_REDIRECT_URL", "") r := gin.New() r.GET("/api/v1/auth/google/login", GoogleLogin) req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/google/login", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503, got %d", w.Code) } } func TestGoogleCallbackInvalidState(t *testing.T) { gin.SetMode(gin.TestMode) setupTestDB(t) t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY", "client-id") t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET", "client-secret") t.Setenv("SOCIAL_AUTH_GOOGLE_REDIRECT_URL", "http://localhost:8080/api/v1/auth/google/callback") r := gin.New() r.GET("/api/v1/auth/google/callback", GoogleCallback) req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/google/callback?state=state1&code=code1", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestGoogleCallbackSuccessCreatesUserAndTokens(t *testing.T) { gin.SetMode(gin.TestMode) db := setupTestDB(t) t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY", "client-id") t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET", "client-secret") t.Setenv("SOCIAL_AUTH_GOOGLE_REDIRECT_URL", "http://localhost:8080/api/v1/auth/google/callback") t.Setenv("JWT_SECRET", "test-secret-1234567890") t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890") originalExchange := exchangeGoogleCode originalFetch := fetchGoogleUserInfo t.Cleanup(func() { exchangeGoogleCode = originalExchange fetchGoogleUserInfo = originalFetch }) exchangeGoogleCode = func(ctx context.Context, cfg *oauth2.Config, code string) (*oauth2.Token, error) { return &oauth2.Token{AccessToken: "google-access", TokenType: "Bearer"}, nil } fetchGoogleUserInfo = func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (*googleUserInfo, error) { return &googleUserInfo{ Sub: "google-sub-123", Email: "google.user@example.com", EmailVerified: true, Name: "Google User", Picture: "https://cdn.example.com/avatar.png", GivenName: "Google", FamilyName: "User", }, nil } r := gin.New() r.GET("/api/v1/auth/google/callback", GoogleCallback) req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/google/callback?state=state123&code=code123", nil) req.AddCookie(&http.Cookie{Name: googleOAuthStateCookieName, Value: "state123"}) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) } var resp map[string]any if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to decode response: %v", err) } if resp["access_token"] == "" || resp["refresh_token"] == "" { t.Fatal("expected access_token and refresh_token in response") } if resp["provider"] != "google" { t.Fatalf("expected provider=google, got %v", resp["provider"]) } var user models.User if err := db.Where("email = ?", "google.user@example.com").First(&user).Error; err != nil { t.Fatalf("expected user to be created, err=%v", err) } if !user.IsEmailVerified() { t.Fatal("expected created google user to be verified") } var social models.SocialAccount if err := db.Where("user_id = ? AND provider = ? AND provider_id = ?", user.ID, "google", "google-sub-123").First(&social).Error; err != nil { t.Fatalf("expected social account to be linked, err=%v", err) } } func TestGitHubLoginMissingConfig(t *testing.T) { gin.SetMode(gin.TestMode) setupTestDB(t) t.Setenv("SOCIAL_AUTH_GITHUB_KEY", "") t.Setenv("SOCIAL_AUTH_GITHUB_SECRET", "") t.Setenv("SOCIAL_AUTH_GITHUB_REDIRECT_URL", "") r := gin.New() r.GET("/api/v1/auth/github/login", GitHubLogin) req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/github/login", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503, got %d", w.Code) } } func TestGitHubCallbackSuccessCreatesVerifiedUserAndTokens(t *testing.T) { gin.SetMode(gin.TestMode) db := setupTestDB(t) t.Setenv("SOCIAL_AUTH_GITHUB_KEY", "gh-client-id") t.Setenv("SOCIAL_AUTH_GITHUB_SECRET", "gh-client-secret") t.Setenv("SOCIAL_AUTH_GITHUB_REDIRECT_URL", "http://localhost:8080/api/v1/auth/github/callback") t.Setenv("JWT_SECRET", "test-secret-1234567890") t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890") originalExchange := exchangeGitHubCode originalFetch := fetchGitHubUserInfo t.Cleanup(func() { exchangeGitHubCode = originalExchange fetchGitHubUserInfo = originalFetch }) exchangeGitHubCode = func(ctx context.Context, cfg *oauth2.Config, code string) (*oauth2.Token, error) { return &oauth2.Token{AccessToken: "github-access", TokenType: "Bearer"}, nil } fetchGitHubUserInfo = func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (*socialUserProfile, error) { return &socialUserProfile{ ProviderID: "4242", Email: "github.user@example.com", Name: "GitHub User", AvatarURL: "https://cdn.example.com/gh-avatar.png", EmailVerified: true, }, nil } r := gin.New() r.GET("/api/v1/auth/github/callback", GitHubCallback) req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/github/callback?state=state-gh-123&code=code-gh-123", nil) req.AddCookie(&http.Cookie{Name: githubOAuthStateCookieName, Value: "state-gh-123"}) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) } var resp map[string]any if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to decode response: %v", err) } if resp["access_token"] == "" || resp["refresh_token"] == "" { t.Fatal("expected access_token and refresh_token in response") } if resp["provider"] != "github" { t.Fatalf("expected provider=github, got %v", resp["provider"]) } var user models.User if err := db.Where("email = ?", "github.user@example.com").First(&user).Error; err != nil { t.Fatalf("expected user to be created, err=%v", err) } if !user.IsEmailVerified() { t.Fatal("expected created github user to be verified") } var social models.SocialAccount if err := db.Where("user_id = ? AND provider = ? AND provider_id = ?", user.ID, "github", "4242").First(&social).Error; err != nil { t.Fatalf("expected github social account to be linked, err=%v", err) } } func TestGoogleOAuthConfigSupportsSocialAuthEnv(t *testing.T) { t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY", "'google-client-id-from-social'") t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET", "'google-client-secret-from-social'") t.Setenv("SOCIAL_AUTH_GOOGLE_REDIRECT_URL", "") t.Setenv("APP_BASE_URL", "http://localhost:8080") cfg, err := getGoogleOAuthConfig() if err != nil { t.Fatalf("expected config, got error: %v", err) } if cfg.ClientID != "google-client-id-from-social" { t.Fatalf("unexpected client id: %q", cfg.ClientID) } if cfg.ClientSecret != "google-client-secret-from-social" { t.Fatalf("unexpected client secret: %q", cfg.ClientSecret) } if cfg.RedirectURL != "http://localhost:8080/api/v1/auth/google/callback" { t.Fatalf("unexpected redirect url: %q", cfg.RedirectURL) } } func TestGitHubOAuthConfigSupportsSocialAuthEnv(t *testing.T) { t.Setenv("SOCIAL_AUTH_GITHUB_KEY", "'github-client-id-from-social'") t.Setenv("SOCIAL_AUTH_GITHUB_SECRET", "'github-client-secret-from-social'") t.Setenv("SOCIAL_AUTH_GITHUB_REDIRECT_URL", "") t.Setenv("APP_BASE_URL", "http://localhost:8080") cfg, err := getGitHubOAuthConfig() if err != nil { t.Fatalf("expected config, got error: %v", err) } if cfg.ClientID != "github-client-id-from-social" { t.Fatalf("unexpected client id: %q", cfg.ClientID) } if cfg.ClientSecret != "github-client-secret-from-social" { t.Fatalf("unexpected client secret: %q", cfg.ClientSecret) } if cfg.RedirectURL != "http://localhost:8080/api/v1/auth/github/callback" { t.Fatalf("unexpected redirect url: %q", cfg.RedirectURL) } }