* HH-438: close upload and remote URL attack surfaces * HH-438: use valid PNG in direct upload test * HH-438: align PostgreSQL upload staging schema * HH-438: run upload migrations before PostgreSQL E2E --------- Co-authored-by: Rogee <rogee@ipao.vip>
1160 lines
41 KiB
Go
1160 lines
41 KiB
Go
package v1
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/suite"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
|
|
"github.com/gochat/gochat/internal/auth"
|
|
"github.com/gochat/gochat/internal/config"
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
"github.com/gochat/gochat/internal/service"
|
|
"github.com/gochat/gochat/pkg/crypto"
|
|
"github.com/gochat/gochat/pkg/response"
|
|
)
|
|
|
|
// --- Profile Handler Test Suite ---
|
|
// Uses real SQLite DB + real repos + real service.
|
|
|
|
type ProfileHandlerTestSuite struct {
|
|
suite.Suite
|
|
router *gin.Engine
|
|
handler *ProfileHandler
|
|
db *gorm.DB
|
|
mailer *fakeProfileConfirmationMailer
|
|
user *model.User
|
|
account *model.Account
|
|
refreshStore *auth.RefreshTokenStore
|
|
|
|
userID uint
|
|
accountID uint
|
|
}
|
|
|
|
func TestProfileHandlerSuite(t *testing.T) {
|
|
suite.Run(t, new(ProfileHandlerTestSuite))
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) SetupSuite() {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
// Create in-memory SQLite DB
|
|
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
s.Require().NoError(err)
|
|
|
|
// Migrate models needed for profile operations
|
|
s.Require().NoError(db.AutoMigrate(
|
|
&model.Account{},
|
|
&model.User{},
|
|
&model.AccountUser{},
|
|
&model.CustomRole{},
|
|
&model.AccessToken{},
|
|
&model.InstallationConfig{},
|
|
&model.UserSession{},
|
|
))
|
|
s.db = db
|
|
|
|
// Create test account
|
|
account := &model.Account{Name: "TestAccount", Status: "active", OnboardingStep: "profile"}
|
|
s.Require().NoError(db.Create(account).Error)
|
|
s.account = account
|
|
s.accountID = account.ID
|
|
|
|
// Create test user
|
|
passwordDigest, err := crypto.HashPassword("oldpassword")
|
|
s.Require().NoError(err)
|
|
user := &model.User{
|
|
Name: "ProfileUser",
|
|
Email: "profile@example.com",
|
|
AccountID: account.ID,
|
|
Password: passwordDigest,
|
|
PasswordDigest: passwordDigest,
|
|
Provider: "email",
|
|
DisplayName: "Profile Display",
|
|
MessageSignature: "Regards",
|
|
PubsubToken: "pubsub-profile-user",
|
|
}
|
|
s.Require().NoError(db.Create(user).Error)
|
|
s.user = user
|
|
s.userID = user.ID
|
|
|
|
// Link user to account
|
|
accountUser := &model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator", Availability: "offline", AutoOffline: true}
|
|
s.Require().NoError(db.Create(accountUser).Error)
|
|
s.Require().NoError(db.Create(&model.AccessToken{OwnerType: model.AccessTokenOwnerTypeUser, OwnerID: user.ID, Token: "profile-token-1", TokenPrefix: "profile-", Name: "Personal Access Token"}).Error)
|
|
|
|
// Create real repos + service
|
|
userRepo := repository.NewUserRepo(db)
|
|
accountUserRepo := repository.NewAccountUserRepo(db)
|
|
accessTokenRepo := repository.NewAccessTokenRepo(db)
|
|
installationConfigRepo := repository.NewInstallationConfigRepo(db)
|
|
refreshStore := auth.NewRefreshTokenStore(nil, &config.JWTConfig{RefreshExpiryHours: 24})
|
|
s.refreshStore = refreshStore
|
|
profileSvc := service.NewProfileService(userRepo, accountUserRepo, accessTokenRepo, installationConfigRepo, refreshStore)
|
|
uploadCfg := &config.Config{}
|
|
uploadCfg.Storage.LocalPath = s.T().TempDir()
|
|
uploadCfg.Storage.MaxFileSize = 20 << 20
|
|
uploadSvc := service.NewUploadService(nil, uploadCfg)
|
|
s.mailer = &fakeProfileConfirmationMailer{}
|
|
profileSvc.SetConfirmationMailer(s.mailer)
|
|
s.handler = NewProfileHandler(profileSvc, uploadSvc)
|
|
|
|
// Build router with profile routes and auth middleware
|
|
s.router = s.buildRouter()
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TearDownSuite() {
|
|
if s.db != nil {
|
|
sqlDB, _ := s.db.DB()
|
|
_ = sqlDB.Close()
|
|
}
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) buildRouter() *gin.Engine {
|
|
r := gin.New()
|
|
|
|
// Auth middleware that sets user_id in context
|
|
authMW := func(c *gin.Context) {
|
|
c.Set("user_id", s.userID)
|
|
c.Set("account_id", s.accountID)
|
|
c.Next()
|
|
}
|
|
|
|
profile := r.Group("/api/v1/profile")
|
|
profile.Use(authMW)
|
|
profile.GET("", s.handler.Get)
|
|
profile.PUT("", s.handler.Update)
|
|
profile.PUT("/avatar", s.handler.UpdateAvatar)
|
|
profile.DELETE("/avatar", s.handler.DeleteAvatar)
|
|
profile.POST("/availability", s.handler.SetAvailability)
|
|
profile.POST("/auto_offline", s.handler.SetAutoOffline)
|
|
profile.PUT("/set_active_account", s.handler.SetActiveAccount)
|
|
profile.POST("/resend_confirmation", s.handler.ResendConfirmation)
|
|
profile.POST("/reset_access_token", s.handler.ResetAccessToken)
|
|
profile.GET("/sessions", s.handler.ListSessions)
|
|
profile.DELETE("/sessions/:id", s.handler.RevokeSession)
|
|
|
|
return r
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) SetupTest() {
|
|
s.db.Where("user_id = ?", s.userID).Delete(&model.UserSession{})
|
|
// Reset user to original state before each test
|
|
s.db.Model(&model.User{}).Where("id = ?", s.userID).Updates(map[string]interface{}{
|
|
"name": "ProfileUser",
|
|
"email": "profile@example.com",
|
|
"password": s.user.PasswordDigest,
|
|
"password_digest": s.user.PasswordDigest,
|
|
"avatar_url": "",
|
|
"available": false,
|
|
"display_name": "Profile Display",
|
|
"message_signature": "Regards",
|
|
"pubsub_token": "pubsub-profile-user",
|
|
"confirmation_token": "",
|
|
"unconfirmed_email": "",
|
|
})
|
|
s.db.Model(&model.User{}).Where("id = ?", s.userID).UpdateColumns(map[string]interface{}{
|
|
"confirmed_at": nil,
|
|
"confirmation_sent_at": nil,
|
|
"reset_password_token": "",
|
|
"reset_password_sent_at": nil,
|
|
})
|
|
s.db.Model(&model.AccountUser{}).Where("account_id = ? AND user_id = ?", s.accountID, s.userID).UpdateColumns(map[string]interface{}{
|
|
"role": "administrator",
|
|
"custom_role_id": 0,
|
|
"availability": "offline",
|
|
"auto_offline": true,
|
|
"inviter_id": 0,
|
|
})
|
|
s.db.Unscoped().Where("account_id = ?", s.accountID).Delete(&model.CustomRole{})
|
|
s.db.Unscoped().Where("owner_type = ? AND owner_id = ?", model.AccessTokenOwnerTypeUser, s.userID).Delete(&model.AccessToken{})
|
|
s.db.Unscoped().Where("name = ?", "CHATWOOT_INBOX_HMAC_KEY").Delete(&model.InstallationConfig{})
|
|
s.Require().NoError(s.db.Create(&model.AccessToken{OwnerType: model.AccessTokenOwnerTypeUser, OwnerID: s.userID, Token: "profile-token-1", TokenPrefix: "profile-", Name: "Personal Access Token"}).Error)
|
|
s.mailer.Reset()
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestSessions_IndexAndDestroyMatchChatwootContract() {
|
|
now := time.Now().UTC()
|
|
current := &model.UserSession{UserID: s.userID, ClientID: "current-client", BrowserName: "Chrome", PlatformName: "Linux", LastActivityAt: &now}
|
|
other := &model.UserSession{UserID: s.userID, ClientID: "other-client", BrowserName: "Firefox", PlatformName: "Windows", LastActivityAt: &now}
|
|
s.Require().NoError(s.db.Create(current).Error)
|
|
s.Require().NoError(s.db.Create(other).Error)
|
|
s.Require().NoError(s.refreshStore.StoreForClient(context.Background(), s.userID, current.ClientID, "current-refresh"))
|
|
s.Require().NoError(s.refreshStore.StoreForClient(context.Background(), s.userID, other.ClientID, "other-refresh"))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/profile/sessions", nil)
|
|
req.Header.Set("client", "current-client")
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
s.Equal(http.StatusOK, w.Code, w.Body.String())
|
|
var payload []map[string]any
|
|
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
|
|
s.Require().Len(payload, 2)
|
|
for _, session := range payload {
|
|
for _, key := range []string{"id", "browser_name", "browser_version", "device_name", "platform_name", "platform_version", "ip_address", "city", "country", "country_code", "last_activity_at", "created_at", "current"} {
|
|
s.Contains(session, key)
|
|
}
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/v1/profile/sessions/%d", current.ID), nil)
|
|
req.Header.Set("client", "current-client")
|
|
w = httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
s.Equal(http.StatusUnprocessableEntity, w.Code)
|
|
req = httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/v1/profile/sessions/%d", other.ID), nil)
|
|
req.Header.Set("client", "current-client")
|
|
w = httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
s.Equal(http.StatusOK, w.Code)
|
|
var count int64
|
|
s.db.Model(&model.UserSession{}).Where("id = ?", other.ID).Count(&count)
|
|
s.Equal(int64(0), count)
|
|
active, err := s.refreshStore.HasClient(context.Background(), s.userID, other.ClientID)
|
|
s.Require().NoError(err)
|
|
s.False(active)
|
|
}
|
|
|
|
type fakeProfileConfirmationMailer struct {
|
|
calls []service.ProfileConfirmationMailRequest
|
|
}
|
|
|
|
func (m *fakeProfileConfirmationMailer) SendConfirmationInstructions(_ context.Context, req service.ProfileConfirmationMailRequest) error {
|
|
m.calls = append(m.calls, req)
|
|
return nil
|
|
}
|
|
|
|
func (m *fakeProfileConfirmationMailer) Reset() {
|
|
m.calls = nil
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) decodeProfileBody(w *httptest.ResponseRecorder) map[string]interface{} {
|
|
var payload map[string]interface{}
|
|
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
|
|
return payload
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) firstAccountFromProfile(payload map[string]interface{}) map[string]interface{} {
|
|
accounts, ok := payload["accounts"].([]interface{})
|
|
s.Require().True(ok)
|
|
s.Require().Len(accounts, 1)
|
|
account, ok := accounts[0].(map[string]interface{})
|
|
s.Require().True(ok)
|
|
return account
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) assertChatwootProfileUserFixture(payload map[string]interface{}, expectedAvailability string, expectedAutoOffline bool) map[string]interface{} {
|
|
s.T().Helper()
|
|
|
|
assert.Equal(s.T(), float64(s.userID), payload["id"])
|
|
assert.Equal(s.T(), "ProfileUser", payload["name"])
|
|
assert.Equal(s.T(), "profile@example.com", payload["email"])
|
|
assert.Equal(s.T(), "", payload["uid"])
|
|
assert.Equal(s.T(), "Profile Display", payload["available_name"])
|
|
assert.Equal(s.T(), "Profile Display", payload["display_name"])
|
|
assert.Equal(s.T(), "", payload["avatar_url"])
|
|
assert.Equal(s.T(), "User", payload["type"])
|
|
assert.Equal(s.T(), "email", payload["provider"])
|
|
assert.Equal(s.T(), "pubsub-profile-user", payload["pubsub_token"])
|
|
assert.Equal(s.T(), "Regards", payload["message_signature"])
|
|
assert.Equal(s.T(), "profile-token-1", payload["access_token"])
|
|
assert.Equal(s.T(), "administrator", payload["role"])
|
|
assert.Equal(s.T(), map[string]interface{}{}, payload["custom_attributes"])
|
|
assert.Equal(s.T(), map[string]interface{}{}, payload["ui_settings"])
|
|
assert.Equal(s.T(), false, payload["confirmed"])
|
|
assert.Equal(s.T(), float64(s.accountID), payload["account_id"])
|
|
assert.Nil(s.T(), payload["inviter_id"])
|
|
assert.NotContains(s.T(), payload, "hmac_identifier")
|
|
|
|
account := s.firstAccountFromProfile(payload)
|
|
assert.Equal(s.T(), float64(s.accountID), account["id"])
|
|
assert.Equal(s.T(), "TestAccount", account["name"])
|
|
assert.Equal(s.T(), "active", account["status"])
|
|
assert.Equal(s.T(), "profile", account["onboarding_step"])
|
|
assert.Equal(s.T(), "administrator", account["role"])
|
|
assert.Equal(s.T(), expectedAvailability, account["availability"])
|
|
assert.Equal(s.T(), expectedAvailability, account["availability_status"])
|
|
assert.Equal(s.T(), expectedAutoOffline, account["auto_offline"])
|
|
assert.Equal(s.T(), []interface{}{"administrator"}, account["permissions"])
|
|
assert.Nil(s.T(), account["custom_role_id"])
|
|
assert.Nil(s.T(), account["custom_role"])
|
|
return account
|
|
}
|
|
|
|
// ===================== Get Profile =====================
|
|
|
|
func (s *ProfileHandlerTestSuite) TestGet_Success() {
|
|
req, _ := http.NewRequest("GET", "/api/v1/profile", nil)
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
|
|
dataMap := s.decodeProfileBody(w)
|
|
s.assertChatwootProfileUserFixture(dataMap, "offline", true)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestGet_CustomRolePermissions() {
|
|
role := &model.CustomRole{AccountID: s.accountID, Name: "Support Lead"}
|
|
s.Require().NoError(role.SetPermissionKeys([]model.PermissionDimension{
|
|
model.DimensionConversationManage,
|
|
model.DimensionContactManage,
|
|
}))
|
|
s.Require().NoError(s.db.Create(role).Error)
|
|
s.Require().NoError(s.db.Model(&model.AccountUser{}).
|
|
Where("account_id = ? AND user_id = ?", s.accountID, s.userID).
|
|
Updates(map[string]interface{}{"role": "agent", "custom_role_id": role.ID}).Error)
|
|
|
|
req, _ := http.NewRequest("GET", "/api/v1/profile", nil)
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
payload := s.decodeProfileBody(w)
|
|
account := s.firstAccountFromProfile(payload)
|
|
assert.Equal(s.T(), "agent", account["role"])
|
|
assert.Equal(s.T(), []interface{}{"conversation_manage", "contact_manage", "custom_role"}, account["permissions"])
|
|
assert.Equal(s.T(), float64(role.ID), account["custom_role_id"])
|
|
customRole := account["custom_role"].(map[string]interface{})
|
|
assert.Equal(s.T(), "Support Lead", customRole["name"])
|
|
assert.Equal(s.T(), []interface{}{"conversation_manage", "contact_manage"}, customRole["permissions"])
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestGet_HMACIdentifierWhenConfigured() {
|
|
secret := "random_secret_key"
|
|
s.Require().NoError(s.db.Create(&model.InstallationConfig{Name: "CHATWOOT_INBOX_HMAC_KEY", Value: secret}).Error)
|
|
|
|
req, _ := http.NewRequest("GET", "/api/v1/profile", nil)
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
payload := s.decodeProfileBody(w)
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
_, _ = mac.Write([]byte("profile@example.com"))
|
|
expected := hex.EncodeToString(mac.Sum(nil))
|
|
assert.Equal(s.T(), expected, payload["hmac_identifier"])
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestGet_Unauthorized() {
|
|
// Create router without auth middleware — user_id will be 0
|
|
r := gin.New()
|
|
r.GET("/api/v1/profile", s.handler.Get)
|
|
|
|
req, _ := http.NewRequest("GET", "/api/v1/profile", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusUnauthorized, w.Code)
|
|
|
|
var resp response.APIResponse
|
|
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.False(s.T(), resp.Success)
|
|
assert.NotNil(s.T(), resp.Error)
|
|
assert.Equal(s.T(), response.ErrUnauthorized, resp.Error.Code)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestGet_UserNotFound() {
|
|
// Router that sets a non-existent user ID
|
|
r := gin.New()
|
|
r.GET("/api/v1/profile", func(c *gin.Context) {
|
|
c.Set("user_id", uint(9999)) // non-existent
|
|
c.Next()
|
|
}, s.handler.Get)
|
|
|
|
req, _ := http.NewRequest("GET", "/api/v1/profile", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusNotFound, w.Code)
|
|
|
|
var resp response.APIResponse
|
|
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.False(s.T(), resp.Success)
|
|
assert.NotNil(s.T(), resp.Error)
|
|
assert.Equal(s.T(), response.ErrNotFound, resp.Error.Code)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestGet_NilService() {
|
|
// Handler with nil service should panic or return 500
|
|
h := &ProfileHandler{svc: nil}
|
|
r := gin.New()
|
|
r.GET("/api/v1/profile", func(c *gin.Context) {
|
|
c.Set("user_id", uint(1))
|
|
c.Next()
|
|
}, h.Get)
|
|
|
|
req, _ := http.NewRequest("GET", "/api/v1/profile", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
// Expect panic from nil service call — recover and check
|
|
func() {
|
|
defer func() {
|
|
_ = recover()
|
|
}()
|
|
r.ServeHTTP(w, req)
|
|
}()
|
|
// If no panic, it would be 500; if panic, we caught it
|
|
// This test validates that nil svc is catastrophic
|
|
}
|
|
|
|
// ===================== Update Profile =====================
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdate_Success() {
|
|
body := map[string]interface{}{
|
|
"profile": map[string]interface{}{
|
|
"name": "UpdatedName",
|
|
"availability": "online",
|
|
},
|
|
}
|
|
b, _ := json.Marshal(body)
|
|
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
|
|
dataMap := s.decodeProfileBody(w)
|
|
assert.Equal(s.T(), "UpdatedName", dataMap["name"])
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdate_SuccessWithEmail() {
|
|
body := map[string]interface{}{
|
|
"profile": map[string]interface{}{
|
|
"email": "newemail@example.com",
|
|
},
|
|
}
|
|
b, _ := json.Marshal(body)
|
|
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
|
|
dataMap := s.decodeProfileBody(w)
|
|
assert.Equal(s.T(), "newemail@example.com", dataMap["email"])
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdate_SuccessWithAvailabilityOffline() {
|
|
// First set user available=true, then set availability=offline
|
|
s.db.Model(&model.User{}).Where("id = ?", s.userID).Update("available", true)
|
|
|
|
body := map[string]interface{}{
|
|
"profile": map[string]interface{}{
|
|
"availability": "offline",
|
|
},
|
|
}
|
|
b, _ := json.Marshal(body)
|
|
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdate_SuccessWithAvailabilityBusy() {
|
|
body := map[string]interface{}{
|
|
"profile": map[string]interface{}{
|
|
"availability": "busy",
|
|
},
|
|
}
|
|
b, _ := json.Marshal(body)
|
|
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdate_SuccessWithAvatarURL() {
|
|
body := map[string]interface{}{
|
|
"profile": map[string]interface{}{
|
|
"avatar_url": "https://example.com/avatar.png",
|
|
},
|
|
}
|
|
b, _ := json.Marshal(body)
|
|
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdate_SuccessEmptyBody() {
|
|
// Empty body binds as zero values — all fields are omitempty so validation passes
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader([]byte("{}")))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdate_Unauthorized() {
|
|
r := gin.New()
|
|
r.PUT("/api/v1/profile", s.handler.Update)
|
|
|
|
body, _ := json.Marshal(map[string]interface{}{"name": "Test"})
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusUnauthorized, w.Code)
|
|
|
|
var resp response.APIResponse
|
|
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.Equal(s.T(), response.ErrUnauthorized, resp.Error.Code)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdate_InvalidJSON() {
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader([]byte("{invalid json}")))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
|
|
var resp response.APIResponse
|
|
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.False(s.T(), resp.Success)
|
|
assert.Equal(s.T(), response.ErrBadRequest, resp.Error.Code)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdate_ValidationFail_InvalidAvailability() {
|
|
// Availability must be one of: online, offline, busy
|
|
body := map[string]interface{}{
|
|
"profile": map[string]interface{}{
|
|
"availability": "invalid_status",
|
|
},
|
|
}
|
|
b, _ := json.Marshal(body)
|
|
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
|
|
var resp response.APIResponse
|
|
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.False(s.T(), resp.Success)
|
|
assert.Equal(s.T(), response.ErrValidation, resp.Error.Code)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdate_ValidationFail_InvalidEmail() {
|
|
body := map[string]interface{}{
|
|
"profile": map[string]interface{}{
|
|
"email": "not-an-email",
|
|
},
|
|
}
|
|
b, _ := json.Marshal(body)
|
|
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
|
|
var resp response.APIResponse
|
|
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.False(s.T(), resp.Success)
|
|
assert.Equal(s.T(), response.ErrValidation, resp.Error.Code)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdate_ValidationFail_NameTooShort() {
|
|
// Name with min=2, sending "a" (1 char) should fail validation
|
|
body := map[string]interface{}{
|
|
"profile": map[string]interface{}{
|
|
"name": "a",
|
|
},
|
|
}
|
|
b, _ := json.Marshal(body)
|
|
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
|
|
var resp response.APIResponse
|
|
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.False(s.T(), resp.Success)
|
|
assert.Equal(s.T(), response.ErrValidation, resp.Error.Code)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdate_UserNotFound() {
|
|
r := gin.New()
|
|
r.PUT("/api/v1/profile", func(c *gin.Context) {
|
|
c.Set("user_id", uint(9999)) // non-existent
|
|
c.Next()
|
|
}, s.handler.Update)
|
|
|
|
body := map[string]interface{}{"profile": map[string]interface{}{"name": "TestName"}}
|
|
b, _ := json.Marshal(body)
|
|
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusNotFound, w.Code)
|
|
|
|
var resp response.APIResponse
|
|
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.Equal(s.T(), response.ErrNotFound, resp.Error.Code)
|
|
}
|
|
|
|
// ===================== Update Avatar =====================
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdateAvatar_Success() {
|
|
body := map[string]interface{}{
|
|
"avatar_url": "https://cdn.example.com/new-avatar.png",
|
|
}
|
|
b, _ := json.Marshal(body)
|
|
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile/avatar", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
|
|
dataMap := s.decodeProfileBody(w)
|
|
assert.Equal(s.T(), "https://cdn.example.com/new-avatar.png", dataMap["avatar_url"])
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdateAvatar_Unauthorized() {
|
|
r := gin.New()
|
|
r.PUT("/api/v1/profile/avatar", s.handler.UpdateAvatar)
|
|
|
|
body := map[string]interface{}{"avatar_url": "https://example.com/a.png"}
|
|
b, _ := json.Marshal(body)
|
|
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile/avatar", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusUnauthorized, w.Code)
|
|
|
|
var resp response.APIResponse
|
|
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.Equal(s.T(), response.ErrUnauthorized, resp.Error.Code)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdateAvatar_InvalidJSON() {
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile/avatar", bytes.NewReader([]byte("not json")))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
|
|
var resp response.APIResponse
|
|
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.Equal(s.T(), response.ErrBadRequest, resp.Error.Code)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdateAvatar_MissingAvatarURL() {
|
|
// UpdateAvatarRequest has validate:"required" on avatar_url
|
|
// NOTE: ShouldBindJSON does NOT trigger validate tags.
|
|
// Empty body binds as zero-value struct (AvatarURL=""), then service validation fails.
|
|
body := map[string]interface{}{}
|
|
b, _ := json.Marshal(body)
|
|
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile/avatar", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
// The service validates required and returns validation error,
|
|
// which handleServiceError maps to 400 validation error
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
|
|
var resp response.APIResponse
|
|
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.False(s.T(), resp.Success)
|
|
assert.Equal(s.T(), response.ErrValidation, resp.Error.Code)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdateAvatar_EmptyAvatarURL() {
|
|
// Explicitly sending avatar_url as empty string
|
|
body := map[string]interface{}{
|
|
"avatar_url": "",
|
|
}
|
|
b, _ := json.Marshal(body)
|
|
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile/avatar", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
// Service validation: required field fails for empty string
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
|
|
var resp response.APIResponse
|
|
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.False(s.T(), resp.Success)
|
|
assert.Equal(s.T(), response.ErrValidation, resp.Error.Code)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdateAvatar_UserNotFound() {
|
|
r := gin.New()
|
|
r.PUT("/api/v1/profile/avatar", func(c *gin.Context) {
|
|
c.Set("user_id", uint(9999)) // non-existent
|
|
c.Next()
|
|
}, s.handler.UpdateAvatar)
|
|
|
|
body := map[string]interface{}{"avatar_url": "https://example.com/a.png"}
|
|
b, _ := json.Marshal(body)
|
|
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile/avatar", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusNotFound, w.Code)
|
|
|
|
var resp response.APIResponse
|
|
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
|
|
assert.Equal(s.T(), response.ErrNotFound, resp.Error.Code)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdateAvatar_NilService() {
|
|
h := &ProfileHandler{svc: nil}
|
|
r := gin.New()
|
|
r.PUT("/api/v1/profile/avatar", func(c *gin.Context) {
|
|
c.Set("user_id", uint(1))
|
|
c.Next()
|
|
}, h.UpdateAvatar)
|
|
|
|
body := map[string]interface{}{"avatar_url": "https://example.com/a.png"}
|
|
b, _ := json.Marshal(body)
|
|
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile/avatar", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
func() {
|
|
defer func() {
|
|
_ = recover()
|
|
}()
|
|
r.ServeHTTP(w, req)
|
|
}()
|
|
// Nil service causes panic — validates catastrophic failure path
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestDeleteAvatar_ReturnsChatwootUserSerializer() {
|
|
s.Require().NoError(s.db.Model(&model.User{}).Where("id = ?", s.userID).Update("avatar_url", "https://cdn.example.com/current-avatar.png").Error)
|
|
|
|
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/profile/avatar", nil)
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
dataMap := s.decodeProfileBody(w)
|
|
assert.Equal(s.T(), "", dataMap["avatar_url"])
|
|
|
|
var user model.User
|
|
s.Require().NoError(s.db.First(&user, s.userID).Error)
|
|
assert.Equal(s.T(), "", user.AvatarURL)
|
|
}
|
|
|
|
// ===================== Chatwoot Profile Serializer Fixtures =====================
|
|
|
|
func (s *ProfileHandlerTestSuite) TestSetAvailability_ReturnsChatwootUserSerializer() {
|
|
body := map[string]interface{}{
|
|
"profile": map[string]interface{}{
|
|
"account_id": s.accountID,
|
|
"availability": "online",
|
|
},
|
|
}
|
|
b, _ := json.Marshal(body)
|
|
|
|
req, _ := http.NewRequest("POST", "/api/v1/profile/availability", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
payload := s.decodeProfileBody(w)
|
|
s.assertChatwootProfileUserFixture(payload, "online", true)
|
|
account := s.firstAccountFromProfile(payload)
|
|
assert.Equal(s.T(), "online", account["availability"])
|
|
assert.Equal(s.T(), "online", account["availability_status"])
|
|
|
|
var accountUser model.AccountUser
|
|
s.Require().NoError(s.db.Where("account_id = ? AND user_id = ?", s.accountID, s.userID).First(&accountUser).Error)
|
|
assert.Equal(s.T(), "online", accountUser.Availability)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestSetAutoOffline_ReturnsChatwootUserSerializer() {
|
|
body := map[string]interface{}{
|
|
"profile": map[string]interface{}{
|
|
"account_id": s.accountID,
|
|
"auto_offline": false,
|
|
},
|
|
}
|
|
b, _ := json.Marshal(body)
|
|
|
|
req, _ := http.NewRequest("POST", "/api/v1/profile/auto_offline", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
payload := s.decodeProfileBody(w)
|
|
s.assertChatwootProfileUserFixture(payload, "offline", false)
|
|
account := s.firstAccountFromProfile(payload)
|
|
assert.Equal(s.T(), false, account["auto_offline"])
|
|
|
|
var accountUser model.AccountUser
|
|
s.Require().NoError(s.db.Where("account_id = ? AND user_id = ?", s.accountID, s.userID).First(&accountUser).Error)
|
|
assert.False(s.T(), accountUser.AutoOffline)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestSetActiveAccount_UpdatesMembershipActiveAt() {
|
|
var before model.AccountUser
|
|
s.Require().NoError(s.db.Where("account_id = ? AND user_id = ?", s.accountID, s.userID).First(&before).Error)
|
|
s.Require().Nil(before.ActiveAt)
|
|
|
|
body := map[string]interface{}{
|
|
"profile": map[string]interface{}{
|
|
"account_id": s.accountID,
|
|
},
|
|
}
|
|
b, _ := json.Marshal(body)
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile/set_active_account", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusNoContent, w.Code)
|
|
assert.Empty(s.T(), w.Body.String())
|
|
|
|
var after model.AccountUser
|
|
s.Require().NoError(s.db.Where("account_id = ? AND user_id = ?", s.accountID, s.userID).First(&after).Error)
|
|
s.Require().NotNil(after.ActiveAt)
|
|
assert.WithinDuration(s.T(), time.Now(), *after.ActiveAt, 5*time.Second)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestResetAccessToken_RegeneratesTokenInChatwootUserSerializer() {
|
|
req, _ := http.NewRequest("POST", "/api/v1/profile/reset_access_token", nil)
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
payload := s.decodeProfileBody(w)
|
|
token, ok := payload["access_token"].(string)
|
|
assert.True(s.T(), ok)
|
|
assert.NotEmpty(s.T(), token)
|
|
assert.NotEqual(s.T(), "profile-token-1", token)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestResendConfirmation_DoesNotSendForConfirmedUser() {
|
|
now := time.Now().UTC()
|
|
s.Require().NoError(s.db.Model(&model.User{}).Where("id = ?", s.userID).Update("confirmed_at", now).Error)
|
|
|
|
req, _ := http.NewRequest("POST", "/api/v1/profile/resend_confirmation", nil)
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusNoContent, w.Code)
|
|
assert.Empty(s.T(), s.mailer.calls)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestResendConfirmation_SendsConfirmationInstructions() {
|
|
req, _ := http.NewRequest("POST", "/api/v1/profile/resend_confirmation", nil)
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusNoContent, w.Code)
|
|
s.Require().Len(s.mailer.calls, 1)
|
|
mail := s.mailer.calls[0]
|
|
assert.Equal(s.T(), "confirmation", mail.Kind)
|
|
assert.Equal(s.T(), "profile@example.com", mail.ToEmail)
|
|
assert.Equal(s.T(), "Confirm your email to get started", mail.Heading)
|
|
assert.Equal(s.T(), "Confirm my account", mail.ActionText)
|
|
assert.Contains(s.T(), mail.ActionURL, "/app/auth/confirmation?confirmation_token=")
|
|
assert.NotEmpty(s.T(), mail.ConfirmationToken)
|
|
assert.Empty(s.T(), mail.ResetPasswordToken)
|
|
|
|
var user model.User
|
|
s.Require().NoError(s.db.First(&user, s.userID).Error)
|
|
assert.Equal(s.T(), mail.ConfirmationToken, user.ConfirmationToken)
|
|
assert.NotNil(s.T(), user.ConfirmationSentAt)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestResendConfirmation_SendsWorkspaceInvitationForInvitedUser() {
|
|
inviter := &model.User{Name: "Inviter Admin", Email: "inviter-profile@example.com", Provider: "email", Active: true}
|
|
s.Require().NoError(s.db.Create(inviter).Error)
|
|
s.Require().NoError(s.db.Model(&model.AccountUser{}).
|
|
Where("account_id = ? AND user_id = ?", s.accountID, s.userID).
|
|
Update("inviter_id", inviter.ID).Error)
|
|
|
|
req, _ := http.NewRequest("POST", "/api/v1/profile/resend_confirmation", nil)
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusNoContent, w.Code)
|
|
s.Require().Len(s.mailer.calls, 1)
|
|
mail := s.mailer.calls[0]
|
|
assert.Equal(s.T(), "invitation", mail.Kind)
|
|
assert.Equal(s.T(), "Workspace invitation", mail.Eyebrow)
|
|
assert.Equal(s.T(), "You're invited to join TestAccount", mail.Heading)
|
|
assert.Equal(s.T(), "Inviter Admin invited you to join the TestAccount workspace on Chatwoot.", mail.IntroText)
|
|
assert.Equal(s.T(), "Accept invitation", mail.ActionText)
|
|
assert.Contains(s.T(), mail.ActionURL, "/app/auth/password/edit?reset_password_token=")
|
|
assert.Empty(s.T(), mail.ConfirmationToken)
|
|
assert.NotEmpty(s.T(), mail.ResetPasswordToken)
|
|
|
|
var user model.User
|
|
s.Require().NoError(s.db.First(&user, s.userID).Error)
|
|
assert.NotEmpty(s.T(), user.ResetPasswordToken)
|
|
assert.NotEqual(s.T(), mail.ResetPasswordToken, user.ResetPasswordToken)
|
|
assert.NotNil(s.T(), user.ResetPasswordSentAt)
|
|
}
|
|
|
|
// ===================== Edge Cases =====================
|
|
|
|
func (s *ProfileHandlerTestSuite) TestNewProfileHandler() {
|
|
svc := service.NewProfileService(repository.NewUserRepo(s.db), repository.NewAccountUserRepo(s.db))
|
|
h := NewProfileHandler(svc)
|
|
assert.NotNil(s.T(), h)
|
|
assert.NotNil(s.T(), h.svc)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestGet_ViaHeaderUserID() {
|
|
// getUserID also checks X-User-ID header as fallback
|
|
r := gin.New()
|
|
r.GET("/api/v1/profile", s.handler.Get)
|
|
|
|
req, _ := http.NewRequest("GET", "/api/v1/profile", nil)
|
|
req.Header.Set("X-User-ID", strconvFormatUint(s.userID))
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
|
|
dataMap := s.decodeProfileBody(w)
|
|
assert.Equal(s.T(), "ProfileUser", dataMap["name"])
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestGet_ZeroUserIDViaHeader() {
|
|
// X-User-ID set to 0 should still result in unauthorized
|
|
r := gin.New()
|
|
r.GET("/api/v1/profile", s.handler.Get)
|
|
|
|
req, _ := http.NewRequest("GET", "/api/v1/profile", nil)
|
|
req.Header.Set("X-User-ID", "0")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusUnauthorized, w.Code)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdate_NoContentTypeHeader() {
|
|
// Sending JSON body without Content-Type — ShouldBindJSON may still parse it
|
|
// in Gin test mode depending on version. Verify the behavior.
|
|
body, _ := json.Marshal(map[string]interface{}{"profile": map[string]interface{}{"name": "TestName"}})
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader(body))
|
|
// No Content-Type header set
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
// Gin ShouldBindJSON requires Content-Type application/json — without it,
|
|
// binding fails and the handler returns 400. But some Gin versions auto-detect.
|
|
// Accept either 400 (binding fail) or 200 (if Gin auto-binds):
|
|
code := w.Code
|
|
assert.True(s.T(), code == http.StatusBadRequest || code == http.StatusOK,
|
|
"expected either 400 (binding fail) or 200 (auto-bind), got %d", code)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdate_MultipleFieldsAtOnce() {
|
|
body := map[string]interface{}{
|
|
"profile": map[string]interface{}{
|
|
"name": "MultiUpdate",
|
|
"email": "multi@example.com",
|
|
"avatar_url": "https://example.com/multi.png",
|
|
"availability": "online",
|
|
},
|
|
}
|
|
b, _ := json.Marshal(body)
|
|
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
|
|
dataMap := s.decodeProfileBody(w)
|
|
assert.Equal(s.T(), "MultiUpdate", dataMap["name"])
|
|
assert.Equal(s.T(), "multi@example.com", dataMap["email"])
|
|
assert.Equal(s.T(), "https://example.com/multi.png", dataMap["avatar_url"])
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdate_SettingsJSONParity() {
|
|
body := map[string]interface{}{
|
|
"profile": map[string]interface{}{
|
|
"display_name": "Display Changed",
|
|
"message_signature": "Kind regards",
|
|
"phone_number": "+15551234567",
|
|
"ui_settings": map[string]interface{}{
|
|
"editor_message_key": "cmd_enter",
|
|
"conversation_display_type": "expanded",
|
|
},
|
|
},
|
|
}
|
|
b, _ := json.Marshal(body)
|
|
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
payload := s.decodeProfileBody(w)
|
|
assert.Equal(s.T(), "Display Changed", payload["display_name"])
|
|
assert.Equal(s.T(), "Display Changed", payload["available_name"])
|
|
assert.Equal(s.T(), "Kind regards", payload["message_signature"])
|
|
uiSettings := payload["ui_settings"].(map[string]interface{})
|
|
assert.Equal(s.T(), "cmd_enter", uiSettings["editor_message_key"])
|
|
customAttrs := payload["custom_attributes"].(map[string]interface{})
|
|
assert.Equal(s.T(), "+15551234567", customAttrs["phone_number"])
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdate_PasswordJSONParity() {
|
|
body := map[string]interface{}{
|
|
"profile": map[string]interface{}{
|
|
"current_password": "oldpassword",
|
|
"password": "newpassword",
|
|
"password_confirmation": "newpassword",
|
|
},
|
|
}
|
|
b, _ := json.Marshal(body)
|
|
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
var user model.User
|
|
s.Require().NoError(s.db.First(&user, s.userID).Error)
|
|
assert.True(s.T(), crypto.CheckPassword("newpassword", user.PasswordDigest))
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdate_PasswordInvalidCurrentPassword() {
|
|
body := map[string]interface{}{
|
|
"profile": map[string]interface{}{
|
|
"current_password": "wrongpassword",
|
|
"password": "newpassword",
|
|
"password_confirmation": "newpassword",
|
|
},
|
|
}
|
|
b, _ := json.Marshal(body)
|
|
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdate_MultipartFormProfileParity() {
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
s.Require().NoError(writer.WriteField("profile[name]", "Multipart User"))
|
|
s.Require().NoError(writer.WriteField("profile[display_name]", "Multipart Display"))
|
|
s.Require().NoError(writer.WriteField("profile[message_signature]", "Sent from multipart"))
|
|
s.Require().NoError(writer.WriteField("profile[ui_settings][editor_message_key]", "enter"))
|
|
fileWriter, err := writer.CreateFormFile("profile[avatar]", "avatar.png")
|
|
s.Require().NoError(err)
|
|
_, err = fileWriter.Write(handlerTestPNG)
|
|
s.Require().NoError(err)
|
|
s.Require().NoError(writer.Close())
|
|
|
|
req, _ := http.NewRequest("PUT", "/api/v1/profile", &body)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
payload := s.decodeProfileBody(w)
|
|
assert.Equal(s.T(), "Multipart User", payload["name"])
|
|
assert.Equal(s.T(), "Multipart Display", payload["display_name"])
|
|
assert.Equal(s.T(), "Sent from multipart", payload["message_signature"])
|
|
avatarURL := payload["avatar_url"].(string)
|
|
assert.Contains(s.T(), avatarURL, "/uploads/account/")
|
|
assert.Contains(s.T(), avatarURL, ".png")
|
|
|
|
var persisted model.User
|
|
s.Require().NoError(s.db.First(&persisted, s.userID).Error)
|
|
assert.Equal(s.T(), "Multipart Display", persisted.DisplayName)
|
|
assert.Equal(s.T(), avatarURL, persisted.AvatarURL)
|
|
uiSettings := payload["ui_settings"].(map[string]interface{})
|
|
assert.Equal(s.T(), "enter", uiSettings["editor_message_key"])
|
|
}
|
|
|
|
func (s *ProfileHandlerTestSuite) TestUpdate_SoftDeletedUser() {
|
|
// Soft-delete user, then try to get/update profile
|
|
s.db.Delete(&model.User{}, s.userID)
|
|
|
|
// Get should return not found
|
|
req, _ := http.NewRequest("GET", "/api/v1/profile", nil)
|
|
w := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusNotFound, w.Code)
|
|
|
|
// Update should return not found
|
|
body, _ := json.Marshal(map[string]interface{}{"name": "AfterDelete"})
|
|
req2, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader(body))
|
|
req2.Header.Set("Content-Type", "application/json")
|
|
w2 := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w2, req2)
|
|
|
|
assert.Equal(s.T(), http.StatusNotFound, w.Code)
|
|
|
|
// Restore user for subsequent tests
|
|
s.db.Model(&model.User{}).Where("id = ?", s.userID).Unscoped().Update("deleted_at", nil)
|
|
}
|
|
|
|
// strconvFormatUint helper for X-User-ID header tests
|
|
func strconvFormatUint(n uint) string {
|
|
return fmt.Sprintf("%d", n)
|
|
}
|