first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:40:14 +03:00
commit e04ba85564
129 changed files with 17541 additions and 0 deletions

View File

@@ -0,0 +1,709 @@
package handlers
import (
"errors"
"log"
"net/http"
"strconv"
"strings"
"time"
"ginimageApi/app/accounts/models"
"ginimageApi/app/middleware"
"ginimageApi/configs"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type adminUserResponse struct {
ID uint `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
IsActive bool `json:"is_active"`
IsAdmin bool `json:"is_admin"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type adminUserListResponse struct {
Items []adminUserResponse `json:"items"`
Meta paginationMeta `json:"meta"`
}
type paginationMeta struct {
Page int `json:"page"`
Limit int `json:"limit"`
Total int64 `json:"total"`
}
type adminCreateUserRequest struct {
Username string `json:"username" binding:"required,min=3"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
ConfirmPassword string `json:"confirm_password" binding:"required,eqfield=Password"`
IsAdmin *bool `json:"is_admin"`
IsActive *bool `json:"is_active"`
}
type adminUpdateUserRequest struct {
Username string `json:"username" binding:"omitempty,min=3"`
Email string `json:"email" binding:"omitempty,email"`
Password string `json:"password" binding:"omitempty,min=6"`
IsAdmin *bool `json:"is_admin"`
IsActive *bool `json:"is_active"`
}
type adminUserStatusRequest struct {
IsActive bool `json:"is_active" binding:"required"`
}
type adminUpdateProfileRequest struct {
FirstName string `form:"first_name" binding:"omitempty,min=2"`
LastName string `form:"last_name" binding:"omitempty,min=2"`
}
type adminProfileResponse struct {
UserID uint64 `json:"user_id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
AvatarURL string `json:"avatar_url"`
}
type adminIssueTokenRequest struct {
DurationDays int `json:"duration_days" binding:"required,min=1,max=365"`
}
type adminIssueTokenResponse struct {
AccessToken string `json:"access"`
ExpiresAt string `json:"expires_at"`
}
type AdminUserResponse = adminUserResponse
type AdminUserListResponse = adminUserListResponse
type AdminCreateUserRequest = adminCreateUserRequest
type AdminUpdateUserRequest = adminUpdateUserRequest
type AdminUserStatusRequest = adminUserStatusRequest
type AdminUpdateProfileRequest = adminUpdateProfileRequest
type AdminProfileResponse = adminProfileResponse
type AdminIssueTokenRequest = adminIssueTokenRequest
type AdminIssueTokenResponse = adminIssueTokenResponse
func adminActorID(c *gin.Context) any {
actorID, ok := c.Get("user_id")
if !ok {
return "unknown"
}
return actorID
}
func maskEmail(email string) string {
email = strings.TrimSpace(strings.ToLower(email))
parts := strings.Split(email, "@")
if len(parts) != 2 || parts[0] == "" {
return "invalid-email"
}
local := parts[0]
domain := parts[1]
if len(local) <= 2 {
return local[:1] + "***@" + domain
}
return local[:2] + "***@" + domain
}
func getOrCreateProfileByUserID(userID uint64) (models.Profile, error) {
var profile models.Profile
err := configs.DB.Where("user_id = ?", userID).First(&profile).Error
if err == nil {
return profile, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return models.Profile{}, err
}
profile = models.Profile{UserID: userID}
if err := configs.DB.Create(&profile).Error; err != nil {
return models.Profile{}, err
}
return profile, nil
}
// ListAdminUsers godoc
// @Summary Admin kullanicilari listeler
// @Tags admin-users
// @Produce json
// @Security BearerAuth
// @Param page query int false "Sayfa numarasi" default(1)
// @Param limit query int false "Sayfa boyutu (max 100)" default(10)
// @Param search query string false "Kullanici adi/email arama"
// @Param is_admin query bool false "Admin filtresi"
// @Param is_active query bool false "Aktiflik filtresi"
// @Success 200 {object} AdminUserListResponse
// @Failure 401 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /api/v1/admin/users [get]
func ListAdminUsers(c *gin.Context) {
page := parsePositiveIntOrDefault(c.Query("page"), 1)
limit := parsePositiveIntOrDefault(c.Query("limit"), 10)
if limit > 100 {
limit = 100
}
search := strings.TrimSpace(c.Query("search"))
isAdminFilter := strings.TrimSpace(c.Query("is_admin"))
isActiveFilter := strings.TrimSpace(c.Query("is_active"))
query := configs.DB.Model(&models.User{})
if search != "" {
like := "%" + strings.ToLower(search) + "%"
query = query.Where("LOWER(user_name) LIKE ? OR LOWER(email) LIKE ?", like, like)
}
if v, ok := parseOptionalBool(isAdminFilter); ok {
query = query.Where("is_admin = ?", v)
}
if v, ok := parseOptionalBool(isActiveFilter); ok {
query = query.Where("is_active = ?", v)
}
var total int64
if err := query.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanicilar listelenemedi"})
return
}
var users []models.User
if err := query.
Order("id DESC").
Offset((page - 1) * limit).
Limit(limit).
Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanicilar listelenemedi"})
return
}
items := make([]adminUserResponse, 0, len(users))
for _, user := range users {
items = append(items, toAdminUserResponse(user))
}
c.JSON(http.StatusOK, adminUserListResponse{
Items: items,
Meta: paginationMeta{
Page: page,
Limit: limit,
Total: total,
},
})
}
// GetAdminUser godoc
// @Summary Admin panel icin kullanici detayi getirir
// @Tags admin-users
// @Produce json
// @Security BearerAuth
// @Param id path int true "Kullanici ID"
// @Success 200 {object} AdminUserResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /api/v1/admin/users/{id} [get]
func GetAdminUser(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
return
}
var user models.User
if err := configs.DB.First(&user, userID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "kullanici bulunamadi"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici getirilemedi"})
return
}
c.JSON(http.StatusOK, toAdminUserResponse(user))
}
// CreateAdminUser godoc
// @Summary Admin panel icin kullanici olusturur
// @Tags admin-users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body AdminCreateUserRequest true "Kullanici olusturma verisi"
// @Success 201 {object} AdminUserResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /api/v1/admin/users [post]
func CreateAdminUser(c *gin.Context) {
log.Printf("[ADMIN-USER-CREATE] stage=start actor_id=%v ip=%s ua=%q", adminActorID(c), c.ClientIP(), c.Request.UserAgent())
var req adminCreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("[ADMIN-USER-CREATE] stage=bind_failed actor_id=%v ip=%s error=%q", adminActorID(c), c.ClientIP(), err.Error())
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
log.Printf(
"[ADMIN-USER-CREATE] stage=payload_ok actor_id=%v username=%q email=%q is_admin=%v is_active=%v",
adminActorID(c),
req.Username,
maskEmail(req.Email),
req.IsAdmin,
req.IsActive,
)
var exists models.User
err := configs.DB.Where("email = ?", req.Email).First(&exists).Error
if err == nil {
log.Printf(
"[ADMIN-USER-CREATE] stage=conflict actor_id=%v reason=email_exists incoming_email=%q existing_user_id=%d existing_username=%q existing_active=%v existing_admin=%v",
adminActorID(c),
maskEmail(req.Email),
exists.ID,
exists.UserName,
exists.IsActive != nil && *exists.IsActive,
exists.IsAdmin != nil && *exists.IsAdmin,
)
c.JSON(http.StatusConflict, gin.H{
"error": "email zaten kayitli",
"code": "EMAIL_ALREADY_EXISTS",
})
return
}
if err != nil && err != gorm.ErrRecordNotFound {
log.Printf("[ADMIN-USER-CREATE] stage=check_failed actor_id=%v email=%q error=%q", adminActorID(c), maskEmail(req.Email), err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici kontrol edilemedi"})
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
log.Printf("[ADMIN-USER-CREATE] stage=hash_failed actor_id=%v email=%q error=%q", adminActorID(c), maskEmail(req.Email), err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": "sifre islenemedi"})
return
}
isAdmin := false
if req.IsAdmin != nil {
isAdmin = *req.IsAdmin
}
isActive := true
if req.IsActive != nil {
isActive = *req.IsActive
}
user := models.User{
UserName: req.Username,
Email: req.Email,
Password: string(hashedPassword),
EmailVerified: boolPtr(false),
IsActive: boolPtr(isActive),
IsAdmin: boolPtr(isAdmin),
}
if err := configs.DB.Create(&user).Error; err != nil {
log.Printf("[ADMIN-USER-CREATE] stage=create_failed actor_id=%v email=%q error=%q", adminActorID(c), maskEmail(req.Email), err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici olusturulamadi"})
return
}
log.Printf("[ADMIN-USER-CREATE] stage=success actor_id=%v created_user_id=%d email=%q", adminActorID(c), user.ID, maskEmail(user.Email))
c.JSON(http.StatusCreated, toAdminUserResponse(user))
}
// UpdateAdminUser godoc
// @Summary Admin panel icin kullaniciyi gunceller
// @Tags admin-users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "Kullanici ID"
// @Param request body AdminUpdateUserRequest true "Kullanici guncelleme verisi"
// @Success 200 {object} AdminUserResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /api/v1/admin/users/{id} [put]
func UpdateAdminUser(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
return
}
var req adminUpdateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var user models.User
if err := configs.DB.First(&user, userID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "kullanici bulunamadi"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici getirilemedi"})
return
}
if req.Email != "" && req.Email != user.Email {
var exists models.User
err := configs.DB.Where("email = ? AND id <> ?", req.Email, user.ID).First(&exists).Error
if err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "email zaten kayitli"})
return
}
if err != nil && err != gorm.ErrRecordNotFound {
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici kontrol edilemedi"})
return
}
user.Email = req.Email
}
if req.Username != "" {
user.UserName = req.Username
}
if req.IsAdmin != nil {
user.IsAdmin = boolPtr(*req.IsAdmin)
}
if req.IsActive != nil {
user.IsActive = boolPtr(*req.IsActive)
}
if req.Password != "" {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "sifre islenemedi"})
return
}
user.Password = string(hashedPassword)
}
if err := configs.DB.Save(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici guncellenemedi"})
return
}
c.JSON(http.StatusOK, toAdminUserResponse(user))
}
// UpdateAdminUserStatus godoc
// @Summary Admin panel icin kullanici aktiflik durumunu gunceller
// @Tags admin-users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "Kullanici ID"
// @Param request body AdminUserStatusRequest true "Durum verisi"
// @Success 200 {object} AdminUserResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /api/v1/admin/users/{id}/status [patch]
func UpdateAdminUserStatus(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
return
}
var req adminUserStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
result := configs.DB.Model(&models.User{}).Where("id = ?", userID).Update("is_active", req.IsActive)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici durumu guncellenemedi"})
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "kullanici bulunamadi"})
return
}
var user models.User
if err := configs.DB.First(&user, userID).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici getirilemedi"})
return
}
c.JSON(http.StatusOK, toAdminUserResponse(user))
}
// DeleteAdminUser godoc
// @Summary Admin panel icin kullanici siler
// @Tags admin-users
// @Produce json
// @Security BearerAuth
// @Param id path int true "Kullanici ID"
// @Success 200 {object} MessageResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /api/v1/admin/users/{id} [delete]
func DeleteAdminUser(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
return
}
result := configs.DB.Delete(&models.User{}, userID)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici silinemedi"})
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "kullanici bulunamadi"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "kullanici silindi"})
}
// IssueAdminScopedToken godoc
// @Summary Admin için gün bazlı access token üretir
// @Description Sadece admin rolü için, istekle verilen gün kadar geçerli access token üretir. Refresh token üretilmez.
// @Tags admin-users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body AdminIssueTokenRequest true "Token süresi (gün)"
// @Success 200 {object} AdminIssueTokenResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /api/v1/admin/tokens/issue [post]
func IssueAdminScopedToken(c *gin.Context) {
var req adminIssueTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, err := currentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
var user models.User
if err := configs.DB.First(&user, userID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "kullanici bulunamadi"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici getirilemedi"})
return
}
if user.IsAdmin == nil || !*user.IsAdmin {
c.JSON(http.StatusForbidden, gin.H{"error": "admin yetkisi gerekli"})
return
}
tokenTTL := time.Duration(req.DurationDays) * 24 * time.Hour
accessToken, err := middleware.GenerateAccessToken(user.ID, user.Email, user.UserName, tokenTTL)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token olusturulamadi"})
return
}
c.JSON(http.StatusOK, adminIssueTokenResponse{
AccessToken: accessToken,
ExpiresAt: time.Now().Add(tokenTTL).Format(time.RFC3339),
})
}
// GetAdminUserProfile godoc
// @Summary Admin panel icin kullanicinin profilini getirir
// @Tags admin-users
// @Produce json
// @Security BearerAuth
// @Param id path int true "Kullanici ID"
// @Success 200 {object} AdminProfileResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /api/v1/admin/users/{id}/profile [get]
func GetAdminUserProfile(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
return
}
var user models.User
if err := configs.DB.First(&user, userID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "kullanici bulunamadi"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici getirilemedi"})
return
}
profile, err := getOrCreateProfileByUserID(uint64(userID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "profil getirilemedi"})
return
}
c.JSON(http.StatusOK, adminProfileResponse{
UserID: profile.UserID,
FirstName: profile.FirstName,
LastName: profile.LastName,
AvatarURL: profile.AvatarURL,
})
}
// UpdateAdminUserProfile godoc
// @Summary Admin panel icin kullanici profilini gunceller
// @Tags admin-users
// @Accept multipart/form-data
// @Produce json
// @Security BearerAuth
// @Param id path int true "Kullanici ID"
// @Param first_name formData string false "Ad"
// @Param last_name formData string false "Soyad"
// @Param avatar formData file false "Avatar dosyasi"
// @Success 200 {object} AdminProfileResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /api/v1/admin/users/{id}/profile [put]
func UpdateAdminUserProfile(c *gin.Context) {
userID, ok := parseUintParam(c, "id")
if !ok {
return
}
var req adminUpdateProfileRequest
_ = c.ShouldBind(&req)
var user models.User
if err := configs.DB.First(&user, userID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "kullanici bulunamadi"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici getirilemedi"})
return
}
profile, err := getOrCreateProfileByUserID(uint64(userID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "profil getirilemedi"})
return
}
if req.FirstName != "" {
profile.FirstName = req.FirstName
}
if req.LastName != "" {
profile.LastName = req.LastName
}
oldAvatarURL := profile.AvatarURL
avatarURL, hasAvatar, err := saveAvatarFromMultipart(c, "avatar")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "avatar dosyasi okunamadi"})
return
}
if hasAvatar {
profile.AvatarURL = avatarURL
}
if err := configs.DB.Save(&profile).Error; err != nil {
if hasAvatar {
_ = deleteLocalAvatarByURL(avatarURL)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "profil guncellenemedi"})
return
}
if hasAvatar && oldAvatarURL != "" && oldAvatarURL != profile.AvatarURL {
if err := deleteLocalAvatarByURL(oldAvatarURL); err != nil {
log.Printf("[ADMIN-PROFILE-UPDATE] user_id=%d result=warn stage=delete_old_avatar error=%v", userID, err)
}
}
c.JSON(http.StatusOK, adminProfileResponse{
UserID: profile.UserID,
FirstName: profile.FirstName,
LastName: profile.LastName,
AvatarURL: profile.AvatarURL,
})
}
func parsePositiveIntOrDefault(raw string, fallback int) int {
if strings.TrimSpace(raw) == "" {
return fallback
}
v, err := strconv.Atoi(raw)
if err != nil || v <= 0 {
return fallback
}
return v
}
func parseOptionalBool(raw string) (bool, bool) {
if strings.TrimSpace(raw) == "" {
return false, false
}
v, err := strconv.ParseBool(raw)
if err != nil {
return false, false
}
return v, true
}
func parseUintParam(c *gin.Context, key string) (uint, bool) {
raw := strings.TrimSpace(c.Param(key))
if raw == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "gecersiz kullanici id"})
return 0, false
}
id, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "gecersiz kullanici id"})
return 0, false
}
return uint(id), true
}
func toAdminUserResponse(user models.User) adminUserResponse {
return adminUserResponse{
ID: user.ID,
Username: user.UserName,
Email: user.Email,
EmailVerified: user.IsEmailVerified(),
IsActive: user.IsActive != nil && *user.IsActive,
IsAdmin: user.IsAdmin != nil && *user.IsAdmin,
CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: user.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
}

View File

@@ -0,0 +1,155 @@
package handlers
import (
"encoding/json"
"net/http"
"os"
"strings"
"testing"
"ginimageApi/app/accounts/models"
"ginimageApi/app/middleware"
"ginimageApi/configs"
"github.com/gin-gonic/gin"
)
func TestAdminUserProfileGetAndUpdate(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("JWT_SECRET", "test-secret")
setupHandlersTestDB(t)
adminFlag := true
active := true
verified := true
adminUser := models.User{
UserName: "admin",
Email: "admin-profile@example.com",
Password: "x",
IsAdmin: &adminFlag,
IsActive: &active,
EmailVerified: &verified,
}
if err := configs.DB.Create(&adminUser).Error; err != nil {
t.Fatalf("create admin failed: %v", err)
}
targetFlag := false
target := models.User{
UserName: "target",
Email: "target-profile@example.com",
Password: "x",
IsAdmin: &targetFlag,
IsActive: &active,
EmailVerified: &verified,
}
if err := configs.DB.Create(&target).Error; err != nil {
t.Fatalf("create target failed: %v", err)
}
oldAvatarURL, oldAvatarPath := createOldAvatarFixture(t, "old_admin_target_avatar.png")
seedProfile := models.Profile{UserID: uint64(target.ID), AvatarURL: oldAvatarURL}
if err := configs.DB.Create(&seedProfile).Error; err != nil {
t.Fatalf("seed profile failed: %v", err)
}
token, err := middleware.BuildAccessTokenForUser(adminUser)
if err != nil {
t.Fatalf("token create failed: %v", err)
}
r := gin.New()
r.GET("/admin/users/:id/profile", middleware.AuthRequired(), middleware.AdminRequired(), GetAdminUserProfile)
r.PUT("/admin/users/:id/profile", middleware.AuthRequired(), middleware.AdminRequired(), UpdateAdminUserProfile)
// Profile kaydi yoksa GET ile otomatik olusmali.
wGet := performJSON(r, http.MethodGet, "/admin/users/"+toString(target.ID)+"/profile", nil, map[string]string{
"Authorization": "Bearer " + token,
})
if wGet.Code != http.StatusOK {
t.Fatalf("get admin profile expected 200, got %d body=%s", wGet.Code, wGet.Body.String())
}
var getResp map[string]any
if err := json.Unmarshal(wGet.Body.Bytes(), &getResp); err != nil {
t.Fatalf("parse get response failed: %v", err)
}
if int(getResp["user_id"].(float64)) != int(target.ID) {
t.Fatalf("user_id mismatch in get response")
}
wPut := performMultipart(
r,
http.MethodPut,
"/admin/users/"+toString(target.ID)+"/profile",
map[string]string{"first_name": "Admin", "last_name": "Updated"},
"avatar",
"admin.png",
tinyPNGFixture(t),
map[string]string{"Authorization": "Bearer " + token},
)
if wPut.Code != http.StatusOK {
t.Fatalf("update admin profile expected 200, got %d body=%s", wPut.Code, wPut.Body.String())
}
var profile models.Profile
if err := configs.DB.Where("user_id = ?", target.ID).First(&profile).Error; err != nil {
t.Fatalf("profile should exist after update: %v", err)
}
if profile.FirstName != "Admin" || profile.LastName != "Updated" {
t.Fatalf("profile name mismatch: %+v", profile)
}
if !strings.HasPrefix(profile.AvatarURL, "/uploads/avatars/") {
t.Fatalf("avatar path mismatch: %s", profile.AvatarURL)
}
if _, err := os.Stat(oldAvatarPath); !os.IsNotExist(err) {
t.Fatalf("old avatar should be deleted, err=%v", err)
}
}
func TestAdminUserProfileRequiresAdminRole(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("JWT_SECRET", "test-secret")
setupHandlersTestDB(t)
active := true
verified := true
nonAdminFlag := false
nonAdmin := models.User{
UserName: "nonadmin",
Email: "nonadmin-profile@example.com",
Password: "x",
IsAdmin: &nonAdminFlag,
IsActive: &active,
EmailVerified: &verified,
}
if err := configs.DB.Create(&nonAdmin).Error; err != nil {
t.Fatalf("create non-admin failed: %v", err)
}
target := models.User{
UserName: "target2",
Email: "target2-profile@example.com",
Password: "x",
IsAdmin: &nonAdminFlag,
IsActive: &active,
EmailVerified: &verified,
}
if err := configs.DB.Create(&target).Error; err != nil {
t.Fatalf("create target failed: %v", err)
}
token, err := middleware.BuildAccessTokenForUser(nonAdmin)
if err != nil {
t.Fatalf("token create failed: %v", err)
}
r := gin.New()
r.GET("/admin/users/:id/profile", middleware.AuthRequired(), middleware.AdminRequired(), GetAdminUserProfile)
w := performJSON(r, http.MethodGet, "/admin/users/"+toString(target.ID)+"/profile", nil, map[string]string{
"Authorization": "Bearer " + token,
})
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for non-admin, got %d body=%s", w.Code, w.Body.String())
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,735 @@
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(), &regResp); 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)
}
}