update
This commit is contained in:
@@ -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"])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user