package handlers import ( "bytes" "encoding/base64" "encoding/json" "mime/multipart" "net/http" "net/http/httptest" "net/http/httputil" "net/url" "os" "path/filepath" "strconv" "strings" "testing" "time" "ginimageApi/app/accounts/models" "ginimageApi/app/middleware" "ginimageApi/configs" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func setupHandlersTestDB(t *testing.T) { t.Helper() t.Setenv("AVATAR_WIDTH", "64") t.Setenv("AVATAR_HEIGHT", "64") t.Setenv("AVATAR_QUALITY", "80") t.Setenv("AVATAR_MAX_SIZE_MB", "5") t.Setenv("AVATAR_FORMATS", "png") prev := configs.DB dsn := "file:" + t.Name() + "?mode=memory&cache=shared" db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) if err != nil { t.Fatalf("sqlite open failed: %v", err) } if err := db.AutoMigrate(&models.User{}, &models.Profile{}, &models.SocialAccount{}, &models.RefreshToken{}); err != nil { t.Fatalf("migrate failed: %v", err) } configs.DB = db t.Cleanup(func() { if sqlDB, err := db.DB(); err == nil { _ = sqlDB.Close() } configs.DB = prev }) } func tinyPNGFixture(t *testing.T) []byte { t.Helper() // 1x1 PNG const data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Zr0kAAAAASUVORK5CYII=" b, err := base64.StdEncoding.DecodeString(data) if err != nil { t.Fatalf("png fixture decode failed: %v", err) } return b } func createOldAvatarFixture(t *testing.T, fileName string) (string, string) { t.Helper() dir := filepath.Join("uploads", "avatars") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir avatars failed: %v", err) } fullPath := filepath.Join(dir, fileName) if err := os.WriteFile(fullPath, []byte("old-avatar"), 0o644); err != nil { t.Fatalf("write old avatar failed: %v", err) } t.Cleanup(func() { _ = os.Remove(fullPath) }) return "/uploads/avatars/" + fileName, fullPath } func performJSON(r *gin.Engine, method, path string, payload any, headers map[string]string) *httptest.ResponseRecorder { var body []byte if payload != nil { body, _ = json.Marshal(payload) } req := httptest.NewRequest(method, path, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") for k, v := range headers { req.Header.Set(k, v) } w := httptest.NewRecorder() r.ServeHTTP(w, req) return w } func performMultipart( r *gin.Engine, method, path string, fields map[string]string, fileField, fileName string, fileContent []byte, headers map[string]string, ) *httptest.ResponseRecorder { var body bytes.Buffer writer := multipart.NewWriter(&body) for k, v := range fields { _ = writer.WriteField(k, v) } if fileField != "" { part, _ := writer.CreateFormFile(fileField, fileName) _, _ = part.Write(fileContent) } _ = writer.Close() req := httptest.NewRequest(method, path, &body) req.Header.Set("Content-Type", writer.FormDataContentType()) for k, v := range headers { req.Header.Set(k, v) } w := httptest.NewRecorder() r.ServeHTTP(w, req) return w } func assertJWTFormat(t *testing.T, token string) { t.Helper() parts := strings.Split(token, ".") if len(parts) != 3 { t.Fatalf("token JWT formatinda olmali, segment sayisi: %d", len(parts)) } } func TestAuthFlowRegisterLoginMeRefresh(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("JWT_SECRET", "test-secret") setupHandlersTestDB(t) r := gin.New() r.POST("/register", Register) r.POST("/login", Login) r.POST("/refresh", Refresh) r.GET("/verify-email", VerifyEmail) r.GET("/me", middleware.AuthRequired(), Me) registerPayload := map[string]any{ "username": "john", "email": "john@example.com", "first_name": "John", "last_name": "Doe", "password": "secret123", "confirm_password": "secret123", } wReg := performJSON(r, http.MethodPost, "/register", registerPayload, nil) if wReg.Code != http.StatusCreated { t.Fatalf("register expected 201, got %d body=%s", wReg.Code, wReg.Body.String()) } var regResp map[string]any if err := json.Unmarshal(wReg.Body.Bytes(), ®Resp); err != nil { t.Fatalf("register json parse failed: %v", err) } accessToken, _ := regResp["access"].(string) if accessToken != "" { t.Fatalf("register should not return direct access token before email verification") } verificationToken, _ := regResp["verification_token"].(string) if verificationToken == "" { t.Fatalf("verification_token must be returned") } wLogin := performJSON(r, http.MethodPost, "/login", map[string]any{ "email": "john@example.com", "password": "secret123", }, nil) if wLogin.Code != http.StatusForbidden { t.Fatalf("login expected 403 before verify, got %d body=%s", wLogin.Code, wLogin.Body.String()) } verifyPath := "/verify-email?token=" + url.QueryEscape(verificationToken) wVerify := performJSON(r, http.MethodGet, verifyPath, nil, nil) if wVerify.Code != http.StatusOK { t.Fatalf("verify expected 200, got %d body=%s", wVerify.Code, wVerify.Body.String()) } var verifyResp map[string]any if err := json.Unmarshal(wVerify.Body.Bytes(), &verifyResp); err != nil { t.Fatalf("verify json parse failed: %v", err) } accessToken, _ = verifyResp["access"].(string) refreshToken, _ := verifyResp["refresh"].(string) if accessToken == "" || refreshToken == "" { t.Fatalf("verify should return tokens") } assertJWTFormat(t, accessToken) assertJWTFormat(t, refreshToken) wLogin = performJSON(r, http.MethodPost, "/login", map[string]any{ "email": "john@example.com", "password": "secret123", }, nil) if wLogin.Code != http.StatusOK { t.Fatalf("login expected 200, got %d body=%s", wLogin.Code, wLogin.Body.String()) } var loginResp map[string]any if err := json.Unmarshal(wLogin.Body.Bytes(), &loginResp); err != nil { t.Fatalf("login json parse failed: %v", err) } loginRefreshToken, _ := loginResp["refresh"].(string) loginAccessToken, _ := loginResp["access"].(string) assertJWTFormat(t, loginAccessToken) assertJWTFormat(t, loginRefreshToken) wMe := performJSON(r, http.MethodGet, "/me", nil, map[string]string{"Authorization": "Bearer " + accessToken}) if wMe.Code != http.StatusOK { t.Fatalf("me expected 200, got %d", wMe.Code) } wRefresh := performJSON(r, http.MethodPost, "/refresh", map[string]any{"refresh_token": refreshToken}, nil) if wRefresh.Code != http.StatusOK { t.Fatalf("refresh expected 200, got %d body=%s", wRefresh.Code, wRefresh.Body.String()) } var refreshResp map[string]any if err := json.Unmarshal(wRefresh.Body.Bytes(), &refreshResp); err != nil { t.Fatalf("refresh json parse failed: %v", err) } newRefreshToken, _ := refreshResp["refresh"].(string) newAccessToken, _ := refreshResp["access"].(string) assertJWTFormat(t, newAccessToken) assertJWTFormat(t, newRefreshToken) if newRefreshToken == refreshToken { t.Fatalf("refresh rotation should return a new refresh token") } var oldToken models.RefreshToken if err := configs.DB.Where("token_hash = ?", hashToken(refreshToken)).First(&oldToken).Error; err != nil { t.Fatalf("refresh token record not found: %v", err) } if !oldToken.Revoked { t.Fatalf("old refresh token should be revoked after refresh") } if oldToken.ReplacedByTokenID == "" { t.Fatalf("old token should keep replaced_by_token_id") } } func TestRegisterDuplicateEmail(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("JWT_SECRET", "test-secret") setupHandlersTestDB(t) r := gin.New() r.POST("/register", Register) payload := map[string]any{ "username": "user1", "email": "dup@example.com", "first_name": "User", "last_name": "One", "password": "secret123", "confirm_password": "secret123", } w1 := performJSON(r, http.MethodPost, "/register", payload, nil) if w1.Code != http.StatusCreated { t.Fatalf("first register expected 201, got %d body=%s", w1.Code, w1.Body.String()) } w2 := performJSON(r, http.MethodPost, "/register", payload, nil) if w2.Code != http.StatusConflict { t.Fatalf("second register expected 409, got %d", w2.Code) } } func TestLoginWrongPassword(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("JWT_SECRET", "test-secret") setupHandlersTestDB(t) hash, err := bcrypt.GenerateFromPassword([]byte("correct-password"), bcrypt.DefaultCost) if err != nil { t.Fatalf("bcrypt failed: %v", err) } isAdmin := false user := models.User{UserName: "u1", Email: "u1@example.com", Password: string(hash), IsAdmin: &isAdmin} if err := configs.DB.Create(&user).Error; err != nil { t.Fatalf("seed user failed: %v", err) } r := gin.New() r.POST("/login", Login) w := performJSON(r, http.MethodPost, "/login", map[string]any{ "email": "u1@example.com", "password": "wrong-password", }, nil) if w.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", w.Code) } } func TestMakeAdminWithAdminMiddleware(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("JWT_SECRET", "test-secret") setupHandlersTestDB(t) isAdmin := true isUser := false admin := models.User{UserName: "admin", Email: "admin@example.com", Password: "x", IsAdmin: &isAdmin} target := models.User{UserName: "target", Email: "target@example.com", Password: "x", IsAdmin: &isUser} if err := configs.DB.Create(&admin).Error; err != nil { t.Fatalf("create admin failed: %v", err) } if err := configs.DB.Create(&target).Error; err != nil { t.Fatalf("create target failed: %v", err) } token, err := middleware.BuildAccessTokenForUser(admin) if err != nil { t.Fatalf("token create failed: %v", err) } r := gin.New() r.POST("/users/:id/admin", middleware.AuthRequired(), middleware.AdminRequired(), MakeAdmin) w := performJSON(r, http.MethodPost, "/users/"+toString(target.ID)+"/admin", map[string]any{"is_admin": true}, map[string]string{ "Authorization": "Bearer " + token, }) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) } var updated models.User if err := configs.DB.First(&updated, target.ID).Error; err != nil { t.Fatalf("read updated user failed: %v", err) } if updated.IsAdmin == nil || !*updated.IsAdmin { t.Fatalf("target user should be admin") } } func TestRefreshRejectsExpiredToken(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("JWT_SECRET", "test-secret") setupHandlersTestDB(t) isAdmin := false user := models.User{UserName: "u2", Email: "u2@example.com", Password: "x", IsAdmin: &isAdmin} if err := configs.DB.Create(&user).Error; err != nil { t.Fatalf("user create failed: %v", err) } refresh := "deadbeef" record := models.RefreshToken{ UserID: uint64(user.ID), TokenID: "tid1", TokenHash: hashToken(refresh), TokenFingerprint: tokenFingerprint(refresh), ExpiresAt: time.Now().Add(-time.Minute), } if err := configs.DB.Create(&record).Error; err != nil { t.Fatalf("refresh create failed: %v", err) } r := gin.New() r.POST("/refresh", Refresh) w := performJSON(r, http.MethodPost, "/refresh", map[string]any{"refresh_token": refresh}, nil) if w.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", w.Code) } } func TestIssueTokens_DefaultFlowKeepsSessionExpiryNilAndRefreshWorks(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("JWT_SECRET", "test-secret") setupHandlersTestDB(t) isAdmin := false isActive := true emailVerified := true user := models.User{ UserName: "normal_user", Email: "normal@example.com", Password: "x", IsAdmin: &isAdmin, IsActive: &isActive, EmailVerified: &emailVerified, } if err := configs.DB.Create(&user).Error; err != nil { t.Fatalf("user create failed: %v", err) } _, refreshToken, _, err := issueTokens(user, "test-agent", "127.0.0.1") if err != nil { t.Fatalf("issueTokens failed: %v", err) } var record models.RefreshToken if err := configs.DB.Where("token_hash = ?", hashToken(refreshToken)).First(&record).Error; err != nil { t.Fatalf("refresh token record should exist: %v", err) } if record.SessionExpiresAt != nil { t.Fatalf("default flow should keep session_expires_at nil") } r := gin.New() r.POST("/refresh", Refresh) w := performJSON(r, http.MethodPost, "/refresh", map[string]any{"refresh_token": refreshToken}, nil) if w.Code != http.StatusOK { t.Fatalf("refresh expected 200 for default flow, got %d body=%s", w.Code, w.Body.String()) } } func TestAdminScopedTokenIssuesOnlyAccessTokenWithoutRefreshRecord(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("JWT_SECRET", "test-secret") setupHandlersTestDB(t) isAdmin := true isActive := true emailVerified := true adminUser := models.User{ UserName: "admin_user", Email: "admin_scoped@example.com", Password: "x", IsAdmin: &isAdmin, IsActive: &isActive, EmailVerified: &emailVerified, } if err := configs.DB.Create(&adminUser).Error; err != nil { t.Fatalf("admin user create failed: %v", err) } accessToken, err := middleware.BuildAccessTokenForUser(adminUser) if err != nil { t.Fatalf("build access token failed: %v", err) } r := gin.New() r.POST("/admin/tokens/issue", middleware.AuthRequired(), middleware.AdminRequired(), IssueAdminScopedToken) wIssue := performJSON( r, http.MethodPost, "/admin/tokens/issue", map[string]any{"duration_days": 45}, map[string]string{"Authorization": "Bearer " + accessToken}, ) if wIssue.Code != http.StatusOK { t.Fatalf("issue endpoint expected 200, got %d body=%s", wIssue.Code, wIssue.Body.String()) } var issueResp map[string]any if err := json.Unmarshal(wIssue.Body.Bytes(), &issueResp); err != nil { t.Fatalf("issue response parse failed: %v", err) } scopedAccess, _ := issueResp["access"].(string) if scopedAccess == "" { t.Fatalf("issued scoped access token should exist") } assertJWTFormat(t, scopedAccess) if _, ok := issueResp["refresh"]; ok { t.Fatalf("scoped token response should not include refresh token") } expiresAt, _ := issueResp["expires_at"].(string) if expiresAt == "" { t.Fatalf("scoped token response should include expires_at") } expiresAtTime, err := time.Parse(time.RFC3339, expiresAt) if err != nil { t.Fatalf("expires_at should be RFC3339: %v", err) } expected := time.Now().Add(45 * 24 * time.Hour) if expiresAtTime.Before(expected.Add(-2*time.Minute)) || expiresAtTime.After(expected.Add(2*time.Minute)) { t.Fatalf("expires_at should be close to requested duration, got=%s expected_around=%s", expiresAtTime, expected) } var refreshCount int64 if err := configs.DB.Model(&models.RefreshToken{}).Where("user_id = ?", adminUser.ID).Count(&refreshCount).Error; err != nil { t.Fatalf("refresh token count query failed: %v", err) } if refreshCount != 0 { t.Fatalf("scoped access token flow should not create refresh records, got %d", refreshCount) } } func toString(v uint) string { return strconv.FormatUint(uint64(v), 10) } func TestRegisterRejectsMismatchedConfirmPassword(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("JWT_SECRET", "test-secret") setupHandlersTestDB(t) r := gin.New() r.POST("/register", Register) w := performJSON(r, http.MethodPost, "/register", map[string]any{ "username": "user2", "email": "user2@example.com", "first_name": "User", "last_name": "Two", "password": "secret123", "confirm_password": "wrong123", }, nil) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d body=%s", w.Code, w.Body.String()) } } func TestGoogleSocialLoginCreatesVerifiedActiveUserAndProfile(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("JWT_SECRET", "test-secret") setupHandlersTestDB(t) provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/userinfo" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"sub":"g-123","email":"social@example.com","email_verified":true,"given_name":"Social","family_name":"User","picture":"https://cdn/avatar.png"}`)) })) defer provider.Close() prevGoogle := googleUserInfoURL googleUserInfoURL = provider.URL + "/userinfo" t.Cleanup(func() { googleUserInfoURL = prevGoogle }) r := gin.New() r.POST("/auth/social/google", GoogleLogin) w := performJSON(r, http.MethodPost, "/auth/social/google", map[string]any{"access_token": "token-abc"}, nil) if w.Code != http.StatusOK { dump, _ := httputil.DumpResponse(w.Result(), true) t.Fatalf("expected 200, got %d body=%s", w.Code, string(dump)) } var resp map[string]any if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("response parse failed: %v", err) } if resp["provider"] != "google" { t.Fatalf("provider should be google") } if access, _ := resp["access"].(string); access == "" { t.Fatalf("access token should be returned") } var user models.User if err := configs.DB.Where("email = ?", "social@example.com").First(&user).Error; err != nil { t.Fatalf("user should be created: %v", err) } if user.EmailVerified == nil || !*user.EmailVerified { t.Fatalf("social login user should be email verified") } if user.IsActive == nil || !*user.IsActive { t.Fatalf("social login user should be active") } var profile models.Profile if err := configs.DB.Where("user_id = ?", user.ID).First(&profile).Error; err != nil { t.Fatalf("profile should be created: %v", err) } if profile.FirstName != "Social" || profile.LastName != "User" { t.Fatalf("profile name mismatch: %+v", profile) } } func TestGitHubSocialLoginReadsPrimaryEmail(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("JWT_SECRET", "test-secret") setupHandlersTestDB(t) provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/user": _, _ = w.Write([]byte(`{"id":99,"login":"octo","name":"Octo Cat","email":"","avatar_url":"https://cdn/octo.png"}`)) case "/user/emails": _, _ = w.Write([]byte(`[{"email":"octo@example.com","primary":true,"verified":true}]`)) default: http.NotFound(w, r) } })) defer provider.Close() prevUser := githubUserURL prevEmails := githubEmailsURL githubUserURL = provider.URL + "/user" githubEmailsURL = provider.URL + "/user/emails" t.Cleanup(func() { githubUserURL = prevUser githubEmailsURL = prevEmails }) r := gin.New() r.POST("/auth/social/github", GitHubLogin) w := performJSON(r, http.MethodPost, "/auth/social/github", map[string]any{"access_token": "gh-token"}, nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) } var social models.SocialAccount if err := configs.DB.Where("provider = ? AND provider_id = ?", "github", "99").First(&social).Error; err != nil { t.Fatalf("github social account should be created: %v", err) } if social.Email != "octo@example.com" { t.Fatalf("github email should come from /user/emails, got %s", social.Email) } } func TestMeProfileGetAndUpdate(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("JWT_SECRET", "test-secret") setupHandlersTestDB(t) isActive := true emailVerified := true isAdmin := false user := models.User{ UserName: "profile_user", Email: "profile@example.com", Password: "x", IsActive: &isActive, EmailVerified: &emailVerified, IsAdmin: &isAdmin, } if err := configs.DB.Create(&user).Error; err != nil { t.Fatalf("create user failed: %v", err) } oldAvatarURL, oldAvatarPath := createOldAvatarFixture(t, "old_user_avatar.png") profile := models.Profile{UserID: uint64(user.ID), FirstName: "Test", LastName: "User", AvatarURL: oldAvatarURL} if err := configs.DB.Create(&profile).Error; err != nil { t.Fatalf("create profile failed: %v", err) } token, err := middleware.BuildAccessTokenForUser(user) if err != nil { t.Fatalf("token create failed: %v", err) } r := gin.New() r.GET("/me/profile", middleware.AuthRequired(), GetMyProfile) r.PUT("/me/profile", middleware.AuthRequired(), UpdateMyProfile) wGet := performJSON(r, http.MethodGet, "/me/profile", nil, map[string]string{"Authorization": "Bearer " + token}) if wGet.Code != http.StatusOK { t.Fatalf("get profile expected 200, got %d body=%s", wGet.Code, wGet.Body.String()) } wPut := performMultipart( r, http.MethodPut, "/me/profile", map[string]string{"first_name": "Yeni", "last_name": "Isim"}, "avatar", "avatar.png", tinyPNGFixture(t), map[string]string{"Authorization": "Bearer " + token}, ) if wPut.Code != http.StatusOK { t.Fatalf("update profile expected 200, got %d body=%s", wPut.Code, wPut.Body.String()) } var updated models.Profile if err := configs.DB.Where("user_id = ?", user.ID).First(&updated).Error; err != nil { t.Fatalf("read updated profile failed: %v", err) } if updated.FirstName != "Yeni" || updated.LastName != "Isim" { t.Fatalf("profile name not updated: %+v", updated) } if !strings.HasPrefix(updated.AvatarURL, "/uploads/avatars/") { t.Fatalf("avatar path not updated: %s", updated.AvatarURL) } if _, err := os.Stat(oldAvatarPath); !os.IsNotExist(err) { t.Fatalf("old avatar should be deleted, err=%v", err) } } func TestMeProfileUpdateCreatesProfileWhenMissing(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("JWT_SECRET", "test-secret") setupHandlersTestDB(t) isActive := true emailVerified := true isAdmin := false user := models.User{ UserName: "legacy_user", Email: "legacy@example.com", Password: "x", IsActive: &isActive, EmailVerified: &emailVerified, IsAdmin: &isAdmin, } if err := configs.DB.Create(&user).Error; err != nil { t.Fatalf("create user failed: %v", err) } token, err := middleware.BuildAccessTokenForUser(user) if err != nil { t.Fatalf("token create failed: %v", err) } r := gin.New() r.PUT("/me/profile", middleware.AuthRequired(), UpdateMyProfile) wPut := performMultipart( r, http.MethodPut, "/me/profile", map[string]string{"first_name": "Beyhan", "last_name": "Ogur"}, "avatar", "avatar.png", tinyPNGFixture(t), map[string]string{"Authorization": "Bearer " + token}, ) if wPut.Code != http.StatusOK { t.Fatalf("update profile expected 200, got %d body=%s", wPut.Code, wPut.Body.String()) } var profile models.Profile if err := configs.DB.Where("user_id = ?", user.ID).First(&profile).Error; err != nil { t.Fatalf("profile should be auto-created: %v", err) } if profile.FirstName != "Beyhan" || profile.LastName != "Ogur" { t.Fatalf("profile fields mismatch: %+v", profile) } }