|
|
|
@@ -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{})
|
|
|
|
|