From 2c6bee2b3751e3cdf394826d401c8fea384a3586 Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 24 Aug 2026 10:54:38 +0800 Subject: [PATCH] fix(HH-581): keep widget websocket on visitor auth (#152) Co-authored-by: Rogee --- .../internal/app/bootstrap_realtime_test.go | 35 ++++++++++++++++++- .../helpers/BaseActionCableConnector.js | 13 +++++-- .../specs/BaseActionCableConnector.spec.js | 28 +++++++++++++++ .../javascript/widget/helpers/actionCable.js | 2 +- .../widget/helpers/specs/actionCable.spec.js | 4 +++ 5 files changed, 78 insertions(+), 4 deletions(-) diff --git a/backend/internal/app/bootstrap_realtime_test.go b/backend/internal/app/bootstrap_realtime_test.go index da651eae..0d586194 100644 --- a/backend/internal/app/bootstrap_realtime_test.go +++ b/backend/internal/app/bootstrap_realtime_test.go @@ -3,6 +3,7 @@ package app import ( "bufio" "context" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -15,6 +16,7 @@ import ( "github.com/alicebob/miniredis/v2" miniredisserver "github.com/alicebob/miniredis/v2/server" + "github.com/gorilla/websocket" "github.com/stretchr/testify/require" "gorm.io/driver/postgres" "gorm.io/gorm" @@ -77,11 +79,16 @@ func TestBootstrapRoutesDurableRealtimeToHubAndSSEWithoutDuplicates(t *testing.T user := model.User{AccountID: account.ID, Name: "Bootstrap SSE", Email: fmt.Sprintf("bootstrap-sse-%d@example.test", stamp), Password: "unused", Provider: "email", Active: true} require.NoError(t, application.db.Create(&user).Error) require.NoError(t, application.db.Create(&model.AccountUser{UserID: user.ID, AccountID: account.ID, Role: "administrator"}).Error) + visitorToken := fmt.Sprintf("bootstrap-visitor-%d", stamp) + inbox := model.Inbox{AccountID: account.ID, Name: "Bootstrap widget", ChannelType: "Channel::WebWidget", Enabled: true} + require.NoError(t, application.db.Create(&inbox).Error) + contact := model.Contact{AccountID: account.ID, Name: "Bootstrap visitor"} + require.NoError(t, application.db.Create(&contact).Error) + require.NoError(t, application.db.Create(&model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, PubsubToken: visitorToken}).Error) dashboard := handlerws.NewClient(user.ID, account.ID, nil, application.wsHub) dashboard.Identifier = fmt.Sprintf(`{"channel":"AccountChannel","account_id":%d}`, account.ID) application.wsHub.Register(dashboard) - visitorToken := fmt.Sprintf("bootstrap-visitor-%d", stamp) visitor := handlerws.NewClient(0, account.ID, nil, application.wsHub) visitor.IsContact = true visitor.PubsubToken = visitorToken @@ -94,6 +101,22 @@ func TestBootstrapRoutesDurableRealtimeToHubAndSSEWithoutDuplicates(t *testing.T server := httptest.NewServer(application.Handler()) t.Cleanup(server.Close) + visitorConn, _, err := websocket.DefaultDialer.Dial( + "ws"+strings.TrimPrefix(server.URL, "http")+"/cable?pubsub_token="+url.QueryEscape(visitorToken), + nil, + ) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, visitorConn.Close()) }) + require.NoError(t, visitorConn.SetReadDeadline(time.Now().Add(time.Second))) + _, _, err = visitorConn.ReadMessage() + require.NoError(t, err) + identifier := fmt.Sprintf(`{"channel":"RoomChannel","pubsub_token":%q}`, visitorToken) + require.NoError(t, visitorConn.WriteJSON(map[string]any{"command": "subscribe", "identifier": identifier})) + var confirmation map[string]any + require.NoError(t, visitorConn.ReadJSON(&confirmation)) + require.Equal(t, "confirm_subscription", confirmation["type"]) + require.JSONEq(t, identifier, confirmation["identifier"].(string)) + requestCtx, cancelRequest := context.WithCancel(context.Background()) t.Cleanup(cancelRequest) req, err := http.NewRequestWithContext(requestCtx, http.MethodGet, fmt.Sprintf("%s/api/v1/accounts/%d/events", server.URL, account.ID), nil) @@ -144,6 +167,16 @@ func TestBootstrapRoutesDurableRealtimeToHubAndSSEWithoutDuplicates(t *testing.T require.True(t, processed) require.NoError(t, err) requireHubEvent(t, visitor.Send) + var delivered struct { + Identifier string `json:"identifier"` + Message json.RawMessage `json:"message"` + } + require.NoError(t, visitorConn.ReadJSON(&delivered)) + require.JSONEq(t, identifier, delivered.Identifier) + var widgetEvent wspkg.WSMessage + require.NoError(t, json.Unmarshal(delivered.Message, &widgetEvent)) + require.Equal(t, wspkg.EventMessageCreated, widgetEvent.Event) + require.Equal(t, float64(7), widgetEvent.Data.(map[string]any)["id"]) require.True(t, tokenFailed.Load()) assertNoHubEvent(t, dashboard.Send) assertNoHubEvent(t, visitor.Send) diff --git a/frontend/app/javascript/shared/helpers/BaseActionCableConnector.js b/frontend/app/javascript/shared/helpers/BaseActionCableConnector.js index 2526f48b..f9d28f66 100644 --- a/frontend/app/javascript/shared/helpers/BaseActionCableConnector.js +++ b/frontend/app/javascript/shared/helpers/BaseActionCableConnector.js @@ -11,15 +11,18 @@ class BaseActionCableConnector { app, pubsubToken, websocketHost = '', - presenceInterval = PRESENCE_INTERVAL + presenceInterval = PRESENCE_INTERVAL, + useSessionAuth = true ) { this.consumer = null; this.subscription = null; this.websocketHost = websocketHost; this.pubsubToken = pubsubToken; + this.useSessionAuth = useSessionAuth; this.app = app; this.events = {}; this.reconnectTimer = null; + this.ticketFailureLogged = false; this.isAValidEvent = () => true; this.connect(); this.triggerPresenceInterval = () => { @@ -34,14 +37,20 @@ class BaseActionCableConnector { async connect() { const wsOrigin = this.websocketHost || window.location.origin; let websocketURL = `${wsOrigin}/cable`; - if (Cookies.get('cw_d_session_state')) { + if (this.useSessionAuth && Cookies.get('cw_d_session_state')) { try { const response = await window.axios.post('/api/v1/auth/ws_ticket'); const ticket = response.data?.data?.ticket; if (!ticket) throw new Error('missing websocket ticket'); websocketURL += `?ticket=${encodeURIComponent(ticket)}`; this.usesWSTicket = true; + this.ticketFailureLogged = false; } catch (error) { + if (!this.ticketFailureLogged) { + // eslint-disable-next-line no-console + console.warn('WebSocket ticket exchange failed; retrying'); + this.ticketFailureLogged = true; + } this.initReconnectTimer(); return; } diff --git a/frontend/app/javascript/shared/helpers/specs/BaseActionCableConnector.spec.js b/frontend/app/javascript/shared/helpers/specs/BaseActionCableConnector.spec.js index efd4dbce..3ae4b609 100644 --- a/frontend/app/javascript/shared/helpers/specs/BaseActionCableConnector.spec.js +++ b/frontend/app/javascript/shared/helpers/specs/BaseActionCableConnector.spec.js @@ -66,6 +66,24 @@ describe('BaseActionCableConnector', () => { ); }); + it('keeps widget token auth when a dashboard cookie shares its origin', () => { + getCookie.mockReturnValue('dashboard-session'); + + const connector = new BaseActionCableConnector( + app, + 'visitor token', + '', + 20000, + false + ); + + expect(post).not.toHaveBeenCalled(); + expect(createConsumer).toHaveBeenCalledWith( + `${window.location.origin}/cable?pubsub_token=visitor%20token` + ); + connector.disconnect(); + }); + it('continues consuming widget events after ActionCable reconnects', async () => { getCookie.mockReturnValue(undefined); const connector = new BaseActionCableConnector(app, 'visitor-token'); @@ -138,9 +156,19 @@ describe('BaseActionCableConnector', () => { getCookie.mockReturnValue('1'); post.mockRejectedValue({ response: { status: 401 } }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + new BaseActionCableConnector(app, 'pubsub'); await vi.waitFor(() => expect(post).toHaveBeenCalledOnce()); + await vi.advanceTimersByTimeAsync(1000); + await vi.waitFor(() => expect(post).toHaveBeenCalledTimes(2)); + expect(createConsumer).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith( + 'WebSocket ticket exchange failed; retrying' + ); + warn.mockRestore(); }); }); diff --git a/frontend/app/javascript/widget/helpers/actionCable.js b/frontend/app/javascript/widget/helpers/actionCable.js index 298337c3..83900bbe 100644 --- a/frontend/app/javascript/widget/helpers/actionCable.js +++ b/frontend/app/javascript/widget/helpers/actionCable.js @@ -17,7 +17,7 @@ const WIDGET_PRESENCE_INTERVAL = 60000; class ActionCableConnector extends BaseActionCableConnector { constructor(app, pubsubToken) { - super(app, pubsubToken, '', WIDGET_PRESENCE_INTERVAL); + super(app, pubsubToken, '', WIDGET_PRESENCE_INTERVAL, false); this.events = { 'message.created': this.onMessageCreated, 'message.updated': this.onMessageUpdated, diff --git a/frontend/app/javascript/widget/helpers/specs/actionCable.spec.js b/frontend/app/javascript/widget/helpers/specs/actionCable.spec.js index 858e69a4..5ed3cb90 100644 --- a/frontend/app/javascript/widget/helpers/specs/actionCable.spec.js +++ b/frontend/app/javascript/widget/helpers/specs/actionCable.spec.js @@ -53,6 +53,10 @@ describe('Widget ActionCableConnector', () => { }); }; + it('uses visitor-token auth even when a dashboard session shares the origin', () => { + expect(actionCable.useSessionAuth).toBe(false); + }); + it('emits message side effects only once when realtime replays a message', async () => { const message = { id: 42,