H-337: restore Web widget reply visibility (#64)
* H-337: restore widget reply delivery * H-337: harden widget conversation ownership --------- Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
@@ -29,6 +29,7 @@ var (
|
||||
ErrWidgetConversationNotFound = errors.New("conversation not found")
|
||||
ErrWidgetEndConversationDisabled = errors.New("end conversation is not permitted")
|
||||
ErrWidgetMessageContentTooLong = errors.New("Content is too long (maximum is 150000 characters)")
|
||||
errWidgetConversationOwnership = errors.New("conversation does not belong to this contact")
|
||||
)
|
||||
|
||||
const widgetMessageContentLimit = 150000
|
||||
@@ -368,16 +369,12 @@ func (s *WidgetService) SendMessage(ctx context.Context, req WidgetSendMessageRe
|
||||
var conversation *model.Conversation
|
||||
conversationCreated := false
|
||||
if req.ConversationID != nil {
|
||||
conversation, err = s.conversationRepo.FindByID(ctx, *req.ConversationID)
|
||||
conversation, err = s.resolveWidgetConversation(ctx, contactInbox, *req.ConversationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("conversation not found: %w", err)
|
||||
}
|
||||
// Verify conversation belongs to this contact
|
||||
if conversation.ContactID != contactInbox.ContactID {
|
||||
return nil, errors.New("conversation does not belong to this contact")
|
||||
}
|
||||
} else {
|
||||
conversations, _, findErr := s.conversationRepo.FindByContact(ctx, contactInbox.Contact.AccountID, contactInbox.ContactID, 0, 1)
|
||||
conversations, _, findErr := s.findWidgetConversations(ctx, contactInbox, 0, 1)
|
||||
if findErr != nil {
|
||||
return nil, findErr
|
||||
}
|
||||
@@ -523,8 +520,7 @@ func (s *WidgetService) GetConversations(ctx context.Context, widgetToken string
|
||||
return nil, fmt.Errorf("invalid widget_token: %w", err)
|
||||
}
|
||||
|
||||
conversations, _, err := s.conversationRepo.FindByContact(
|
||||
ctx, contactInbox.Contact.AccountID, contactInbox.ContactID, 0, 50)
|
||||
conversations, _, err := s.findWidgetConversations(ctx, contactInbox, 0, 50)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -536,8 +532,7 @@ func (s *WidgetService) GetLatestConversation(ctx context.Context, widgetToken s
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid widget_token: %w", err)
|
||||
}
|
||||
conversations, _, err := s.conversationRepo.FindByContact(
|
||||
ctx, contactInbox.Contact.AccountID, contactInbox.ContactID, 0, 1)
|
||||
conversations, _, err := s.findWidgetConversations(ctx, contactInbox, 0, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -552,12 +547,38 @@ func (s *WidgetService) GetConversation(ctx context.Context, widgetToken string,
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid widget_token: %w", err)
|
||||
}
|
||||
return s.resolveWidgetConversation(ctx, contactInbox, conversationID)
|
||||
}
|
||||
|
||||
func (s *WidgetService) findWidgetConversations(ctx context.Context, contactInbox *model.ContactInbox, offset, limit int) ([]model.Conversation, int64, error) {
|
||||
count, err := s.contactInboxRepo.CountByContactAndInbox(ctx, contactInbox.ContactID, contactInbox.InboxID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return s.conversationRepo.FindByContactInbox(ctx, contactInbox.Contact.AccountID, contactInbox.ContactID,
|
||||
contactInbox.InboxID, contactInbox.ID, count == 1, offset, limit)
|
||||
}
|
||||
|
||||
func (s *WidgetService) resolveWidgetConversation(ctx context.Context, contactInbox *model.ContactInbox, conversationID uint) (*model.Conversation, error) {
|
||||
conversation, err := s.conversationRepo.FindByID(ctx, conversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if conversation.ContactID != contactInbox.ContactID {
|
||||
return nil, errors.New("conversation does not belong to this contact")
|
||||
if conversation.ContactID != contactInbox.ContactID || conversation.InboxID != contactInbox.InboxID {
|
||||
return nil, errWidgetConversationOwnership
|
||||
}
|
||||
if conversation.ContactInboxID != nil {
|
||||
if *conversation.ContactInboxID == contactInbox.ID {
|
||||
return conversation, nil
|
||||
}
|
||||
return nil, errWidgetConversationOwnership
|
||||
}
|
||||
count, err := s.contactInboxRepo.CountByContactAndInbox(ctx, contactInbox.ContactID, contactInbox.InboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count != 1 {
|
||||
return nil, errWidgetConversationOwnership
|
||||
}
|
||||
return conversation, nil
|
||||
}
|
||||
@@ -1129,14 +1150,9 @@ func (s *WidgetService) GetMessages(ctx context.Context, widgetToken string, con
|
||||
return nil, 0, fmt.Errorf("invalid widget_token: %w", err)
|
||||
}
|
||||
|
||||
// Verify conversation belongs to this contact
|
||||
conversation, err := s.conversationRepo.FindByID(ctx, conversationID)
|
||||
if err != nil {
|
||||
if _, err := s.resolveWidgetConversation(ctx, contactInbox, conversationID); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if conversation.ContactID != contactInbox.ContactID {
|
||||
return nil, 0, errors.New("conversation does not belong to this contact")
|
||||
}
|
||||
|
||||
return s.messageRepo.FindByConversation(ctx, conversationID, offset, limit)
|
||||
}
|
||||
@@ -1285,13 +1301,10 @@ func (s *WidgetService) UpdateMessage(ctx context.Context, req WidgetMessageUpda
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
conversation, err := s.conversationRepo.FindByID(ctx, message.ConversationID)
|
||||
conversation, err := s.resolveWidgetConversation(ctx, contactInbox, message.ConversationID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if conversation.ContactID != contactInbox.ContactID || conversation.InboxID != contactInbox.InboxID {
|
||||
return nil, nil, errors.New("message does not belong to this contact")
|
||||
}
|
||||
|
||||
contact := &contactInbox.Contact
|
||||
if strings.TrimSpace(req.ContactEmail) != "" && message.ContentType == string(model.MessageContentTypeInputEmail) {
|
||||
@@ -1445,13 +1458,9 @@ func (s *WidgetService) AddDyteParticipant(ctx context.Context, websiteToken, wi
|
||||
if message.InboxID != inbox.ID {
|
||||
return nil, errors.New("message does not belong to this inbox")
|
||||
}
|
||||
conversation, err := s.conversationRepo.FindByID(ctx, message.ConversationID)
|
||||
if err != nil {
|
||||
if _, err := s.resolveWidgetConversation(ctx, contactInbox, message.ConversationID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if conversation.ContactID != contactInbox.ContactID {
|
||||
return nil, errors.New("message does not belong to this contact")
|
||||
}
|
||||
if message.ContentType != "integrations" {
|
||||
return nil, errors.New("Invalid message type. Action not permitted")
|
||||
}
|
||||
@@ -1488,16 +1497,14 @@ func (s *WidgetService) ToggleTyping(ctx context.Context, widgetToken string, co
|
||||
return fmt.Errorf("invalid widget_token: %w", err)
|
||||
}
|
||||
|
||||
conversation, err := s.conversationRepo.FindByID(ctx, conversationID)
|
||||
conversation, err := s.resolveWidgetConversation(ctx, contactInbox, conversationID)
|
||||
if err != nil {
|
||||
if errors.Is(err, errWidgetConversationOwnership) {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("conversation not found: %w", err)
|
||||
}
|
||||
|
||||
// Verify conversation belongs to this contact
|
||||
if conversation.ContactID != contactInbox.ContactID {
|
||||
return errors.New("conversation does not belong to this contact")
|
||||
}
|
||||
|
||||
performer := &ws.Performer{
|
||||
ID: contactInbox.ContactID,
|
||||
Name: contactInbox.Contact.Name,
|
||||
|
||||
@@ -787,6 +787,49 @@ func TestWidgetService_GetConversations_InvalidToken(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestWidgetService_GetLatestMessagesScopesByContactInbox(t *testing.T) {
|
||||
db, svc := setupWidgetServiceTest(t)
|
||||
ctx := context.Background()
|
||||
seedWidgetInbox(t, db)
|
||||
initResp, err := svc.Init(ctx, WidgetInitRequest{WebsiteToken: "test_ws_token_123"})
|
||||
require.NoError(t, err)
|
||||
owned, err := svc.SendMessage(ctx, WidgetSendMessageRequest{WidgetToken: initResp.WidgetToken, Content: "owned message"})
|
||||
require.NoError(t, err)
|
||||
|
||||
otherContactInbox := model.ContactInbox{
|
||||
ContactID: initResp.ContactID,
|
||||
InboxID: initResp.InboxID,
|
||||
SourceID: "other-source",
|
||||
PubsubToken: "other-widget-token",
|
||||
}
|
||||
require.NoError(t, db.Create(&otherContactInbox).Error)
|
||||
otherConversation := model.Conversation{
|
||||
AccountID: initResp.AccountID,
|
||||
InboxID: initResp.InboxID,
|
||||
ContactID: initResp.ContactID,
|
||||
ContactInboxID: &otherContactInbox.ID,
|
||||
Status: string(model.ConversationStatusOpen),
|
||||
ChannelType: string(channel.ChannelWebWidget),
|
||||
Channel: "web_widget",
|
||||
}
|
||||
require.NoError(t, db.Create(&otherConversation).Error)
|
||||
require.NoError(t, db.Create(&model.Message{
|
||||
ConversationID: otherConversation.ID,
|
||||
AccountID: initResp.AccountID,
|
||||
InboxID: initResp.InboxID,
|
||||
Content: "other identity message",
|
||||
ContentType: "text",
|
||||
MessageType: string(model.MessageTypeOutgoing),
|
||||
}).Error)
|
||||
|
||||
messages, _, conversation, err := svc.GetLatestConversationMessages(ctx, initResp.WidgetToken, 0, 0)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, conversation)
|
||||
assert.Equal(t, owned.ConversationID, conversation.ID)
|
||||
require.Len(t, messages, 1)
|
||||
assert.Equal(t, "owned message", messages[0].Content)
|
||||
}
|
||||
|
||||
func TestWidgetService_UpdateLastSeenQueuesMessageStatusJob(t *testing.T) {
|
||||
db, svc := setupWidgetServiceTest(t)
|
||||
ctx := context.Background()
|
||||
@@ -892,6 +935,96 @@ func TestWidgetService_GetMessages_WrongConversationOwner(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "does not belong to this contact")
|
||||
}
|
||||
|
||||
func TestWidgetService_LegacyConversationRequiresUniqueContactInbox(t *testing.T) {
|
||||
db, svc := setupWidgetServiceTest(t)
|
||||
ctx := context.Background()
|
||||
account, inbox := seedWidgetInbox(t, db)
|
||||
contact := &model.Contact{AccountID: account.ID, Name: "Legacy visitor"}
|
||||
require.NoError(t, db.Create(contact).Error)
|
||||
contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, PubsubToken: "legacy-token"}
|
||||
require.NoError(t, db.Create(contactInbox).Error)
|
||||
legacy := &model.Conversation{
|
||||
AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID,
|
||||
Status: "open", ChannelType: "web_widget", Channel: "web_widget",
|
||||
}
|
||||
require.NoError(t, db.Create(legacy).Error)
|
||||
require.NoError(t, db.Create(&model.Message{
|
||||
AccountID: account.ID, InboxID: inbox.ID, ConversationID: legacy.ID,
|
||||
Content: "legacy reply", ContentType: "text", MessageType: "outgoing", Status: "sent",
|
||||
}).Error)
|
||||
|
||||
conversations, err := svc.GetConversations(ctx, contactInbox.PubsubToken)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, conversations, 1)
|
||||
assert.Equal(t, legacy.ID, conversations[0].ID)
|
||||
_, err = svc.GetConversation(ctx, contactInbox.PubsubToken, legacy.ID)
|
||||
require.NoError(t, err)
|
||||
messages, _, err := svc.GetMessages(ctx, contactInbox.PubsubToken, legacy.ID, 0, 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, messages, 1)
|
||||
conversationID := legacy.ID
|
||||
_, err = svc.SendMessage(ctx, WidgetSendMessageRequest{
|
||||
WidgetToken: contactInbox.PubsubToken, ConversationID: &conversationID, Content: "legacy visitor reply",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, db.Create(&model.ContactInbox{
|
||||
ContactID: contact.ID, InboxID: inbox.ID, PubsubToken: "second-legacy-token",
|
||||
}).Error)
|
||||
conversations, err = svc.GetConversations(ctx, contactInbox.PubsubToken)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, conversations)
|
||||
_, err = svc.GetConversation(ctx, contactInbox.PubsubToken, legacy.ID)
|
||||
assert.ErrorIs(t, err, errWidgetConversationOwnership)
|
||||
_, _, err = svc.GetMessages(ctx, contactInbox.PubsubToken, legacy.ID, 0, 10)
|
||||
assert.ErrorIs(t, err, errWidgetConversationOwnership)
|
||||
_, err = svc.SendMessage(ctx, WidgetSendMessageRequest{
|
||||
WidgetToken: contactInbox.PubsubToken, ConversationID: &conversationID, Content: "must fail closed",
|
||||
})
|
||||
assert.ErrorIs(t, err, errWidgetConversationOwnership)
|
||||
}
|
||||
|
||||
func TestWidgetService_ExplicitConversationEntrypointsRejectSiblingToken(t *testing.T) {
|
||||
db, svc := setupWidgetServiceTest(t)
|
||||
ctx := context.Background()
|
||||
account, inbox := seedWidgetInbox(t, db)
|
||||
contact := &model.Contact{AccountID: account.ID, Name: "Shared contact"}
|
||||
require.NoError(t, db.Create(contact).Error)
|
||||
owner := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, PubsubToken: "owner-token"}
|
||||
sibling := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, PubsubToken: "sibling-token"}
|
||||
require.NoError(t, db.Create(owner).Error)
|
||||
require.NoError(t, db.Create(sibling).Error)
|
||||
conversation := &model.Conversation{
|
||||
AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &owner.ID,
|
||||
Status: "open", ChannelType: "web_widget", Channel: "web_widget",
|
||||
}
|
||||
require.NoError(t, db.Create(conversation).Error)
|
||||
message := &model.Message{
|
||||
AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID,
|
||||
Content: "owner only", ContentType: "integrations", MessageType: "outgoing", Status: "sent",
|
||||
ContentAttributes: mustJSON(map[string]any{"data": map[string]any{"meeting_id": "meeting-1"}}),
|
||||
}
|
||||
require.NoError(t, db.Create(message).Error)
|
||||
|
||||
_, err := svc.GetConversation(ctx, sibling.PubsubToken, conversation.ID)
|
||||
assert.ErrorIs(t, err, errWidgetConversationOwnership)
|
||||
_, _, err = svc.GetMessages(ctx, sibling.PubsubToken, conversation.ID, 0, 10)
|
||||
assert.ErrorIs(t, err, errWidgetConversationOwnership)
|
||||
err = svc.ToggleTyping(ctx, sibling.PubsubToken, conversation.ID, true)
|
||||
assert.ErrorIs(t, err, errWidgetConversationOwnership)
|
||||
_, _, err = svc.UpdateMessage(ctx, WidgetMessageUpdate{
|
||||
WidgetToken: sibling.PubsubToken, MessageID: message.ID, SubmittedValues: []map[string]any{{"value": "nope"}},
|
||||
})
|
||||
assert.ErrorIs(t, err, errWidgetConversationOwnership)
|
||||
_, err = svc.AddDyteParticipant(ctx, "test_ws_token_123", sibling.PubsubToken, message.ID)
|
||||
assert.ErrorIs(t, err, errWidgetConversationOwnership)
|
||||
conversationID := conversation.ID
|
||||
_, err = svc.SendMessage(ctx, WidgetSendMessageRequest{
|
||||
WidgetToken: sibling.PubsubToken, ConversationID: &conversationID, Content: "nope",
|
||||
})
|
||||
assert.ErrorIs(t, err, errWidgetConversationOwnership)
|
||||
}
|
||||
|
||||
// ========== GetCableToken Tests ==========
|
||||
|
||||
func TestWidgetService_GetCableToken(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user