first commit
709
app/accounts/handlers/admin_users.go
Normal 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"),
|
||||
}
|
||||
}
|
||||
155
app/accounts/handlers/admin_users_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
1281
app/accounts/handlers/user.go
Normal file
735
app/accounts/handlers/user_test.go
Normal 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(), ®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)
|
||||
}
|
||||
}
|
||||