feat(profile): send confirmation invitations

This commit is contained in:
2026-06-06 12:13:56 +08:00
parent 34fdb077be
commit 682d0ab78b
11 changed files with 512 additions and 30 deletions
+28 -1
View File
@@ -24,6 +24,7 @@ type AgentHandlerTestSuite struct {
suite.Suite
db *gorm.DB
handler *AgentHandler
mailer *fakeProfileConfirmationMailer
account *model.Account
user *model.User
}
@@ -34,11 +35,13 @@ func (s *AgentHandlerTestSuite) SetupSuite() {
Logger: logger.Default.LogMode(logger.Silent),
})
s.Require().NoError(err)
s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.User{}, &model.AccountUser{}))
s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.User{}, &model.AccountUser{}, &model.InstallationConfig{}))
s.db = db
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"}
@@ -58,6 +61,7 @@ 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() {
@@ -188,6 +192,29 @@ func (s *AgentHandlerTestSuite) TestCreateAgent() {
assert.Equal(s.T(), s.user.ID, membership.InvitedBy)
}
func (s *AgentHandlerTestSuite) TestCreateAgentSendsWorkspaceInvitation() {
req := service.CreateAgentRequest{Email: "invite-mail@test.com", Name: "Invite Mail", 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 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)
}
func (s *AgentHandlerTestSuite) TestCreateAgentDefaultsBlankNameFromEmail() {
req := map[string]interface{}{
"agent": map[string]interface{}{
+102 -10
View File
@@ -2,6 +2,7 @@ package v1
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
@@ -11,6 +12,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
@@ -34,6 +36,7 @@ type ProfileHandlerTestSuite struct {
router *gin.Engine
handler *ProfileHandler
db *gorm.DB
mailer *fakeProfileConfirmationMailer
user *model.User
account *model.Account
@@ -100,6 +103,8 @@ func (s *ProfileHandlerTestSuite) SetupSuite() {
accessTokenRepo := repository.NewAccessTokenRepo(db)
installationConfigRepo := repository.NewInstallationConfigRepo(db)
profileSvc := service.NewProfileService(userRepo, accountUserRepo, accessTokenRepo, installationConfigRepo)
s.mailer = &fakeProfileConfirmationMailer{}
profileSvc.SetConfirmationMailer(s.mailer)
s.handler = NewProfileHandler(profileSvc)
// Build router with profile routes and auth middleware
@@ -135,26 +140,49 @@ func (s *ProfileHandlerTestSuite) SetupTest() {
s.Require().NoError(err)
// 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": passwordDigest,
"password_digest": passwordDigest,
"avatar_url": "",
"available": false,
"display_name": "Profile Display",
"message_signature": "Regards",
"pubsub_token": "pubsub-profile-user",
"name": "ProfileUser",
"email": "profile@example.com",
"password": passwordDigest,
"password_digest": 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.AccountUser{}).Where("account_id = ? AND user_id = ?", s.accountID, s.userID).Updates(map[string]interface{}{
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()
}
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{} {
@@ -724,6 +752,70 @@ func (s *ProfileHandlerTestSuite) TestResetAccessToken_RegeneratesTokenInChatwoo
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() {