This commit is contained in:
2026-07-12 12:20:23 +08:00
parent 7f2d5579ff
commit f923791d39
252 changed files with 9872 additions and 647 deletions
@@ -453,9 +453,9 @@ func serializeAccountSettings(account *model.Account) map[string]any {
"auto_resolve_after": account.AutoResolveDuration,
"auto_resolve_duration": account.AutoResolveDuration,
"auto_resolve_message": "",
"auto_resolve_ignore_waiting": false,
"auto_resolve_ignore_waiting": account.AutoResolveIgnoreWaiting,
"audio_transcriptions": account.AudioTranscriptions,
"auto_resolve_label": "",
"auto_resolve_label": account.AutoResolveLabel,
"reporting_timezone": account.ReportingTimezone,
}
return settings
@@ -343,6 +343,41 @@ func (s *AccountHandlerTestSuite) TestPatchUpdate_AudioTranscriptions() {
assert.False(s.T(), stored.AudioTranscriptions)
}
func (s *AccountHandlerTestSuite) TestPatchUpdate_AutoResolvePreferences() {
acc := s.seedAccount("Auto Resolve Preferences")
body := `{"auto_resolve_after":15,"auto_resolve_ignore_waiting":true,"auto_resolve_label":"vip"}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("PATCH", "/api/v1/accounts/"+strconv.FormatUint(uint64(acc.ID), 10), bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
resp := s.unmarshalResponse(w)
settings := resp["settings"].(map[string]interface{})
assert.Equal(s.T(), float64(15), settings["auto_resolve_after"])
assert.Equal(s.T(), true, settings["auto_resolve_ignore_waiting"])
assert.Equal(s.T(), "vip", settings["auto_resolve_label"])
var stored model.Account
require.NoError(s.T(), s.db.First(&stored, acc.ID).Error)
assert.Equal(s.T(), 15, stored.AutoResolveDuration)
assert.True(s.T(), stored.AutoResolveIgnoreWaiting)
assert.Equal(s.T(), "vip", stored.AutoResolveLabel)
body = `{"auto_resolve_after":0,"auto_resolve_ignore_waiting":false,"auto_resolve_label":""}`
w = httptest.NewRecorder()
req, _ = http.NewRequest("PATCH", "/api/v1/accounts/"+strconv.FormatUint(uint64(acc.ID), 10), bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
require.NoError(s.T(), s.db.First(&stored, acc.ID).Error)
assert.Zero(s.T(), stored.AutoResolveDuration)
assert.False(s.T(), stored.AutoResolveIgnoreWaiting)
assert.Empty(s.T(), stored.AutoResolveLabel)
}
func (s *AccountHandlerTestSuite) TestPatchUpdate_ChatwootGeneralSettingsPayload() {
acc := s.seedAccount("Test Account")
@@ -120,6 +120,7 @@ func (h *AgentHandler) Create(c *gin.Context) {
return
}
c.Header("Cache-Control", "no-store")
c.JSON(http.StatusOK, serializeAgentDetail(agent, accountID))
}
@@ -188,6 +189,30 @@ func (h *AgentHandler) Delete(c *gin.Context) {
c.Status(http.StatusOK)
}
// ResetPassword generates a new temporary password for an email agent.
// POST /api/v1/accounts/:account_id/agents/:agent_id/reset_password
func (h *AgentHandler) ResetPassword(c *gin.Context) {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified")
return
}
id, err := strconv.ParseUint(c.Param("agent_id"), 10, 32)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid agent id")
return
}
temporaryPassword, svcErr := h.svc.ResetPassword(c.Request.Context(), uint(id), accountID)
if svcErr != nil {
applogger.L().Errorf("Reset password for agent %d in account %d: %v", id, accountID, svcErr)
handleServiceError(c, svcErr)
return
}
c.Header("Cache-Control", "no-store")
c.JSON(http.StatusOK, gin.H{"temporary_password": temporaryPassword})
}
// BulkCreate adds multiple agents to the account by email.
// POST /api/v1/accounts/:account_id/agents/bulk_create
// Reference: Chatwoot agents_controller.rb#bulk_create
@@ -222,14 +247,15 @@ func (h *AgentHandler) BulkCreate(c *gin.Context) {
return
}
_, svcErr := h.svc.BulkCreate(c.Request.Context(), accountID, userID, req)
agents, svcErr := h.svc.BulkCreate(c.Request.Context(), accountID, userID, req)
if svcErr != nil {
applogger.L().Errorf("BulkCreate agents for account %d: %v", accountID, svcErr)
handleServiceError(c, svcErr)
return
}
c.Status(http.StatusOK)
c.Header("Cache-Control", "no-store")
c.JSON(http.StatusOK, serializeAgentDetails(agents, accountID))
}
func serializeAgentDetails(agents []repository.AgentDetail, accountID uint) []map[string]any {
@@ -244,7 +270,11 @@ func serializeAgentDetail(agent *repository.AgentDetail, accountID uint) map[str
if agent == nil {
return map[string]any{}
}
return serializeAgentUser(&agent.User, accountID, agent.Role, agent.Availability, agent.AutoOffline, agent.CustomRoleID)
payload := serializeAgentUser(&agent.User, accountID, agent.Role, agent.Availability, agent.AutoOffline, agent.CustomRoleID)
if agent.TemporaryPassword != "" {
payload["temporary_password"] = agent.TemporaryPassword
}
return payload
}
func serializeAgentUser(user *model.User, accountID uint, role string, availability string, autoOffline bool, customRoleID uint) map[string]any {
@@ -13,6 +13,7 @@ import (
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
pkgcrypto "github.com/gochat/gochat/pkg/crypto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"gorm.io/driver/sqlite"
@@ -24,7 +25,6 @@ type AgentHandlerTestSuite struct {
suite.Suite
db *gorm.DB
handler *AgentHandler
mailer *fakeProfileConfirmationMailer
account *model.Account
user *model.User
}
@@ -40,8 +40,6 @@ func (s *AgentHandlerTestSuite) SetupSuite() {
agentRepo := repository.NewAgentRepo(db)
svc := service.NewAgentService(agentRepo, db)
s.mailer = &fakeProfileConfirmationMailer{}
svc.SetConfirmationMailer(s.mailer)
s.handler = NewAgentHandler(svc)
s.account = &model.Account{Name: "test-agent-account"}
@@ -61,7 +59,6 @@ func (s *AgentHandlerTestSuite) SetupTest() {
// Don't delete users — we need the inviter user to persist
// Only delete agent users (not the inviter)
s.db.Exec("DELETE FROM users WHERE id != ?", s.user.ID)
s.mailer.Reset()
}
func (s *AgentHandlerTestSuite) TearDownSuite() {
@@ -186,10 +183,20 @@ func (s *AgentHandlerTestSuite) TestCreateAgent() {
assert.NotContains(s.T(), data, "invited_by")
assert.NotContains(s.T(), data, "inviter_id")
assert.NotContains(s.T(), data, "account_user_id")
temporaryPassword, ok := data["temporary_password"].(string)
s.Require().True(ok)
assert.Len(s.T(), temporaryPassword, 16)
assert.Equal(s.T(), "no-store", w.Header().Get("Cache-Control"))
var membership model.AccountUser
s.Require().NoError(s.db.Where("account_id = ? AND user_id = ?", s.account.ID, uint(data["id"].(float64))).First(&membership).Error)
assert.Equal(s.T(), s.user.ID, membership.InvitedBy)
var createdUser model.User
s.Require().NoError(s.db.First(&createdUser, uint(data["id"].(float64))).Error)
assert.NotNil(s.T(), createdUser.ConfirmedAt)
assert.True(s.T(), pkgcrypto.CheckPassword(temporaryPassword, createdUser.PasswordDigest))
assert.True(s.T(), pkgcrypto.CheckPassword(temporaryPassword, createdUser.Password))
}
func (s *AgentHandlerTestSuite) TestCreateAgentChatwootFrontendPayload() {
@@ -218,27 +225,63 @@ func (s *AgentHandlerTestSuite) TestCreateAgentChatwootFrontendPayload() {
assert.NotContains(s.T(), data, "data")
}
func (s *AgentHandlerTestSuite) TestCreateAgentSendsWorkspaceInvitation() {
req := service.CreateAgentRequest{Email: "invite-mail@test.com", Name: "Invite Mail", Role: "agent"}
func (s *AgentHandlerTestSuite) TestCreateAgentDoesNotRequireInvitationEmail() {
req := service.CreateAgentRequest{Email: "direct-login@test.com", Name: "Direct Login", Role: "agent"}
w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID)
s.handler.Create(c)
assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String())
s.Require().Len(s.mailer.calls, 1)
mail := s.mailer.calls[0]
assert.Equal(s.T(), "invitation", mail.Kind)
assert.Equal(s.T(), "invite-mail@test.com", mail.ToEmail)
assert.Equal(s.T(), "You're invited to join test-agent-account", mail.Heading)
assert.Equal(s.T(), "Inviter Admin invited you to join the test-agent-account 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.NotEmpty(s.T(), mail.ResetPasswordToken)
var data map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &data))
temporaryPassword, ok := data["temporary_password"].(string)
s.Require().True(ok)
assert.Regexp(s.T(), `[A-Z]`, temporaryPassword)
assert.Regexp(s.T(), `[a-z]`, temporaryPassword)
assert.Regexp(s.T(), `[0-9]`, temporaryPassword)
assert.Regexp(s.T(), `[!@#$%]`, temporaryPassword)
var invited model.User
s.Require().NoError(s.db.Where("email = ?", "invite-mail@test.com").First(&invited).Error)
assert.NotEmpty(s.T(), invited.ResetPasswordToken)
assert.NotEqual(s.T(), mail.ResetPasswordToken, invited.ResetPasswordToken)
assert.NotNil(s.T(), invited.ResetPasswordSentAt)
var created model.User
s.Require().NoError(s.db.Where("email = ?", "direct-login@test.com").First(&created).Error)
assert.Empty(s.T(), created.ResetPasswordToken)
assert.Nil(s.T(), created.ResetPasswordSentAt)
assert.NotNil(s.T(), created.ConfirmedAt)
assert.True(s.T(), pkgcrypto.CheckPassword(temporaryPassword, created.PasswordDigest))
}
func (s *AgentHandlerTestSuite) TestResetPasswordReturnsNewTemporaryPassword() {
req := service.CreateAgentRequest{Email: "reset-agent@test.com", Name: "Reset Agent", Role: "agent"}
createResponse, createContext := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID)
s.handler.Create(createContext)
s.Require().Equal(http.StatusOK, createResponse.Code)
var created map[string]interface{}
s.Require().NoError(json.Unmarshal(createResponse.Body.Bytes(), &created))
agentID := uint(created["id"].(float64))
initialPassword := created["temporary_password"].(string)
w, c := s.makeRequest("POST", fmt.Sprintf("/api/v1/accounts/1/agents/%d/reset_password", agentID), nil, s.account.ID, s.user.ID)
s.handler.ResetPassword(c)
assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String())
assert.Equal(s.T(), "no-store", w.Header().Get("Cache-Control"))
var data map[string]string
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &data))
newPassword := data["temporary_password"]
assert.Len(s.T(), newPassword, 16)
assert.NotEqual(s.T(), initialPassword, newPassword)
var user model.User
s.Require().NoError(s.db.First(&user, agentID).Error)
assert.True(s.T(), pkgcrypto.CheckPassword(newPassword, user.PasswordDigest))
assert.False(s.T(), pkgcrypto.CheckPassword(initialPassword, user.PasswordDigest))
}
func (s *AgentHandlerTestSuite) TestResetPasswordIsScopedToAccount() {
otherAccount := &model.Account{Name: "other-reset-account"}
s.Require().NoError(s.db.Create(otherAccount).Error)
w, c := s.makeRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/agents/%d/reset_password", otherAccount.ID, s.user.ID), nil, otherAccount.ID, s.user.ID)
s.handler.ResetPassword(c)
assert.Equal(s.T(), http.StatusNotFound, w.Code, w.Body.String())
}
func (s *AgentHandlerTestSuite) TestCreateAgentDefaultsBlankNameFromEmail() {
@@ -467,7 +510,12 @@ func (s *AgentHandlerTestSuite) TestBulkCreate() {
s.handler.BulkCreate(c)
assert.Equal(s.T(), http.StatusOK, w.Code)
assert.Empty(s.T(), w.Body.String())
var data []map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &data))
s.Require().Len(data, 3)
for _, agent := range data {
assert.NotEmpty(s.T(), agent["temporary_password"])
}
}
func (s *AgentHandlerTestSuite) TestBulkCreateValidation() {
@@ -477,7 +525,7 @@ func (s *AgentHandlerTestSuite) TestBulkCreateValidation() {
w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents/bulk_create", req, s.account.ID, s.user.ID)
s.handler.BulkCreate(c)
assert.Equal(s.T(), http.StatusOK, w.Code)
assert.Empty(s.T(), w.Body.String())
assert.JSONEq(s.T(), `[]`, w.Body.String())
}
func (s *AgentHandlerTestSuite) TestBulkCreateSkipsInvalidEmailsAndClearsOnboardingStep() {
@@ -489,7 +537,10 @@ func (s *AgentHandlerTestSuite) TestBulkCreateSkipsInvalidEmailsAndClearsOnboard
s.handler.BulkCreate(c)
assert.Equal(s.T(), http.StatusOK, w.Code)
assert.Empty(s.T(), w.Body.String())
var data []map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &data))
s.Require().Len(data, 1)
assert.NotEmpty(s.T(), data[0]["temporary_password"])
var validUser model.User
s.Require().NoError(s.db.Where("email = ?", "valid-bulk@test.com").First(&validUser).Error)
@@ -521,7 +572,11 @@ func (s *AgentHandlerTestSuite) TestBulkCreateSkipsDuplicates() {
s.handler.BulkCreate(c2)
assert.Equal(s.T(), http.StatusOK, w2.Code)
assert.Empty(s.T(), w2.Body.String())
var data []map[string]interface{}
s.Require().NoError(json.Unmarshal(w2.Body.Bytes(), &data))
s.Require().Len(data, 1)
assert.Equal(s.T(), "new@test.com", data[0]["email"])
assert.NotEmpty(s.T(), data[0]["temporary_password"])
}
func (s *AgentHandlerTestSuite) TestListAfterCreate() {
@@ -755,7 +755,7 @@ func (h *ArticleHandler) articleSearchParams(c *gin.Context, portalID uint, offs
Query: c.Query("query"),
CategorySlug: c.Query("category_slug"),
Locale: c.Query("locale"),
Status: c.Query("status"),
Status: normalizeArticleStatusFilter(c.Query("status")),
SortBy: c.Query("sort_by"),
Offset: offset,
Limit: limit,
@@ -775,6 +775,19 @@ func (h *ArticleHandler) articleSearchParams(c *gin.Context, portalID uint, offs
return params, true
}
func normalizeArticleStatusFilter(status string) string {
switch strings.TrimSpace(status) {
case "0":
return string(model.ArticleStatusDraft)
case "1":
return string(model.ArticleStatusPublished)
case "2":
return string(model.ArticleStatusArchived)
default:
return status
}
}
func articleIDParam(c *gin.Context) (uint, error) {
if id, err := parseUintAnyParam(c, "article_id", "id"); err == nil && id != 0 {
return id, nil
@@ -698,6 +698,63 @@ func (s *ArticleHandlerTestSuite) TestPatch_RawPayloadClearsDescription() {
assert.Equal(s.T(), "", payload["description"])
}
func (s *ArticleHandlerTestSuite) TestPatch_AcceptsChatwootNumericStatus() {
article := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "numeric-status-article", Slug: "numeric-status-article", Status: "draft"}
s.Require().NoError(s.db.Create(article).Error)
r := gin.New()
r.PATCH("/api/v1/accounts/:account_id/portals/:portal_id/articles/:article_id", s.handler.Update)
w := httptest.NewRecorder()
req, _ := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles/%d", s.account.ID, article.ID), bytes.NewBufferString(`{"status":1}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
payload := resp["payload"].(map[string]interface{})
assert.Equal(s.T(), "published", payload["status"])
var updated model.Article
s.Require().NoError(s.db.First(&updated, article.ID).Error)
assert.Equal(s.T(), "published", updated.Status)
}
func (s *ArticleHandlerTestSuite) TestList_AcceptsChatwootNumericStatusFilters() {
for index, status := range []model.ArticleStatus{
model.ArticleStatusDraft,
model.ArticleStatusPublished,
model.ArticleStatusArchived,
} {
article := &model.Article{
AccountID: s.account.ID,
PortalID: s.portal.ID,
Title: fmt.Sprintf("numeric-filter-%d", index),
Slug: fmt.Sprintf("numeric-filter-%d", index),
Status: string(status),
Locale: "en",
}
s.Require().NoError(s.db.Create(article).Error)
}
r := gin.New()
r.GET("/api/v1/accounts/:account_id/portals/:portal_id/articles", s.handler.List)
for statusCode, expectedStatus := range []string{"draft", "published", "archived"} {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/portals/test-portal/articles?locale=en&status=%d", s.account.ID, statusCode), nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
payload := resp["payload"].([]interface{})
s.Require().Len(payload, 1)
assert.Equal(s.T(), expectedStatus, payload[0].(map[string]interface{})["status"])
}
}
func (s *ArticleHandlerTestSuite) TestDelete_ReturnsEmptyOK() {
article := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "delete-article", Slug: "delete-article", Status: "draft"}
s.Require().NoError(s.db.Create(article).Error)
@@ -135,7 +135,7 @@ func (s *AssignmentPolicyHandlerTestSuite) TestGetAccountPolicy_Success() {
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(s.T(), "Default", resp["name"])
assert.Equal(s.T(), float64(1), resp["conversation_priority"])
assert.Equal(s.T(), "longest_waiting", resp["conversation_priority"])
assert.Equal(s.T(), true, resp["enabled"])
}
@@ -172,7 +172,7 @@ func (s *AssignmentPolicyHandlerTestSuite) TestGetAccountPolicy_Unauthorized() {
// ==================== CreateAccountPolicy ====================
func (s *AssignmentPolicyHandlerTestSuite) TestCreateAccountPolicy_Success() {
body := `{"name":"Default","assignment_order":0,"conversation_priority":1,"fair_distribution_limit":5,"fair_distribution_window":300,"enabled":true}`
body := `{"name":"Default","assignment_order":"round_robin","conversation_priority":"longest_waiting","fair_distribution_limit":5,"fair_distribution_window":300,"enabled":true}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/assignment_policy", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
@@ -183,10 +183,29 @@ func (s *AssignmentPolicyHandlerTestSuite) TestCreateAccountPolicy_Success() {
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(s.T(), "Default", resp["name"])
assert.Equal(s.T(), "round_robin", resp["assignment_order"])
assert.Equal(s.T(), "longest_waiting", resp["conversation_priority"])
assert.Equal(s.T(), float64(5), resp["fair_distribution_limit"])
assert.Equal(s.T(), float64(300), resp["fair_distribution_window"])
}
func (s *AssignmentPolicyHandlerTestSuite) TestCreateAccountPolicy_AcceptsFrontendEnumPayload() {
body := `{"name":"test","description":"123123","enabled":true,"assignment_order":"balanced","conversation_priority":"earliest_created","fair_distribution_limit":100,"fair_distribution_window":3600}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/assignment_policies", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(s.T(), "balanced", resp["assignment_order"])
assert.Equal(s.T(), "earliest_created", resp["conversation_priority"])
assert.Equal(s.T(), float64(100), resp["fair_distribution_limit"])
assert.Equal(s.T(), float64(3600), resp["fair_distribution_window"])
}
func (s *AssignmentPolicyHandlerTestSuite) TestCreateAccountPolicy_InvalidJSON() {
body := `{invalid json`
w := httptest.NewRecorder()
@@ -245,7 +264,7 @@ func (s *AssignmentPolicyHandlerTestSuite) TestUpdateAccountPolicy_Success() {
}
s.Require().NoError(s.db.Create(policy).Error)
body := `{"name":"Priority","fair_distribution_limit":10}`
body := `{"name":"Priority","assignment_order":"balanced","conversation_priority":"longest_waiting","fair_distribution_limit":10}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", "/api/v1/accounts/1/assignment_policy/"+uintToStr(policy.ID), bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
@@ -256,6 +275,8 @@ func (s *AssignmentPolicyHandlerTestSuite) TestUpdateAccountPolicy_Success() {
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(s.T(), "Priority", resp["name"])
assert.Equal(s.T(), "balanced", resp["assignment_order"])
assert.Equal(s.T(), "longest_waiting", resp["conversation_priority"])
assert.Equal(s.T(), float64(10), resp["fair_distribution_limit"])
}
@@ -61,13 +61,6 @@ type LoginMFAResquest struct {
TOTPCode string `json:"totp_code" binding:"required"`
}
// RegisterRequest is the JSON body for register endpoint.
type RegisterRequest struct {
Name string `json:"name" binding:"required,min=2"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
// RefreshRequest is the JSON body for refresh endpoint.
type RefreshRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
@@ -251,31 +244,6 @@ func (h *AuthHandler) LoginMFA(c *gin.Context) {
})
}
// Register creates a new user account.
// POST /api/v1/auth/register
func (h *AuthHandler) Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
user, err := h.authService.Register(c.Request.Context(), &service.RegisterInput{
Name: req.Name,
Email: req.Email,
Password: req.Password,
})
if err != nil {
response.AbortWithStatusError(c, http.StatusConflict, response.ErrConflict, err.Error())
return
}
response.Created(c, gin.H{
"user": user,
"message": "Registration successful. Please verify your email.",
})
}
// Refresh rotates a refresh token and returns new JWT pair.
// POST /api/v1/auth/refresh
// Implements refresh token rotation per P2E §1.4 security requirement.
@@ -521,7 +489,6 @@ func RegisterAuthRoutes(rg *gin.RouterGroup, handler *AuthHandler) {
// Core auth endpoints
authGroup.POST("/login", handler.Login)
authGroup.POST("/login/mfa", handler.LoginMFA)
authGroup.POST("/register", handler.Register)
authGroup.POST("/refresh", handler.Refresh)
authGroup.DELETE("/logout", handler.Logout)
@@ -65,6 +65,15 @@ func setupChatwootAuthTest(t *testing.T) (*gin.Engine, *gorm.DB, *model.User) {
return router, db, user
}
func TestRegisterAuthRoutesDoesNotExposeSelfRegistration(t *testing.T) {
router := gin.New()
RegisterAuthRoutes(router.Group("/api/v1"), &AuthHandler{})
for _, route := range router.Routes() {
require.NotEqual(t, "/api/v1/auth/register", route.Path)
}
}
func TestChatwootAuthSignInReturnsDeviseHeadersAndUserPayload(t *testing.T) {
router, _, _ := setupChatwootAuthTest(t)
body, _ := json.Marshal(map[string]string{"email": " AUTH@example.com ", "password": "password123"})
@@ -285,6 +285,29 @@ func TestSerializeInboxComputesCallbackWebhookURL(t *testing.T) {
require.Nil(t, serializeInbox(website, nil, false)["callback_webhook_url"])
}
func TestSerializeInboxComputesWebWidgetScript(t *testing.T) {
t.Setenv("FRONTEND_URL", "https://app.example.test/")
website := &model.Inbox{
AccountID: 1,
Name: "Website",
ChannelType: "web_widget",
ChannelConfig: `{"website_token":"website-token"}`,
}
script, ok := serializeInbox(website, nil, false)["web_widget_script"].(string)
require.True(t, ok)
require.Contains(t, script, `var BASE_URL="https://app.example.test"`)
require.Contains(t, script, `g.src=BASE_URL+"/sdk/js/sdk.js"`)
require.Contains(t, script, `websiteToken: "website-token"`)
missingToken := &model.Inbox{AccountID: 1, Name: "Website", ChannelType: "web_widget"}
require.Nil(t, serializeInbox(missingToken, nil, false)["web_widget_script"])
email := &model.Inbox{AccountID: 1, Name: "Email", ChannelType: "email"}
require.Nil(t, serializeInbox(email, nil, false)["web_widget_script"])
}
func TestInboxHandler_SensitiveFieldsRequireAdministratorRole(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -209,14 +209,19 @@ func (h *InboxMemberHandler) updateAccountScoped(c *gin.Context) {
// DestroyAccountScoped removes selected users from an inbox using Chatwoot's account-level route.
// DELETE /api/v1/accounts/:account_id/inbox_members
func (h *InboxMemberHandler) DestroyAccountScoped(c *gin.Context) {
var req struct {
InboxID uint `json:"inbox_id" binding:"required"`
UserIDs []uint `json:"user_ids" binding:"required"`
}
var req service.UpdateMultipleRequest
if bindErr := c.ShouldBindJSON(&req); bindErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()})
return
}
if req.InboxID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "inbox_id is required"})
return
}
if req.UserIDs == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "user_ids is required"})
return
}
for _, userID := range req.UserIDs {
if svcErr := h.svc.RemoveMember(c.Request.Context(), req.InboxID, userID); svcErr != nil {
@@ -136,7 +136,7 @@ func (s *InboxMemberHandlerTestSuite) TestAccountScopedInboxMembers_ChatwootPayl
s.Require().Len(payload, 2)
update := httptest.NewRecorder()
body = fmt.Sprintf(`{"inbox_id":%d,"user_ids":[%d]}`, s.inbox.ID, users[2].ID)
body = fmt.Sprintf(`{"inbox_id":"%d","user_ids":[%d]}`, s.inbox.ID, users[2].ID)
req, _ = http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/inbox_members", s.account.ID), bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(update, req)
@@ -146,7 +146,7 @@ func (s *InboxMemberHandlerTestSuite) TestAccountScopedInboxMembers_ChatwootPayl
s.Require().Equal(float64(users[2].ID), payload[0]["id"])
destroy := httptest.NewRecorder()
body = fmt.Sprintf(`{"inbox_id":%d,"user_ids":[%d]}`, s.inbox.ID, users[2].ID)
body = fmt.Sprintf(`{"inbox_id":"%d","user_ids":[%d]}`, s.inbox.ID, users[2].ID)
req, _ = http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/inbox_members", s.account.ID), bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(destroy, req)
@@ -2,6 +2,7 @@ package v1
import (
"encoding/json"
"fmt"
"os"
"strings"
@@ -47,7 +48,7 @@ func serializeInbox(inbox *model.Inbox, db *gorm.DB, isAdmin bool) map[string]an
"hmac_mandatory": configValue(config, "hmac_mandatory"),
"welcome_title": configValue(config, "welcome_title"),
"welcome_tagline": configValue(config, "welcome_tagline"),
"web_widget_script": configValue(config, "web_widget_script"),
"web_widget_script": webWidgetScript(inbox, config),
"website_token": configValue(config, "website_token"),
"selected_feature_flags": configValue(config, "selected_feature_flags"),
"reply_time": configValue(config, "reply_time"),
@@ -230,6 +231,36 @@ func callbackWebhookURL(inbox *model.Inbox, config map[string]any) any {
}
}
func webWidgetScript(inbox *model.Inbox, config map[string]any) any {
if chatwootChannelType(inbox.ChannelType) != "Channel::WebWidget" {
return nil
}
websiteToken := strings.TrimSpace(inboxConfigString(config, "website_token"))
if websiteToken == "" {
return nil
}
baseURL := strings.TrimRight(os.Getenv("FRONTEND_URL"), "/")
return fmt.Sprintf(`
<script>
(function(d,t) {
var BASE_URL=%q;
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
g.src=BASE_URL+"/sdk/js/sdk.js";
g.async = true;
s.parentNode.insertBefore(g,s);
g.onload=function(){
window.chatwootSDK.run({
websiteToken: %q,
baseUrl: BASE_URL
})
}
})(document,"script");
</script>
`, baseURL, websiteToken)
}
func parseInboxChannelConfig(raw string) map[string]any {
if raw == "" {
return map[string]any{}
@@ -2,6 +2,7 @@ package v1
import (
"encoding/json"
"mime/multipart"
"net/http"
"strings"
@@ -15,12 +16,17 @@ import (
// ProfileHandler handles Profile get/update + avatar + availability + auto_offline + set_active_account + resend_confirmation + reset_access_token.
// Reference: Chatwoot app/controllers/api/v1/profile_controller.rb
type ProfileHandler struct {
svc *service.ProfileService
svc *service.ProfileService
uploadSvc *service.UploadService
}
// NewProfileHandler creates a new Profile handler.
func NewProfileHandler(svc *service.ProfileService) *ProfileHandler {
return &ProfileHandler{svc: svc}
func NewProfileHandler(svc *service.ProfileService, uploadSvc ...*service.UploadService) *ProfileHandler {
handler := &ProfileHandler{svc: svc}
if len(uploadSvc) > 0 {
handler.uploadSvc = uploadSvc[0]
}
return handler
}
// Get returns the current user's profile.
@@ -52,13 +58,25 @@ func (h *ProfileHandler) Update(c *gin.Context) {
return
}
req, err := bindProfileUpdate(c)
req, avatar, err := bindProfileUpdate(c)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
return
}
accountID := c.GetUint("account_id")
if avatar != nil {
if h.uploadSvc == nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "profile avatar storage is not configured")
return
}
upload, uploadErr := h.uploadSvc.ProfileAvatarUpload(c.Request.Context(), accountID, avatar)
if uploadErr != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, uploadErr.Error())
return
}
req.Profile.AvatarURL = upload.FileURL
}
user, svcErr := h.svc.Update(c.Request.Context(), userID, accountID, req.Profile)
if svcErr != nil {
applogger.L().Errorf("Update profile for user %d: %v", userID, svcErr)
@@ -69,22 +87,22 @@ func (h *ProfileHandler) Update(c *gin.Context) {
c.JSON(http.StatusOK, user)
}
func bindProfileUpdate(c *gin.Context) (service.ProfileUpdatePayload, error) {
func bindProfileUpdate(c *gin.Context) (service.ProfileUpdatePayload, *multipart.FileHeader, error) {
contentType := c.GetHeader("Content-Type")
if strings.Contains(contentType, "multipart/form-data") || strings.Contains(contentType, "application/x-www-form-urlencoded") {
return bindProfileUpdateForm(c)
}
var req service.ProfileUpdatePayload
if err := c.ShouldBindJSON(&req); err != nil {
return req, err
return req, nil, err
}
return req, nil
return req, nil, nil
}
func bindProfileUpdateForm(c *gin.Context) (service.ProfileUpdatePayload, error) {
func bindProfileUpdateForm(c *gin.Context) (service.ProfileUpdatePayload, *multipart.FileHeader, error) {
var req service.ProfileUpdatePayload
if err := c.Request.ParseMultipartForm(32 << 20); err != nil && !strings.Contains(err.Error(), "request Content-Type isn't multipart/form-data") {
return req, err
return req, nil, err
}
form := c.Request.Form
profile := &req.Profile
@@ -109,9 +127,7 @@ func bindProfileUpdateForm(c *gin.Context) (service.ProfileUpdatePayload, error)
if value := form.Get("profile[avatar_url]"); value != "" {
profile.AvatarURL = value
}
if file, err := c.FormFile("profile[avatar]"); err == nil && file != nil {
profile.AvatarURL = file.Filename
}
avatar, _ := c.FormFile("profile[avatar]")
uiSettings := map[string]any{}
for key, values := range form {
if !strings.HasPrefix(key, "profile[ui_settings][") || len(values) == 0 {
@@ -129,7 +145,7 @@ func bindProfileUpdateForm(c *gin.Context) (service.ProfileUpdatePayload, error)
if len(uiSettings) > 0 {
profile.UISettings = uiSettings
}
return req, nil
return req, avatar, nil
}
// UpdateAvatar updates the current user's avatar.
@@ -21,6 +21,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/gochat/gochat/internal/config"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
@@ -103,9 +104,13 @@ func (s *ProfileHandlerTestSuite) SetupSuite() {
accessTokenRepo := repository.NewAccessTokenRepo(db)
installationConfigRepo := repository.NewInstallationConfigRepo(db)
profileSvc := service.NewProfileService(userRepo, accountUserRepo, accessTokenRepo, installationConfigRepo)
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)
s.handler = NewProfileHandler(profileSvc, uploadSvc)
// Build router with profile routes and auth middleware
s.router = s.buildRouter()
@@ -1058,7 +1063,14 @@ func (s *ProfileHandlerTestSuite) TestUpdate_MultipartFormProfileParity() {
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"])
assert.Equal(s.T(), "avatar.png", payload["avatar_url"])
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"])
}