fix(HH-581): keep widget websocket on visitor auth (#152)
Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
@@ -3,6 +3,7 @@ package app
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"github.com/alicebob/miniredis/v2"
|
"github.com/alicebob/miniredis/v2"
|
||||||
miniredisserver "github.com/alicebob/miniredis/v2/server"
|
miniredisserver "github.com/alicebob/miniredis/v2/server"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/gorm"
|
"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}
|
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(&user).Error)
|
||||||
require.NoError(t, application.db.Create(&model.AccountUser{UserID: user.ID, AccountID: account.ID, Role: "administrator"}).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 := handlerws.NewClient(user.ID, account.ID, nil, application.wsHub)
|
||||||
dashboard.Identifier = fmt.Sprintf(`{"channel":"AccountChannel","account_id":%d}`, account.ID)
|
dashboard.Identifier = fmt.Sprintf(`{"channel":"AccountChannel","account_id":%d}`, account.ID)
|
||||||
application.wsHub.Register(dashboard)
|
application.wsHub.Register(dashboard)
|
||||||
visitorToken := fmt.Sprintf("bootstrap-visitor-%d", stamp)
|
|
||||||
visitor := handlerws.NewClient(0, account.ID, nil, application.wsHub)
|
visitor := handlerws.NewClient(0, account.ID, nil, application.wsHub)
|
||||||
visitor.IsContact = true
|
visitor.IsContact = true
|
||||||
visitor.PubsubToken = visitorToken
|
visitor.PubsubToken = visitorToken
|
||||||
@@ -94,6 +101,22 @@ func TestBootstrapRoutesDurableRealtimeToHubAndSSEWithoutDuplicates(t *testing.T
|
|||||||
|
|
||||||
server := httptest.NewServer(application.Handler())
|
server := httptest.NewServer(application.Handler())
|
||||||
t.Cleanup(server.Close)
|
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())
|
requestCtx, cancelRequest := context.WithCancel(context.Background())
|
||||||
t.Cleanup(cancelRequest)
|
t.Cleanup(cancelRequest)
|
||||||
req, err := http.NewRequestWithContext(requestCtx, http.MethodGet, fmt.Sprintf("%s/api/v1/accounts/%d/events", server.URL, account.ID), nil)
|
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.True(t, processed)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
requireHubEvent(t, visitor.Send)
|
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())
|
require.True(t, tokenFailed.Load())
|
||||||
assertNoHubEvent(t, dashboard.Send)
|
assertNoHubEvent(t, dashboard.Send)
|
||||||
assertNoHubEvent(t, visitor.Send)
|
assertNoHubEvent(t, visitor.Send)
|
||||||
|
|||||||
@@ -11,15 +11,18 @@ class BaseActionCableConnector {
|
|||||||
app,
|
app,
|
||||||
pubsubToken,
|
pubsubToken,
|
||||||
websocketHost = '',
|
websocketHost = '',
|
||||||
presenceInterval = PRESENCE_INTERVAL
|
presenceInterval = PRESENCE_INTERVAL,
|
||||||
|
useSessionAuth = true
|
||||||
) {
|
) {
|
||||||
this.consumer = null;
|
this.consumer = null;
|
||||||
this.subscription = null;
|
this.subscription = null;
|
||||||
this.websocketHost = websocketHost;
|
this.websocketHost = websocketHost;
|
||||||
this.pubsubToken = pubsubToken;
|
this.pubsubToken = pubsubToken;
|
||||||
|
this.useSessionAuth = useSessionAuth;
|
||||||
this.app = app;
|
this.app = app;
|
||||||
this.events = {};
|
this.events = {};
|
||||||
this.reconnectTimer = null;
|
this.reconnectTimer = null;
|
||||||
|
this.ticketFailureLogged = false;
|
||||||
this.isAValidEvent = () => true;
|
this.isAValidEvent = () => true;
|
||||||
this.connect();
|
this.connect();
|
||||||
this.triggerPresenceInterval = () => {
|
this.triggerPresenceInterval = () => {
|
||||||
@@ -34,14 +37,20 @@ class BaseActionCableConnector {
|
|||||||
async connect() {
|
async connect() {
|
||||||
const wsOrigin = this.websocketHost || window.location.origin;
|
const wsOrigin = this.websocketHost || window.location.origin;
|
||||||
let websocketURL = `${wsOrigin}/cable`;
|
let websocketURL = `${wsOrigin}/cable`;
|
||||||
if (Cookies.get('cw_d_session_state')) {
|
if (this.useSessionAuth && Cookies.get('cw_d_session_state')) {
|
||||||
try {
|
try {
|
||||||
const response = await window.axios.post('/api/v1/auth/ws_ticket');
|
const response = await window.axios.post('/api/v1/auth/ws_ticket');
|
||||||
const ticket = response.data?.data?.ticket;
|
const ticket = response.data?.data?.ticket;
|
||||||
if (!ticket) throw new Error('missing websocket ticket');
|
if (!ticket) throw new Error('missing websocket ticket');
|
||||||
websocketURL += `?ticket=${encodeURIComponent(ticket)}`;
|
websocketURL += `?ticket=${encodeURIComponent(ticket)}`;
|
||||||
this.usesWSTicket = true;
|
this.usesWSTicket = true;
|
||||||
|
this.ticketFailureLogged = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!this.ticketFailureLogged) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('WebSocket ticket exchange failed; retrying');
|
||||||
|
this.ticketFailureLogged = true;
|
||||||
|
}
|
||||||
this.initReconnectTimer();
|
this.initReconnectTimer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
it('continues consuming widget events after ActionCable reconnects', async () => {
|
||||||
getCookie.mockReturnValue(undefined);
|
getCookie.mockReturnValue(undefined);
|
||||||
const connector = new BaseActionCableConnector(app, 'visitor-token');
|
const connector = new BaseActionCableConnector(app, 'visitor-token');
|
||||||
@@ -138,9 +156,19 @@ describe('BaseActionCableConnector', () => {
|
|||||||
getCookie.mockReturnValue('1');
|
getCookie.mockReturnValue('1');
|
||||||
post.mockRejectedValue({ response: { status: 401 } });
|
post.mockRejectedValue({ response: { status: 401 } });
|
||||||
|
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
new BaseActionCableConnector(app, 'pubsub');
|
new BaseActionCableConnector(app, 'pubsub');
|
||||||
await vi.waitFor(() => expect(post).toHaveBeenCalledOnce());
|
await vi.waitFor(() => expect(post).toHaveBeenCalledOnce());
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
await vi.waitFor(() => expect(post).toHaveBeenCalledTimes(2));
|
||||||
|
|
||||||
expect(createConsumer).not.toHaveBeenCalled();
|
expect(createConsumer).not.toHaveBeenCalled();
|
||||||
|
expect(warn).toHaveBeenCalledOnce();
|
||||||
|
expect(warn).toHaveBeenCalledWith(
|
||||||
|
'WebSocket ticket exchange failed; retrying'
|
||||||
|
);
|
||||||
|
warn.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const WIDGET_PRESENCE_INTERVAL = 60000;
|
|||||||
|
|
||||||
class ActionCableConnector extends BaseActionCableConnector {
|
class ActionCableConnector extends BaseActionCableConnector {
|
||||||
constructor(app, pubsubToken) {
|
constructor(app, pubsubToken) {
|
||||||
super(app, pubsubToken, '', WIDGET_PRESENCE_INTERVAL);
|
super(app, pubsubToken, '', WIDGET_PRESENCE_INTERVAL, false);
|
||||||
this.events = {
|
this.events = {
|
||||||
'message.created': this.onMessageCreated,
|
'message.created': this.onMessageCreated,
|
||||||
'message.updated': this.onMessageUpdated,
|
'message.updated': this.onMessageUpdated,
|
||||||
|
|||||||
@@ -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 () => {
|
it('emits message side effects only once when realtime replays a message', async () => {
|
||||||
const message = {
|
const message = {
|
||||||
id: 42,
|
id: 42,
|
||||||
|
|||||||
Reference in New Issue
Block a user