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:
Rogee
2026-08-20 22:22:27 +08:00
committed by GitHub
co-authored by rogee
parent abb1bed424
commit b31b1b9562
22 changed files with 847 additions and 83 deletions
@@ -204,7 +204,11 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) {
c.JSON(http.StatusOK, resp)
return
}
payload := widgetMessagePayload(resp.Message, resp.ConversationID)
conversationID := resp.ConversationID
if conversation, lookupErr := h.widgetService.GetConversation(c.Request.Context(), widgetToken, resp.ConversationID); lookupErr == nil {
conversationID = widgetConversationID(*conversation)
}
payload := widgetMessagePayload(resp.Message, conversationID)
if len(resp.Attachments) > 0 {
payload["attachments"] = widgetAttachmentPayloads(resp.Attachments)
}
@@ -271,8 +275,12 @@ func (h *WidgetHandler) GetLatestMessages(c *gin.Context) {
}
payload := make([]gin.H, 0, len(messages))
conversationID := uint(0)
if conversation != nil {
conversationID = widgetConversationID(*conversation)
}
for _, msg := range messages {
messagePayload := widgetMessagePayload(msg, msg.ConversationID)
messagePayload := widgetMessagePayload(msg, conversationID)
if attachments, err := h.widgetService.GetMessageAttachments(c.Request.Context(), msg.ID); err == nil && len(attachments) > 0 {
messagePayload["attachments"] = widgetAttachmentPayloads(attachments)
}
@@ -350,7 +358,7 @@ func (h *WidgetHandler) CreateConversation(c *gin.Context) {
return
}
payload := widgetConversationPayload(*conversation)
payload["messages"] = []gin.H{widgetMessagePayload(resp.Message, resp.ConversationID)}
payload["messages"] = []gin.H{widgetMessagePayload(resp.Message, widgetConversationID(*conversation))}
c.JSON(http.StatusOK, payload)
}
@@ -1275,7 +1283,7 @@ func widgetMessagePayload(message model.Message, conversationID uint) gin.H {
"content": message.Content,
"inbox_id": message.InboxID,
"conversation_id": conversationID,
"message_type": message.MessageType,
"message_type": widgetMessageType(message.MessageType),
"content_type": message.ContentType,
"content_attributes": webhookutil.SanitizeOutboundJSON(message.ContentAttributes),
"created_at": message.CreatedAt.Unix(),
@@ -1284,6 +1292,26 @@ func widgetMessagePayload(message model.Message, conversationID uint) gin.H {
}
}
func widgetMessageType(value string) int {
switch strings.ToLower(strings.TrimSpace(value)) {
case "incoming":
return 0
case "activity":
return 2
case "template":
return 3
default:
return 1
}
}
func widgetConversationID(conversation model.Conversation) uint {
if conversation.DisplayID != nil && *conversation.DisplayID != 0 {
return *conversation.DisplayID
}
return conversation.ID
}
func widgetAttachmentPayloads(attachments []model.Attachment) []gin.H {
payload := make([]gin.H, 0, len(attachments))
for _, attachment := range attachments {
@@ -482,7 +482,7 @@ func TestWidgetHandler_ChatwootConversationQueryTokenReusesSession(t *testing.T)
var messageResp map[string]interface{}
require.NoError(t, json.Unmarshal(wMessage.Body.Bytes(), &messageResp))
assert.Equal(t, "Popout session message", messageResp["content"])
assert.Equal(t, "incoming", messageResp["message_type"])
assert.Equal(t, float64(0), messageResp["message_type"])
assertWidgetMessageFixtureShape(t, messageResp)
wLatest := httptest.NewRecorder()
@@ -740,6 +740,22 @@ func TestWidgetHandler_ChatwootMessages_AuthTokenAndNestedPayload(t *testing.T)
assert.Nil(t, messageResp["message"])
assert.Equal(t, "Hello from Chatwoot widget", messageResp["content"])
assert.NotEmpty(t, messageResp["conversation_id"])
assert.Equal(t, float64(0), messageResp["message_type"])
var conversation model.Conversation
require.NoError(t, db.First(&conversation).Error)
displayID := uint(42)
require.NoError(t, db.Model(&conversation).Update("display_id", displayID).Error)
require.NoError(t, db.Create(&model.Message{
ConversationID: conversation.ID,
AccountID: conversation.AccountID,
InboxID: conversation.InboxID,
Content: "Dashboard reply visible after refresh",
ContentType: "text",
MessageType: string(model.MessageTypeOutgoing),
SenderType: string(model.SenderTypeUser),
Status: "sent",
}).Error)
wIndex := httptest.NewRecorder()
reqIndex, _ := http.NewRequest("GET", "/api/v1/widget/messages", nil)
@@ -750,10 +766,14 @@ func TestWidgetHandler_ChatwootMessages_AuthTokenAndNestedPayload(t *testing.T)
var indexResp map[string]interface{}
require.NoError(t, json.Unmarshal(wIndex.Body.Bytes(), &indexResp))
payload := indexResp["payload"].([]interface{})
require.Len(t, payload, 1)
require.Len(t, payload, 2)
firstMessage := payload[0].(map[string]interface{})
assertWidgetMessageFixtureShape(t, firstMessage)
assert.Equal(t, "Hello from Chatwoot widget", firstMessage["content"])
reply := payload[1].(map[string]interface{})
assert.Equal(t, "Dashboard reply visible after refresh", reply["content"])
assert.Equal(t, float64(1), reply["message_type"])
assert.Equal(t, float64(displayID), reply["conversation_id"])
wContact := httptest.NewRecorder()
reqContact, _ := http.NewRequest("GET", "/api/v1/widget/contact", nil)
@@ -791,6 +811,57 @@ func TestWidgetHandler_ChatwootMessagesIndexFiltersInternalMessages(t *testing.T
assert.Equal(t, float64(1), indexResp["meta"].(map[string]interface{})["total"])
}
func TestWidgetHandler_ExplicitConversationEntrypointsRejectSiblingToken(t *testing.T) {
db, router, _ := setupWidgetHandlerTest(t)
account, inbox := seedWidgetHandlerData(t, db)
contact := &model.Contact{AccountID: account.ID, Name: "Shared visitor"}
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: datatypes.JSON(`{"data":{"meeting_id":"meeting-1"}}`),
}
require.NoError(t, db.Create(message).Error)
tests := []struct {
name, method, path, body string
}{
{"messages", http.MethodGet, fmt.Sprintf("/widget/conversations/%d/messages", conversation.ID), ""},
{"typing", http.MethodPost, fmt.Sprintf("/widget/conversations/%d/toggle_typing", conversation.ID), `{"typing":true}`},
{"message update", http.MethodPatch, fmt.Sprintf("/api/v1/widget/messages/%d", message.ID), `{"message":{"submitted_values":[{"value":"nope"}]}}`},
{"dyte", http.MethodPost, "/api/v1/widget/integrations/dyte/add_participant_to_meeting?website_token=handler_ws_token_123", fmt.Sprintf(`{"message_id":%d}`, message.ID)},
{"send", http.MethodPost, "/widget/messages", fmt.Sprintf(`{"content":"nope","conversation_id":%d}`, conversation.ID)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
req.Header.Set("X-Widget-Token", sibling.PubsubToken)
if tt.body != "" {
req.Header.Set("Content-Type", "application/json")
}
response := httptest.NewRecorder()
router.ServeHTTP(response, req)
assert.GreaterOrEqual(t, response.Code, http.StatusBadRequest, response.Body.String())
})
}
var messageCount int64
require.NoError(t, db.Model(&model.Message{}).Where("conversation_id = ?", conversation.ID).Count(&messageCount).Error)
assert.Equal(t, int64(1), messageCount)
var persisted model.Message
require.NoError(t, db.First(&persisted, message.ID).Error)
assert.JSONEq(t, `{"data":{"meeting_id":"meeting-1"}}`, string(persisted.ContentAttributes))
}
func TestWidgetHandler_ChatwootMessagesIndexReturnsLatestTwenty(t *testing.T) {
db, router, _ := setupWidgetHandlerTest(t)
_, inbox := seedWidgetHandlerData(t, db)
+21 -9
View File
@@ -26,7 +26,7 @@ func uintToStr(u uint) string {
//
// Supports two authentication paths:
// 1. Agent/User auth: JWT token (from query param or Authorization header)
// 2. Contact auth: pubsub_token + user_id (Chatwoot RoomChannel pattern)
// 2. Contact auth: pubsub_token (Chatwoot RoomChannel pattern)
type Handler struct {
hub *Hub
authenticator *wspkg.WSAuthenticator
@@ -55,7 +55,7 @@ func NewHandler(hub *Hub, authenticator *wspkg.WSAuthenticator) *Handler {
}
// ServeWS handles the WebSocket upgrade request at /ws.
// URL: /ws?token=<JWT_ACCESS_TOKEN> OR /ws?pubsub_token=<TOKEN>&user_id=<ID>
// URL: /ws?token=<JWT_ACCESS_TOKEN> OR /ws?pubsub_token=<TOKEN>
// On successful upgrade, the handler:
// 1. Authenticates the request via WSAuthenticator (JWT or pubsub_token)
// 2. Authorizes the user/contact for the requested account
@@ -272,8 +272,19 @@ func (h *Handler) handleSubscribe(client *Client, cmd CommandFrame) {
return
}
// Validate account_id matches the client's authenticated account
if identifier.AccountID != client.AccountID {
room := ""
if client.IsContact {
if identifier.Channel != ChannelRoom || identifier.PubsubToken == "" || identifier.PubsubToken != client.PubsubToken {
rejectData, _ := json.Marshal(RejectFrame{
Type: ServerRejectSubscribe,
Identifier: cmd.Identifier,
Reason: "invalid contact RoomChannel subscription",
})
client.Send <- rejectData
return
}
room = pubsubTokenRoomName(identifier.PubsubToken)
} else if identifier.AccountID != client.AccountID {
rejectData, _ := json.Marshal(RejectFrame{
Type: ServerRejectSubscribe,
Identifier: cmd.Identifier,
@@ -283,14 +294,15 @@ func (h *Handler) handleSubscribe(client *Client, cmd CommandFrame) {
return
}
// Determine room name based on channel type (uses Hub's canonical naming)
room := ""
switch identifier.Channel {
case ChannelAccount, ChannelRoom:
// Determine room name based on channel type (uses Hub's canonical naming).
// Contact RoomChannel was resolved above from its authenticated token.
switch {
case room != "":
case identifier.Channel == ChannelAccount || identifier.Channel == ChannelRoom:
// RoomChannel is Chatwoot's single-subscription model — it maps
// to the account room (all account-level events are delivered).
room = accountRoomName(identifier.AccountID)
case ChannelConversation:
case identifier.Channel == ChannelConversation:
if identifier.ConversationID == 0 {
rejectData, _ := json.Marshal(RejectFrame{
Type: ServerRejectSubscribe,
+9 -10
View File
@@ -180,21 +180,20 @@ func (h *Hub) Register(c *Client) {
h.clients[c.ID] = c
// Auto-subscribe to account room on connect
roomName := accountRoomName(c.AccountID)
h.subscribeClient(c.ID, roomName)
c.SubscribedRooms[roomName] = true
if c.IsContact && c.PubsubToken != "" {
tokenRoom := pubsubTokenRoomName(c.PubsubToken)
h.subscribeClient(c.ID, tokenRoom)
c.SubscribedRooms[tokenRoom] = true
} else if !c.IsContact {
roomName := accountRoomName(c.AccountID)
h.subscribeClient(c.ID, roomName)
c.SubscribedRooms[roomName] = true
}
// Set up presence tracking
if h.presenceMgr != nil {
if c.IsContact {
h.presenceMgr.OnContactConnect(context.Background(), c.ContactID, c.AccountID)
// Also auto-subscribe to pubsub_token room (Chatwoot RoomChannel pattern)
if c.PubsubToken != "" {
tokenRoom := pubsubTokenRoomName(c.PubsubToken)
h.subscribeClient(c.ID, tokenRoom)
c.SubscribedRooms[tokenRoom] = true
}
} else {
cancelFn := h.presenceMgr.OnAgentConnect(context.Background(), c.UserID, c.AccountID)
c.CancelPresence = cancelFn
@@ -61,8 +61,8 @@ func TestHub_Register_ContactWithPresence(t *testing.T) {
hub.Register(client)
assert.Contains(t, hub.clients, client.ID)
// Contact should be auto-subscribed to account room + pubsub_token room
assert.True(t, client.SubscribedRooms[accountRoomName(10)])
// Contacts only receive events addressed to their own pubsub token.
assert.False(t, client.SubscribedRooms[accountRoomName(10)])
assert.True(t, client.SubscribedRooms[pubsubTokenRoomName("token123")])
hub.Unregister(client)
+3 -2
View File
@@ -34,7 +34,7 @@ const (
// ServerConfirmSubscribe acknowledges a successful subscription
ServerConfirmSubscribe ServerMessageType = "confirm_subscription"
// ServerConfirmUnsubscribe acknowledges a successful unsubscribe
ServerConfirmUnsubscribe ServerMessageType = "confirm_unsubscribe" // NOTE: ActionCable uses confirm_subscription for both sub and unsub
ServerConfirmUnsubscribe ServerMessageType = "confirm_unsubscribe" // NOTE: ActionCable uses confirm_subscription for both sub and unsub
// ServerRejectSubscribe rejects a subscription attempt
ServerRejectSubscribe ServerMessageType = "reject_subscription"
// ServerPing is a heartbeat response
@@ -61,13 +61,14 @@ type ChannelIdentifier struct {
Channel string `json:"channel"` // "AccountChannel" or "ConversationChannel"
AccountID uint `json:"account_id"` // required for both channels
ConversationID uint `json:"conversation_id,omitempty"` // required for ConversationChannel
PubsubToken string `json:"pubsub_token,omitempty"` // required for contact RoomChannel
}
// Channel name constants (ActionCable naming style)
const (
ChannelAccount = "AccountChannel"
ChannelConversation = "ConversationChannel"
ChannelRoom = "RoomChannel" // Chatwoot single-subscription channel
ChannelRoom = "RoomChannel" // Chatwoot single-subscription channel
)
// --- Server → Client Frames ---
+250
View File
@@ -1,8 +1,10 @@
package ws
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -19,9 +21,15 @@ import (
"gorm.io/gorm"
"github.com/gochat/gochat/internal/auth"
"github.com/gochat/gochat/internal/channel"
"github.com/gochat/gochat/internal/config"
v1 "github.com/gochat/gochat/internal/handler/api/v1"
widgethandler "github.com/gochat/gochat/internal/handler/widget"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
wspkg "github.com/gochat/gochat/internal/ws"
"github.com/gochat/gochat/internal/wsevent"
)
// --- Protocol Tests ---
@@ -268,6 +276,8 @@ func TestHub_SendToAccountSanitizesVisitorIdentity(t *testing.T) {
visitor.IsContact = true
visitor.Identifier = `{"channel":"AccountChannel","account_id":10}`
hub.Register(visitor)
hub.subscribeClient(visitor.ID, accountRoomName(10))
visitor.SubscribedRooms[accountRoomName(10)] = true
hub.SendToAccount(10, []byte(`{"event":"message.created","data":{"content":"same reply","sender_type":"AgentBot","sender_id":7,"ai_takeover_active":true,"additional_attributes":{"agent_name":"Captain"}}}`))
@@ -470,6 +480,38 @@ func TestUintToStr(t *testing.T) {
assert.Equal(t, "0", uintToStr(0))
}
func TestHandleSubscribe_ContactRoomUsesAuthenticatedPubsubToken(t *testing.T) {
hub := NewHubSimple()
handler := NewHandler(hub, nil)
client := NewClient(9, 10, nil, hub)
client.IsContact = true
client.PubsubToken = "visitor-token"
identifier, err := json.Marshal(ChannelIdentifier{
Channel: ChannelRoom,
PubsubToken: "visitor-token",
})
require.NoError(t, err)
handler.handleSubscribe(client, CommandFrame{Command: CommandSubscribe, Identifier: string(identifier)})
assert.True(t, client.SubscribedRooms[pubsubTokenRoomName("visitor-token")])
var confirm ConfirmFrame
require.NoError(t, json.Unmarshal(<-client.Send, &confirm))
assert.Equal(t, ServerConfirmSubscribe, confirm.Type)
other := NewClient(9, 10, nil, hub)
other.IsContact = true
other.PubsubToken = "visitor-token"
mismatch, err := json.Marshal(ChannelIdentifier{Channel: ChannelRoom, PubsubToken: "other-token"})
require.NoError(t, err)
handler.handleSubscribe(other, CommandFrame{Command: CommandSubscribe, Identifier: string(mismatch)})
assert.False(t, other.SubscribedRooms[pubsubTokenRoomName("other-token")])
var reject RejectFrame
require.NoError(t, json.Unmarshal(<-other.Send, &reject))
assert.Equal(t, ServerRejectSubscribe, reject.Type)
}
// --- Integration: ServeWS with real WebSocket ---
// createTestHandler creates a Handler with a real WSAuthenticator using a test JWT config.
@@ -587,6 +629,214 @@ func TestServeWS_ValidToken_Success(t *testing.T) {
assert.Equal(t, ServerPing, pingResp.Type)
}
func TestServeCable_WidgetReceivesTokenRoomEvent(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Contact{}, &model.Inbox{}, &model.ContactInbox{}))
account := model.Account{Name: "Widget account"}
require.NoError(t, db.Create(&account).Error)
contact := model.Contact{AccountID: account.ID, Name: "Visitor"}
require.NoError(t, db.Create(&contact).Error)
inbox := model.Inbox{AccountID: account.ID, Name: "Website", ChannelType: "Channel::WebWidget", Enabled: true}
require.NoError(t, db.Create(&inbox).Error)
contactInbox := model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, PubsubToken: "visitor-token"}
require.NoError(t, db.Create(&contactInbox).Error)
hub := NewHubSimple()
authenticator := wspkg.NewWSAuthenticator(nil, repository.NewContactInboxRepo(db), db)
handler := NewHandler(hub, authenticator)
router := gin.New()
router.GET("/cable", handler.ServeCable)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
conn, _, err := websocket.DefaultDialer.Dial(
"ws"+strings.TrimPrefix(server.URL, "http")+"/cable?pubsub_token=visitor-token",
nil,
)
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
require.NoError(t, conn.SetReadDeadline(time.Now().Add(2*time.Second)))
_, _, err = conn.ReadMessage() // welcome
require.NoError(t, err)
identifier, err := json.Marshal(ChannelIdentifier{Channel: ChannelRoom, PubsubToken: "visitor-token"})
require.NoError(t, err)
command, err := json.Marshal(CommandFrame{Command: CommandSubscribe, Identifier: string(identifier)})
require.NoError(t, err)
require.NoError(t, conn.WriteMessage(websocket.TextMessage, command))
_, confirmation, err := conn.ReadMessage()
require.NoError(t, err)
var confirm ConfirmFrame
require.NoError(t, json.Unmarshal(confirmation, &confirm))
assert.Equal(t, ServerConfirmSubscribe, confirm.Type)
hub.SendToRoom(pubsubTokenRoomName("visitor-token"), []byte(`{"event":"message.created","data":{"id":12,"content":"Dashboard reply","message_type":1,"conversation_id":42}}`))
_, message, err := conn.ReadMessage()
require.NoError(t, err)
var delivered struct {
Identifier string `json:"identifier"`
Message json.RawMessage `json:"message"`
}
require.NoError(t, json.Unmarshal(message, &delivered))
assert.JSONEq(t, string(identifier), delivered.Identifier)
var event wspkg.WSMessage
require.NoError(t, json.Unmarshal(delivered.Message, &event))
assert.Equal(t, wspkg.EventMessageCreated, event.Event)
payload := event.Data.(map[string]interface{})
assert.Equal(t, "Dashboard reply", payload["content"])
assert.Equal(t, float64(1), payload["message_type"])
assert.Equal(t, float64(42), payload["conversation_id"])
}
func TestDashboardOutgoingReachesDashboardAndReconnectedWidget(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&model.Account{}, &model.User{}, &model.Inbox{}, &model.Contact{}, &model.ContactInbox{},
&model.Conversation{}, &model.Message{}, &model.Attachment{},
))
account := &model.Account{Name: "Realtime account", Active: true}
require.NoError(t, db.Create(account).Error)
inbox := &model.Inbox{AccountID: account.ID, Name: "Website", ChannelType: "web_widget", Enabled: true}
require.NoError(t, db.Create(inbox).Error)
contact := &model.Contact{AccountID: account.ID, Name: "Visitor"}
require.NoError(t, db.Create(contact).Error)
contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, PubsubToken: "visitor-token"}
require.NoError(t, db.Create(contactInbox).Error)
displayID := uint(42)
conversation := &model.Conversation{
AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID,
DisplayID: &displayID, Status: "open", ChannelType: "web_widget", Channel: "web_widget",
}
require.NoError(t, db.Create(conversation).Error)
hub := NewHubSimple()
dispatcher := channel.NewDispatcher()
dispatcher.Register(wsevent.New(wspkg.NewEventPublisherLocal(hub, nil)))
messageService := service.NewMessageService(repository.NewMessageRepo(db), dispatcher, nil)
messageHandler := v1.NewMessageHandler(messageService)
widgetService := service.NewWidgetService(
repository.NewInboxRepo(db), repository.NewContactRepo(db), repository.NewContactInboxRepo(db),
repository.NewConversationRepo(db), repository.NewMessageRepo(db), nil, nil, nil, nil, nil, nil, nil, nil,
)
widgetHandler := widgethandler.NewHandler(widgetService)
jwtService := auth.NewJWTService(&config.JWTConfig{Secret: "dashboard-widget-chain", ExpiryHours: 1, AccessExpiryMinutes: 60})
wsHandler := NewHandler(hub, wspkg.NewWSAuthenticator(jwtService, repository.NewContactInboxRepo(db)))
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("user_id", uint(7))
c.Next()
})
router.GET("/cable", wsHandler.ServeCable)
router.POST("/api/v1/accounts/:account_id/conversations/:conversation_id/messages", messageHandler.Create)
router.GET("/api/v1/widget/messages", widgetHandler.GetLatestMessages)
server := httptest.NewServer(router)
t.Cleanup(server.Close)
user := &model.User{Base: model.Base{ID: 7}, Provider: "local"}
tokenPair, err := jwtService.GenerateTokenPair(user, account.ID, "agent")
require.NoError(t, err)
dashboard := dialCable(t, server.URL, "?token="+tokenPair.AccessToken)
t.Cleanup(func() { _ = dashboard.Close() })
dashboardIdentifier := subscribeCable(t, dashboard, ChannelIdentifier{Channel: ChannelRoom, AccountID: account.ID})
visitor := dialCable(t, server.URL, "?pubsub_token="+contactInbox.PubsubToken)
mismatch, err := json.Marshal(ChannelIdentifier{Channel: ChannelRoom, PubsubToken: "other-token"})
require.NoError(t, err)
require.NoError(t, visitor.WriteJSON(CommandFrame{Command: CommandSubscribe, Identifier: string(mismatch)}))
var rejected RejectFrame
require.NoError(t, visitor.ReadJSON(&rejected))
assert.Equal(t, ServerRejectSubscribe, rejected.Type)
visitorIdentifier := subscribeCable(t, visitor, ChannelIdentifier{Channel: ChannelRoom, PubsubToken: contactInbox.PubsubToken})
createDashboardMessage(t, server.URL, account.ID, displayID, "dashboard reply one")
assertCableMessage(t, dashboard, dashboardIdentifier, "dashboard reply one", displayID)
assertCableMessage(t, visitor, visitorIdentifier, "dashboard reply one", displayID)
refreshRequest, err := http.NewRequest(http.MethodGet, server.URL+"/api/v1/widget/messages", nil)
require.NoError(t, err)
refreshRequest.Header.Set("X-Auth-Token", contactInbox.PubsubToken)
refreshResponse, err := http.DefaultClient.Do(refreshRequest)
require.NoError(t, err)
defer refreshResponse.Body.Close()
require.Equal(t, http.StatusOK, refreshResponse.StatusCode)
var refresh struct {
Payload []struct {
Content string `json:"content"`
ConversationID uint `json:"conversation_id"`
} `json:"payload"`
}
require.NoError(t, json.NewDecoder(refreshResponse.Body).Decode(&refresh))
require.Len(t, refresh.Payload, 1)
assert.Equal(t, "dashboard reply one", refresh.Payload[0].Content)
assert.Equal(t, displayID, refresh.Payload[0].ConversationID)
require.NoError(t, visitor.Close())
visitor = dialCable(t, server.URL, "?pubsub_token="+contactInbox.PubsubToken)
t.Cleanup(func() { _ = visitor.Close() })
visitorIdentifier = subscribeCable(t, visitor, ChannelIdentifier{Channel: ChannelRoom, PubsubToken: contactInbox.PubsubToken})
createDashboardMessage(t, server.URL, account.ID, displayID, "dashboard reply after reconnect")
assertCableMessage(t, dashboard, dashboardIdentifier, "dashboard reply after reconnect", displayID)
assertCableMessage(t, visitor, visitorIdentifier, "dashboard reply after reconnect", displayID)
}
func dialCable(t *testing.T, serverURL, query string) *websocket.Conn {
t.Helper()
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(serverURL, "http")+"/cable"+query, nil)
require.NoError(t, err)
require.NoError(t, conn.SetReadDeadline(time.Now().Add(2*time.Second)))
var welcome WelcomeFrame
require.NoError(t, conn.ReadJSON(&welcome))
require.Equal(t, ServerWelcome, welcome.Type)
return conn
}
func subscribeCable(t *testing.T, conn *websocket.Conn, identifier ChannelIdentifier) string {
t.Helper()
raw, err := json.Marshal(identifier)
require.NoError(t, err)
require.NoError(t, conn.WriteJSON(CommandFrame{Command: CommandSubscribe, Identifier: string(raw)}))
var confirmed ConfirmFrame
require.NoError(t, conn.ReadJSON(&confirmed))
require.Equal(t, ServerConfirmSubscribe, confirmed.Type)
return string(raw)
}
func createDashboardMessage(t *testing.T, serverURL string, accountID, displayID uint, content string) {
t.Helper()
body, err := json.Marshal(map[string]any{"content": content, "message_type": "outgoing"})
require.NoError(t, err)
request, err := http.NewRequest(http.MethodPost,
fmt.Sprintf("%s/api/v1/accounts/%d/conversations/%d/messages", serverURL, accountID, displayID), bytes.NewReader(body))
require.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
response, err := http.DefaultClient.Do(request)
require.NoError(t, err)
defer response.Body.Close()
require.Equal(t, http.StatusOK, response.StatusCode)
}
func assertCableMessage(t *testing.T, conn *websocket.Conn, identifier, content string, displayID uint) {
t.Helper()
var delivered struct {
Identifier string `json:"identifier"`
Message json.RawMessage `json:"message"`
}
require.NoError(t, conn.ReadJSON(&delivered))
assert.JSONEq(t, identifier, delivered.Identifier)
var event wspkg.WSMessage
require.NoError(t, json.Unmarshal(delivered.Message, &event))
require.Equal(t, wspkg.EventMessageCreated, event.Event)
payload := event.Data.(map[string]interface{})
assert.Equal(t, content, payload["content"])
assert.Equal(t, float64(1), payload["message_type"])
assert.Equal(t, float64(displayID), payload["conversation_id"])
}
func TestRemoteSocketRechecksAccessWhenDisconnectPublishFails(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})