Compare commits

...

2 Commits

Author SHA1 Message Date
Tanmay Deep Sharma
6730093a84 Merge branch 'develop' into feat/widget-visitor-token 2026-09-28 14:04:55 -07:00
Tanmay Deep Sharma
d63a71c59d feat(widget): carry the visitor's channel in the widget token and accept sessions without a contact 2026-09-28 12:34:30 -07:00
29 changed files with 566 additions and 95 deletions

View File

@@ -2,7 +2,7 @@
# For Specific Channels like whatsapp, email etc . it smartly generated appropriate the source id when none is provided.
class ContactInboxBuilder
pattr_initialize [:contact, :inbox, :source_id, { hmac_verified: false }]
pattr_initialize [:contact, :inbox, :source_id, :pubsub_token, { hmac_verified: false }]
def perform
@source_id ||= generate_source_id
@@ -65,7 +65,8 @@ class ContactInboxBuilder
source_id: @source_id
}
::ContactInbox.where(attrs).first_or_create!(hmac_verified: hmac_verified || false)
# A nil pubsub_token is filled in by has_secure_token; the widget supplies the one its visitor already streams on.
::ContactInbox.where(attrs).first_or_create!(hmac_verified: hmac_verified || false, pubsub_token: pubsub_token)
rescue ActiveRecord::RecordNotUnique
Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}")
update_old_contact_inbox

View File

@@ -3,7 +3,7 @@
# for contact inbox logic it uses the contact inbox builder
class ContactInboxWithContactBuilder
pattr_initialize [:inbox!, :contact_attributes!, :source_id, :hmac_verified]
pattr_initialize [:inbox!, :contact_attributes!, :source_id, :pubsub_token, :hmac_verified]
def perform
find_or_create_contact_and_contact_inbox
@@ -40,6 +40,7 @@ class ContactInboxWithContactBuilder
contact: @contact,
inbox: @inbox,
source_id: @source_id,
pubsub_token: pubsub_token,
hmac_verified: hmac_verified
).perform
end

View File

@@ -10,6 +10,7 @@ class RoomChannel < ApplicationCable::Channel
end
def update_presence
adopt_visitor_contact
update_subscription
broadcast_presence
end
@@ -30,7 +31,8 @@ class RoomChannel < ApplicationCable::Channel
end
def update_subscription
return if @current_account.blank?
# A visitor whose contact does not exist yet has nothing to track.
return if @current_user.blank?
::OnlineStatusTracker.update_presence(@current_account.id, @current_user.class.name, @current_user.id)
end
@@ -41,19 +43,35 @@ class RoomChannel < ApplicationCable::Channel
def current_user
@current_user ||= if params[:user_id].blank?
ContactInbox.find_by!(pubsub_token: pubsub_token).contact
ContactInbox.find_by(pubsub_token: pubsub_token)&.contact
else
User.find_by!(pubsub_token: pubsub_token, id: params[:user_id])
end
end
def current_account
return if current_user.blank?
@current_account ||= if @current_user.is_a? Contact
@current_account ||= if @current_user.is_a?(User)
@current_user.accounts.find(params[:account_id])
elsif @current_user
@current_user.account
else
@current_user.accounts.find(params[:account_id])
visitor_account
end
end
# Until the visitor's contact exists, the signed widget token is the only proof the stream is theirs;
# it also names the inbox whose agent presence they should see.
def visitor_account
visitor_token = ::Widget::TokenService.new(token: params[:auth_token]).decode_token
raise ActiveRecord::RecordNotFound if visitor_token[:pubsub_token].blank? || visitor_token[:pubsub_token] != pubsub_token
::Inbox.find(visitor_token[:inbox_id]).account
end
# The contact is created lazily (WebsiteTokenHelper#ensure_contact); pick it up on the next ping.
def adopt_visitor_contact
return if @current_user.present? || params[:user_id].present?
@current_user = ContactInbox.find_by(pubsub_token: pubsub_token)&.contact
end
end

View File

@@ -8,6 +8,8 @@ class Api::V1::Widget::BaseController < ApplicationController
private
def conversations
return ::Conversation.none if @contact_inbox.nil?
if @contact_inbox.hmac_verified?
verified_contact_inbox_ids = @contact.contact_inboxes.where(inbox_id: auth_token_params[:inbox_id], hmac_verified: true).map(&:id)
@conversations = @contact.conversations.where(contact_inbox_id: verified_contact_inbox_ids)

View File

@@ -1,9 +1,14 @@
class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController
include WidgetHelper
before_action :set_global_config
def create
build_contact
set_token
@contact_inbox ||= @web_widget.create_contact_inbox(
source_id: auth_token_params[:source_id], pubsub_token: auth_token_params[:pubsub_token], additional_attributes: additional_attributes
)
@contact = @contact_inbox.contact
@token, @pubsub_token = visitor_session(@web_widget, request.headers['X-Auth-Token'], auth_token_params, @contact_inbox)
end
private
@@ -18,25 +23,12 @@ class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController
)
end
# Unlike the other widget endpoints, this one also serves visitors without a token.
def set_contact
@contact_inbox = @web_widget.inbox.contact_inboxes.find_by(
source_id: auth_token_params[:source_id]
)
@contact_inbox = @web_widget.inbox.contact_inboxes.find_by(source_id: auth_token_params[:source_id])
@contact = @contact_inbox&.contact
end
def build_contact
return if @contact.present?
@contact_inbox = @web_widget.create_contact_inbox(additional_attributes)
@contact = @contact_inbox.contact
end
def set_token
payload = { source_id: @contact_inbox.source_id, inbox_id: @web_widget.inbox.id }
@token = ::Widget::TokenService.new(payload: payload).generate_token
end
def additional_attributes
if @web_widget.inbox.account.feature_enabled?('ip_lookup')
{ created_at_ip: request.remote_ip }

View File

@@ -3,6 +3,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
before_action :validate_hmac, only: [:set_user]
before_action :validate_hmac_for_identified_update, only: [:update]
before_action :ensure_contact, only: [:update, :set_user, :destroy_custom_attributes]
def show; end

View File

@@ -1,5 +1,6 @@
class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
include Events::Types
before_action :ensure_contact, only: [:create]
before_action :render_not_found_if_empty, only: [:toggle_typing, :toggle_status, :set_custom_attributes, :destroy_custom_attributes]
def index

View File

@@ -3,10 +3,4 @@ class Api::V1::Widget::DirectUploadsController < ActiveStorage::DirectUploadsCon
include WebsiteTokenHelper
before_action :set_web_widget
before_action :set_contact
def create
return if @contact.nil? || @current_account.nil?
super
end
end

View File

@@ -1,5 +1,8 @@
class Api::V1::Widget::EventsController < Api::V1::Widget::BaseController
include Events::Types
# Both events reach listeners that need the contact inbox: campaigns start a conversation on it,
# and webwidget_triggered webhooks/agent bots receive it.
before_action :ensure_contact
def create
Rails.configuration.dispatcher.dispatch(permitted_params[:name], Time.zone.now, contact_inbox: @contact_inbox,

View File

@@ -1,6 +1,6 @@
class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
before_action :prevent_reply_to_resolved_conversation, only: [:create]
before_action :set_conversation, only: [:create]
before_action :ensure_contact, :set_conversation, only: [:create]
before_action :set_message, only: [:update]
def index

View File

@@ -1,22 +1,41 @@
module WebsiteTokenHelper
def auth_token_params
@auth_token_params ||= ::Widget::TokenService.new(token: request.headers['X-Auth-Token']).decode_token
@auth_token_params ||= begin
decoded = ::Widget::TokenService.new(token: request.headers['X-Auth-Token']).decode_token
# A token only identifies a session in the inbox it was minted for.
decoded[:inbox_id] == @web_widget.inbox.id ? decoded : {}
end
end
def set_web_widget
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
@current_account = @web_widget.inbox.account
render json: { error: 'Account is suspended' }, status: :unauthorized unless @current_account.active?
end
def set_contact
@contact_inbox = @web_widget.inbox.contact_inboxes.find_by(
source_id: auth_token_params[:source_id]
)
@contact = @contact_inbox&.contact
raise ActiveRecord::RecordNotFound unless @contact
raise ActiveRecord::RecordNotFound if auth_token_params[:source_id].blank?
@contact_inbox = @web_widget.inbox.contact_inboxes.find_by(source_id: auth_token_params[:source_id])
@contact = @contact_inbox&.contact
# A row whose contact is gone (dependent: :destroy_async) is not a session; the next page load issues a new one.
raise ActiveRecord::RecordNotFound if @contact_inbox && @contact.nil?
Current.contact = @contact
end
# A visitor only gets a contact once they do something worth keeping (open the widget, send a
# message, identify themselves); loading the page is not that. The token carries the ids to use,
# and the row is only ever created on the stream the widget is already subscribed to.
def ensure_contact
return if @contact.present?
raise ActiveRecord::RecordNotFound if auth_token_params[:pubsub_token].blank?
additional_attributes = @current_account.feature_enabled?('ip_lookup') ? { created_at_ip: request.remote_ip } : {}
@contact_inbox = @web_widget.create_contact_inbox(
source_id: auth_token_params[:source_id], pubsub_token: auth_token_params[:pubsub_token], additional_attributes: additional_attributes
)
@contact = @contact_inbox.contact
Current.contact = @contact
end

View File

@@ -8,8 +8,9 @@ class WidgetsController < ActionController::Base
before_action :skip_crawler_requests
before_action :ensure_location_is_supported
before_action :set_token
before_action :set_contact
before_action :build_contact
before_action :set_contact_inbox
before_action :ensure_contact_inbox
before_action :set_visitor_session
after_action :allow_iframe_requests
private
@@ -33,30 +34,34 @@ class WidgetsController < ActionController::Base
end
def set_token
@token = permitted_params[:cw_conversation]
@auth_token_params = if @token.present?
::Widget::TokenService.new(token: @token).decode_token
else
{}
end
@auth_token_params = ::Widget::TokenService.new(token: permitted_params[:cw_conversation]).decode_token
# A token only identifies a session in the inbox it was minted for.
@auth_token_params = {} unless @auth_token_params[:inbox_id] == @web_widget.inbox.id
end
def set_contact
def set_contact_inbox
return if @auth_token_params[:source_id].nil?
@contact_inbox = ::ContactInbox.find_by(
inbox_id: @web_widget.inbox.id,
source_id: @auth_token_params[:source_id]
)
@contact_inbox = ::ContactInbox.find_by(inbox_id: @web_widget.inbox.id, source_id: @auth_token_params[:source_id])
return unless @contact_inbox && @contact_inbox.contact.nil?
@contact = @contact_inbox&.contact
# A row whose contact is gone (dependent: :destroy_async) is not a session to continue.
@contact_inbox = nil
@auth_token_params = {}
end
def build_contact
return if @contact.present?
# A token issued by a server that no longer creates the contact on load carries the ids to reuse,
# so the row lands on the stream the visitor is already subscribed to.
def ensure_contact_inbox
return if @contact_inbox.present?
@contact_inbox, @token = build_contact_inbox_with_token(@web_widget, additional_attributes)
@contact = @contact_inbox.contact
@contact_inbox = @web_widget.create_contact_inbox(
source_id: @auth_token_params[:source_id], pubsub_token: @auth_token_params[:pubsub_token], additional_attributes: additional_attributes
)
end
def set_visitor_session
@token, @pubsub_token = visitor_session(@web_widget, permitted_params[:cw_conversation], @auth_token_params, @contact_inbox)
end
def ensure_account_is_active

View File

@@ -1,9 +1,28 @@
module WidgetHelper
def build_contact_inbox_with_token(web_widget, additional_attributes = {})
contact_inbox = web_widget.create_contact_inbox(additional_attributes)
payload = { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id }
token = ::Widget::TokenService.new(payload: payload).generate_token
# Token and pubsub_token for the visitor's session. A current token is handed back as is;
# otherwise one is minted, continuing the row's ids when there is a row and fresh ones when not.
# A token without a pubsub_token predates lazy creation: with a row it is reissued from the row,
# without one it is not a session. Nothing is written to the database until
# WebsiteTokenHelper#ensure_contact.
def visitor_session(web_widget, token, auth_token_params, contact_inbox)
session = contact_inbox ? contact_inbox.slice(:source_id, :pubsub_token) : auth_token_params
session = {} if session[:pubsub_token].blank?
return [token, session[:pubsub_token]] if session.present? && session[:pubsub_token] == auth_token_params[:pubsub_token]
[contact_inbox, token]
source_id = session[:source_id] || SecureRandom.uuid
pubsub_token = session[:pubsub_token] || ContactInbox.generate_unique_secure_token
[visitor_token(web_widget, source_id, pubsub_token), pubsub_token]
end
def build_contact_inbox_with_token(web_widget)
contact_inbox = web_widget.create_contact_inbox
[contact_inbox, visitor_token(web_widget, contact_inbox.source_id, contact_inbox.pubsub_token)]
end
private
def visitor_token(web_widget, source_id, pubsub_token)
payload = { source_id: source_id, inbox_id: web_widget.inbox.id, pubsub_token: pubsub_token }
::Widget::TokenService.new(payload: payload).generate_token
end
end

View File

@@ -49,6 +49,7 @@ window.onload = () => {
window.WOOT_WIDGET = app.mount('#app');
window.actionCable = new ActionCableConnector(
window.WOOT_WIDGET,
window.chatwootPubsubToken
window.chatwootPubsubToken,
window.authToken
);
};

View File

@@ -10,7 +10,8 @@ class BaseActionCableConnector {
app,
pubsubToken,
websocketHost = '',
presenceInterval = PRESENCE_INTERVAL
presenceInterval = PRESENCE_INTERVAL,
subscriptionParams = {}
) {
const websocketURL = websocketHost ? `${websocketHost}/cable` : undefined;
@@ -21,6 +22,7 @@ class BaseActionCableConnector {
pubsub_token: pubsubToken,
account_id: app.$store.getters.getCurrentAccountId,
user_id: app.$store.getters.getCurrentUserID,
...subscriptionParams,
},
{
updatePresence() {

View File

@@ -16,8 +16,11 @@ const isMessageInActiveConversation = (getters, message) => {
const WIDGET_PRESENCE_INTERVAL = 60000;
class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) {
super(app, pubsubToken, '', WIDGET_PRESENCE_INTERVAL);
constructor(app, pubsubToken, authToken) {
// Until the visitor's contact exists, the signed widget token authorises the stream.
super(app, pubsubToken, '', WIDGET_PRESENCE_INTERVAL, {
auth_token: authToken,
});
this.events = {
'message.created': this.onMessageCreated,
'message.updated': this.onMessageUpdated,

View File

@@ -100,10 +100,12 @@ class Channel::WebWidget < ApplicationRecord
}
end
def create_contact_inbox(additional_attributes = {})
::ContactInboxWithContactBuilder.new({
inbox: inbox,
contact_attributes: { additional_attributes: additional_attributes }
}).perform
def create_contact_inbox(source_id: nil, pubsub_token: nil, additional_attributes: {})
::ContactInboxWithContactBuilder.new(
inbox: inbox,
contact_attributes: { additional_attributes: additional_attributes },
source_id: source_id,
pubsub_token: pubsub_token
).perform
end
end

View File

@@ -25,12 +25,14 @@ json.website_channel_config do
end
json.contact do
json.email @contact.email
json.id @contact.id
json.identifier @contact.identifier
json.name @contact.name
json.phone_number @contact.phone_number
json.pubsub_token @contact_inbox.pubsub_token
if @contact
json.email @contact.email
json.id @contact.id
json.identifier @contact.identifier
json.name @contact.name
json.phone_number @contact.phone_number
end
json.pubsub_token @pubsub_token
end
json.global_config @global_config

View File

@@ -1,5 +1,7 @@
json.id @contact.id
json.has_email @contact.email.present?
json.has_name @contact.name.present?
json.has_phone_number @contact.phone_number.present?
json.identifier @contact.identifier
if @contact
json.id @contact.id
json.has_email @contact.email.present?
json.has_name @contact.name.present?
json.has_phone_number @contact.phone_number.present?
json.identifier @contact.identifier
end

View File

@@ -30,7 +30,7 @@
allowMessagesAfterResolved: <%= @web_widget.inbox.allow_messages_after_resolved %>,
disableBranding: <%= @web_widget.inbox.account.feature_enabled?('disable_branding') %>,
}
window.chatwootPubsubToken = '<%= @contact_inbox.pubsub_token %>'
window.chatwootPubsubToken = '<%= @pubsub_token %>'
window.authToken = '<%= @token %>'
window.globalConfig = <%= raw @global_config.to_json %>
</script>

View File

@@ -5,6 +5,22 @@ describe ContactInboxBuilder do
let(:contact) { create(:contact, email: 'xyc@example.com', phone_number: '+23423424123', account: account) }
describe '#perform' do
describe 'web widget inbox' do
let(:web_widget) { create(:channel_widget, account: account) }
it 'creates the contact inbox on the pubsub_token the visitor already streams on' do
contact_inbox = described_class.new(contact: contact, inbox: web_widget.inbox, source_id: 'visitor', pubsub_token: 'stream').perform
expect(contact_inbox.pubsub_token).to eq('stream')
end
it 'generates a pubsub_token when none is given' do
contact_inbox = described_class.new(contact: contact, inbox: web_widget.inbox).perform
expect(contact_inbox.pubsub_token).to be_present
end
end
describe 'twilio sms inbox' do
let!(:twilio_sms) { create(:channel_twilio_sms, account: account) }
let!(:twilio_inbox) { create(:inbox, channel: twilio_sms, account: account) }

View File

@@ -4,6 +4,11 @@ RSpec.describe RoomChannel do
let!(:contact_inbox) { create(:contact_inbox) }
let!(:account) { create(:account) }
let!(:user) { create(:user, account: account) }
let(:web_widget) { create(:channel_widget, account: account) }
let(:visitor_pubsub_token) { ContactInbox.generate_unique_secure_token }
let(:visitor_token) do
Widget::TokenService.new(payload: { source_id: 'visitor', inbox_id: web_widget.inbox.id, pubsub_token: visitor_pubsub_token }).generate_token
end
before do
stub_connection
@@ -21,4 +26,47 @@ RSpec.describe RoomChannel do
expect(subscription).to have_stream_for(user.pubsub_token)
expect(subscription).to have_stream_for("account_#{account.id}")
end
it 'subscribes a visitor without a contact when their widget token vouches for the stream' do
subscribe(pubsub_token: visitor_pubsub_token, auth_token: visitor_token)
expect(subscription).to be_confirmed
expect(subscription).to have_stream_for(visitor_pubsub_token)
end
it 'still tells a visitor without a contact which agents are online' do
expect { subscribe(pubsub_token: visitor_pubsub_token, auth_token: visitor_token) }
.to have_broadcasted_to(visitor_pubsub_token).with(hash_including(event: 'presence.update'))
end
it 'does not track presence for a visitor without a contact' do
subscribe(pubsub_token: visitor_pubsub_token, auth_token: visitor_token)
perform :update_presence
expect(OnlineStatusTracker.get_available_contact_ids(account.id)).to be_empty
end
it 'rejects a stream the widget token does not vouch for' do
expect { subscribe(pubsub_token: "account_#{account.id}", auth_token: visitor_token) }.to raise_error(ActiveRecord::RecordNotFound)
end
it 'rejects an unknown stream without a widget token' do
expect { subscribe(pubsub_token: visitor_pubsub_token) }.to raise_error(ActiveRecord::RecordNotFound)
end
it 'ignores presence pings from a subscription that was rejected' do
expect { subscribe(pubsub_token: visitor_pubsub_token) }.to raise_error(ActiveRecord::RecordNotFound)
expect { perform :update_presence }.not_to raise_error
expect(subscription).not_to have_stream_for(visitor_pubsub_token)
end
it 'starts tracking presence once the visitor gets a contact' do
subscribe(pubsub_token: visitor_pubsub_token, auth_token: visitor_token)
contact = create(:contact, account: account)
create(:contact_inbox, contact: contact, inbox: web_widget.inbox, pubsub_token: visitor_pubsub_token)
perform :update_presence
expect(OnlineStatusTracker.get_presence(account.id, 'Contact', contact.id)).to be_truthy
end
end

View File

@@ -5,7 +5,7 @@ RSpec.describe '/api/v1/widget/config', type: :request do
let(:web_widget) { create(:channel_widget, account: account) }
let!(:contact) { create(:contact, account: account) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: web_widget.inbox) }
let(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } }
let(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id, pubsub_token: contact_inbox.pubsub_token } }
let(:token) { Widget::TokenService.new(payload: payload).generate_token }
describe 'POST /api/v1/widget/config' do
@@ -30,6 +30,10 @@ RSpec.describe '/api/v1/widget/config', type: :request do
expect(response).to have_http_status(:success)
response_data = response.parsed_body
expect(response_data.keys).to include(*response_keys)
created = Contact.find(response_data['contact']['id'])
expect(response_data['contact']['pubsub_token']).to eq(created.contact_inboxes.first.pubsub_token)
auth_token = Widget::TokenService.new(token: response_data['website_channel_config']['auth_token']).decode_token
expect(auth_token[:pubsub_token]).to eq(response_data['contact']['pubsub_token'])
end
end
@@ -45,7 +49,9 @@ RSpec.describe '/api/v1/widget/config', type: :request do
expect(response).to have_http_status(:success)
response_data = response.parsed_body
expect(response_data.keys).to include(*response_keys)
expect(response_data['contact']['id']).to eq(contact.id)
expect(response_data['contact']['pubsub_token']).to eq(contact_inbox.pubsub_token)
expect(response_data['website_channel_config']['auth_token']).to eq(token)
end
it 'returns 401 if account is suspended' do
@@ -60,6 +66,26 @@ RSpec.describe '/api/v1/widget/config', type: :request do
end
end
context 'with correct website token and a token issued without a contact' do
it 'creates the contact with the ids the token carries' do
visitor_payload = { source_id: 'visitor', inbox_id: web_widget.inbox.id, pubsub_token: 'stream' }
visitor_token = Widget::TokenService.new(payload: visitor_payload).generate_token
expect do
post '/api/v1/widget/config',
params: params,
headers: { 'X-Auth-Token' => visitor_token },
as: :json
end.to change(Contact, :count).by(1)
expect(response).to have_http_status(:success)
response_data = response.parsed_body
expect(response_data['contact']['pubsub_token']).to eq('stream')
expect(web_widget.inbox.contact_inboxes.find_by!(source_id: 'visitor').pubsub_token).to eq('stream')
expect(response_data['website_channel_config']['auth_token']).to eq(visitor_token)
end
end
context 'with correct website token and invalid X-Auth-Token' do
it 'returns widget config and new contact with error message' do
expect do
@@ -72,6 +98,7 @@ RSpec.describe '/api/v1/widget/config', type: :request do
expect(response).to have_http_status(:success)
response_data = response.parsed_body
expect(response_data.keys).to include(*response_keys)
expect(response_data['contact']['id']).to be_present
end
end
end

View File

@@ -8,6 +8,23 @@ RSpec.describe '/api/v1/widget/contacts', type: :request do
let(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } }
let(:token) { Widget::TokenService.new(payload: payload).generate_token }
describe 'GET /api/v1/widget/contact' do
it 'returns nothing for a visitor without a contact' do
visitor_payload = { source_id: 'visitor', inbox_id: web_widget.inbox.id, pubsub_token: 'stream' }
visitor_token = Widget::TokenService.new(payload: visitor_payload).generate_token
expect do
get '/api/v1/widget/contact',
params: { website_token: web_widget.website_token },
headers: { 'X-Auth-Token' => visitor_token },
as: :json
end.not_to change(Contact, :count)
expect(response).to have_http_status(:success)
expect(response.parsed_body).to eq({})
end
end
describe 'PATCH /api/v1/widget/contact' do
let(:params) { { website_token: web_widget.website_token, identifier: 'test' } }
@@ -18,6 +35,26 @@ RSpec.describe '/api/v1/widget/contacts', type: :request do
end
end
context 'when the visitor has no contact yet' do
it 'creates the contact with the prechat details' do
visitor_payload = { source_id: 'visitor', inbox_id: web_widget.inbox.id, pubsub_token: 'stream' }
visitor_token = Widget::TokenService.new(payload: visitor_payload).generate_token
expect do
patch '/api/v1/widget/contact',
params: { website_token: web_widget.website_token, name: 'Pre Chat', email: 'prechat@example.com' },
headers: { 'X-Auth-Token' => visitor_token },
as: :json
end.to change(Contact, :count).by(1)
expect(response).to have_http_status(:success)
expect(response.parsed_body['has_email']).to be true
visitor_contact_inbox = web_widget.inbox.contact_inboxes.find_by!(source_id: 'visitor')
expect(visitor_contact_inbox.pubsub_token).to eq('stream')
expect(visitor_contact_inbox.contact.email).to eq('prechat@example.com')
end
end
context 'with correct website token' do
let(:identify_action) { double }
@@ -194,6 +231,42 @@ RSpec.describe '/api/v1/widget/contacts', type: :request do
let(:correct_identifier_hash) { OpenSSL::HMAC.hexdigest('sha256', web_widget.hmac_token, params[:identifier].to_s) }
let(:incorrect_identifier_hash) { 'test' }
context 'when the visitor has no contact yet' do
it 'creates the contact for the identifier' do
visitor_payload = { source_id: 'visitor', inbox_id: web_widget.inbox.id, pubsub_token: 'stream' }
visitor_token = Widget::TokenService.new(payload: visitor_payload).generate_token
expect do
patch '/api/v1/widget/contact/set_user',
params: params.merge(identifier_hash: correct_identifier_hash),
headers: { 'X-Auth-Token' => visitor_token },
as: :json
end.to change(Contact, :count).by(1)
body = response.parsed_body
visitor_contact_inbox = web_widget.inbox.contact_inboxes.find_by!(source_id: 'visitor')
expect(visitor_contact_inbox.contact.id).to eq(body['id'])
expect(visitor_contact_inbox.contact.identifier).to eq('test')
expect(visitor_contact_inbox.pubsub_token).to eq('stream')
expect(visitor_contact_inbox.hmac_verified?).to be(true)
expect(body).not_to have_key('widget_auth_token')
end
it 'does not create a contact when the identifier hash is invalid' do
visitor_payload = { source_id: 'visitor', inbox_id: web_widget.inbox.id, pubsub_token: 'stream' }
visitor_token = Widget::TokenService.new(payload: visitor_payload).generate_token
expect do
patch '/api/v1/widget/contact/set_user',
params: params.merge(identifier_hash: incorrect_identifier_hash),
headers: { 'X-Auth-Token' => visitor_token },
as: :json
end.not_to(change { [Contact.count, ContactInbox.count] })
expect(response).to have_http_status(:unauthorized)
end
end
context 'when the current contact identifier is different from param identifier' do
before do
contact.update(identifier: 'random')
@@ -263,18 +336,22 @@ RSpec.describe '/api/v1/widget/contacts', type: :request do
context 'with invalid website token' do
it 'returns unauthorized' do
post '/api/v1/widget/destroy_custom_attributes', params: { website_token: '' }
post '/api/v1/widget/contact/destroy_custom_attributes', params: { website_token: '' }
expect(response).to have_http_status(:not_found)
end
end
context 'with correct website token' do
it 'calls destroy custom attributes' do
post '/api/v1/widget/destroy_custom_attributes',
contact.update!(custom_attributes: { 'test' => 'x', 'keep' => 'y' })
post '/api/v1/widget/contact/destroy_custom_attributes',
params: params,
headers: { 'X-Auth-Token' => token },
as: :json
expect(contact.reload.custom_attributes).to eq({})
expect(response).to have_http_status(:success)
expect(contact.reload.custom_attributes).to eq('keep' => 'y')
end
end
end

View File

@@ -45,12 +45,52 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
end
end
context 'with a conversation but invalid source id' do
it 'returns the correct conversation params' do
context 'with a token whose visitor has no contact yet' do
it 'returns no conversation without creating anything' do
allow(Rails.configuration.dispatcher).to receive(:dispatch)
payload = { source_id: 'invalid source id', inbox_id: web_widget.inbox.id }
payload = { source_id: 'visitor', inbox_id: web_widget.inbox.id, pubsub_token: 'stream' }
token = Widget::TokenService.new(payload: payload).generate_token
expect do
get '/api/v1/widget/conversations',
headers: { 'X-Auth-Token' => token },
params: { website_token: web_widget.website_token },
as: :json
end.not_to(change { [Contact.count, ContactInbox.count] })
expect(response).to have_http_status(:success)
expect(response.parsed_body).to eq({})
end
end
context 'with an invalid or expired token' do
it 'returns not found for a token that cannot be decoded' do
get '/api/v1/widget/conversations',
headers: { 'X-Auth-Token' => 'garbage' },
params: { website_token: web_widget.website_token },
as: :json
expect(response).to have_http_status(:not_found)
end
it 'returns not found for an expired token' do
expired_token = JWT.encode(payload.merge(exp: 1.day.ago.to_i), Rails.application.secret_key_base, 'HS256')
get '/api/v1/widget/conversations',
headers: { 'X-Auth-Token' => expired_token },
params: { website_token: web_widget.website_token },
as: :json
expect(response).to have_http_status(:not_found)
end
end
context 'when the contact behind the token has been deleted' do
it 'returns not found instead of treating the row as a visitor' do
token
contact.delete
get '/api/v1/widget/conversations',
headers: { 'X-Auth-Token' => token },
params: { website_token: web_widget.website_token },
@@ -62,6 +102,38 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
end
describe 'POST /api/v1/widget/conversations' do
it 'creates the contact along with the first conversation of a visitor' do
visitor_payload = { source_id: 'visitor', inbox_id: web_widget.inbox.id, pubsub_token: 'stream' }
visitor_token = Widget::TokenService.new(payload: visitor_payload).generate_token
expect do
post '/api/v1/widget/conversations',
headers: { 'X-Auth-Token' => visitor_token },
params: conversation_params,
as: :json
end.to change(Contact, :count).by(1)
expect(response).to have_http_status(:success)
visitor_contact_inbox = web_widget.inbox.contact_inboxes.find_by!(source_id: 'visitor')
expect(visitor_contact_inbox.pubsub_token).to eq('stream')
expect(visitor_contact_inbox.conversations.count).to eq(1)
expect(visitor_contact_inbox.contact.email).to eq('contact-email@chatwoot.com')
end
it 'does not create a conversation when the contact behind the token has been deleted' do
token
contact.delete
expect do
post '/api/v1/widget/conversations',
headers: { 'X-Auth-Token' => token },
params: conversation_params,
as: :json
end.not_to(change { [Contact.count, Conversation.count] })
expect(response).to have_http_status(:not_found)
end
it 'creates a conversation with correct details' do
post '/api/v1/widget/conversations',
headers: { 'X-Auth-Token' => token },

View File

@@ -42,6 +42,27 @@ RSpec.describe '/api/v1/widget/direct_uploads', type: :request do
json_response = response.parsed_body
expect(json_response['content_type']).to eq('image/png')
end
it 'creates a blob for a visitor without a contact' do
visitor_payload = { source_id: 'visitor', inbox_id: web_widget.inbox.id, pubsub_token: 'stream' }
visitor_token = Widget::TokenService.new(payload: visitor_payload).generate_token
expect do
post api_v1_widget_direct_uploads_url,
params: {
website_token: web_widget.website_token,
blob: {
filename: 'avatar.png',
byte_size: '1234',
checksum: 'dsjbsdhbfif3874823mnsdbf',
content_type: 'image/png'
}
},
headers: { 'X-Auth-Token' => visitor_token }
end.not_to change(Contact, :count)
expect(response).to have_http_status(:success)
end
end
end
end

View File

@@ -34,6 +34,53 @@ RSpec.describe '/api/v1/widget/events', type: :request do
.with(params[:name], anything, contact_inbox: contact_inbox,
event_info: { test_id: 'test', browser_language: nil, widget_language: nil, browser: anything })
end
it 'gives a visitor without a contact one when they open the widget' do
visitor_payload = { source_id: 'visitor', inbox_id: web_widget.inbox.id, pubsub_token: 'stream' }
visitor_token = Widget::TokenService.new(payload: visitor_payload).generate_token
expect do
post '/api/v1/widget/events',
params: params,
headers: { 'X-Auth-Token' => visitor_token },
as: :json
end.to change(Contact, :count).by(1).and change(ContactInbox, :count).by(1)
expect(response).to have_http_status(:success)
visitor_contact_inbox = web_widget.inbox.contact_inboxes.find_by!(source_id: 'visitor')
expect(visitor_contact_inbox.pubsub_token).to eq('stream')
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(params[:name], anything, hash_including(contact_inbox: visitor_contact_inbox))
end
it 'does not create a contact for a token minted before pubsub_token was part of it' do
legacy_token = Widget::TokenService.new(payload: { source_id: 'purged', inbox_id: web_widget.inbox.id }).generate_token
expect do
post '/api/v1/widget/events',
params: params,
headers: { 'X-Auth-Token' => legacy_token },
as: :json
end.not_to(change { [Contact.count, ContactInbox.count] })
expect(response).to have_http_status(:not_found)
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch).with(params[:name], anything, anything)
end
it 'does not create a contact for a token minted for another inbox' do
other_widget = create(:channel_widget, account: account)
token
expect do
post '/api/v1/widget/events',
params: params.merge(website_token: other_widget.website_token),
headers: { 'X-Auth-Token' => token },
as: :json
end.not_to(change { [Contact.count, ContactInbox.count] })
expect(response).to have_http_status(:not_found)
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch).with(params[:name], anything, anything)
end
end
end
end

View File

@@ -38,6 +38,21 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
json_response = response.parsed_body
expect(json_response['payload'].length).to eq(0)
end
it 'returns empty messages for a visitor without a contact', :skip_before do
visitor_payload = { source_id: 'visitor', inbox_id: web_widget.inbox.id, pubsub_token: 'stream' }
visitor_token = Widget::TokenService.new(payload: visitor_payload).generate_token
expect do
get api_v1_widget_messages_url,
params: { website_token: web_widget.website_token },
headers: { 'X-Auth-Token' => visitor_token },
as: :json
end.not_to change(Contact, :count)
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']).to eq([])
end
end
end
@@ -76,6 +91,22 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
end
context 'when post request is made' do
it 'creates the contact and conversation with the first message of a visitor', :skip_before do
visitor_payload = { source_id: 'visitor', inbox_id: web_widget.inbox.id, pubsub_token: 'stream' }
visitor_token = Widget::TokenService.new(payload: visitor_payload).generate_token
expect do
post api_v1_widget_messages_url,
params: { website_token: web_widget.website_token, message: { content: 'hello world', timestamp: Time.current } },
headers: { 'X-Auth-Token' => visitor_token },
as: :json
end.to change(Contact, :count).by(1).and change(Conversation, :count).by(1)
expect(response).to have_http_status(:success)
expect(response.parsed_body['content']).to eq('hello world')
expect(web_widget.inbox.contact_inboxes.find_by!(source_id: 'visitor').pubsub_token).to eq('stream')
end
it 'creates message in conversation' do
conversation.destroy! # Test all params
message_params = { content: 'hello world', timestamp: Time.current }

View File

@@ -5,26 +5,90 @@ describe '/widget', type: :request do
let(:web_widget) { create(:channel_widget, account: account) }
let(:contact) { create(:contact, account: account) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: web_widget.inbox) }
let(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } }
let(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id, pubsub_token: contact_inbox.pubsub_token } }
let(:token) { Widget::TokenService.new(payload: payload).generate_token }
let(:rendered_token) { Widget::TokenService.new(token: response.body[/window\.authToken = '([^']+)'/, 1]).decode_token }
let(:rendered_pubsub_token) { response.body[/window\.chatwootPubsubToken = '([^']+)'/, 1] }
describe 'GET /widget' do
it 'renders the page correctly when called with website_token' do
it 'renders the page with a token for the created contact when called with website_token' do
url = widget_url(website_token: web_widget.website_token)
expect { get url }.to change(Contact, :count).by(1).and change(ContactInbox, :count).by(1)
expect(response).to be_successful
expect(response.body).not_to include(token)
created = web_widget.inbox.contact_inboxes.find_by!(source_id: rendered_token[:source_id])
expect(rendered_token[:inbox_id]).to eq(web_widget.inbox.id)
expect(rendered_token[:pubsub_token]).to eq(created.pubsub_token)
expect(rendered_pubsub_token).to eq(created.pubsub_token)
end
it 'renders the page correctly when called with website_token and cw_conversation' do
it 'keeps the session when called with website_token and cw_conversation' do
url = widget_url(website_token: web_widget.website_token, cw_conversation: token)
expect { get url }.not_to(change { [Contact.count, ContactInbox.count] })
expect(response).to be_successful
expect(response.body).to include(token)
expect(rendered_pubsub_token).to eq(contact_inbox.pubsub_token)
end
it 'creates the contact with the ids of a token issued without one' do
visitor_payload = { source_id: 'visitor', inbox_id: web_widget.inbox.id, pubsub_token: 'stream' }
visitor_token = Widget::TokenService.new(payload: visitor_payload).generate_token
url = widget_url(website_token: web_widget.website_token, cw_conversation: visitor_token)
expect { get url }.to change(Contact, :count).by(1).and change(ContactInbox, :count).by(1)
expect(web_widget.inbox.contact_inboxes.find_by!(source_id: 'visitor').pubsub_token).to eq('stream')
expect(response.body).to include(visitor_token)
expect(rendered_pubsub_token).to eq('stream')
end
it 'reissues a token minted before pubsub_token was part of it' do
legacy_token = Widget::TokenService.new(payload: payload.except(:pubsub_token)).generate_token
expect { get widget_url(website_token: web_widget.website_token, cw_conversation: legacy_token) }
.not_to(change { [Contact.count, ContactInbox.count] })
expect(response.body).not_to include(legacy_token)
expect(rendered_token).to include(source_id: contact_inbox.source_id, pubsub_token: contact_inbox.pubsub_token)
expect(rendered_pubsub_token).to eq(contact_inbox.pubsub_token)
end
it 'recreates the contact for a token minted before pubsub_token was part of it when its contact is gone' do
legacy_token = Widget::TokenService.new(payload: { source_id: 'purged', inbox_id: web_widget.inbox.id }).generate_token
expect { get widget_url(website_token: web_widget.website_token, cw_conversation: legacy_token) }
.to change(Contact, :count).by(1)
expect(response.body).not_to include(legacy_token)
created = web_widget.inbox.contact_inboxes.find_by!(source_id: 'purged')
expect(rendered_token).to include(source_id: 'purged', pubsub_token: created.pubsub_token)
end
it 'starts a new session when the contact behind the token has been deleted' do
token
contact.delete
expect { get widget_url(website_token: web_widget.website_token, cw_conversation: token) }.to change(Contact, :count).by(1)
expect(response).to be_successful
expect(response.body).not_to include(token)
expect(rendered_token[:source_id]).not_to eq(contact_inbox.source_id)
expect(rendered_pubsub_token).not_to eq(contact_inbox.pubsub_token)
end
it 'starts a new session for a token minted for another inbox' do
other_widget = create(:channel_widget, account: account)
token
expect { get widget_url(website_token: other_widget.website_token, cw_conversation: token) }.to change(Contact, :count).by(1)
expect(response.body).not_to include(token)
expect(rendered_token[:inbox_id]).to eq(other_widget.inbox.id)
expect(rendered_token[:source_id]).not_to eq(contact_inbox.source_id)
expect(rendered_pubsub_token).not_to eq(contact_inbox.pubsub_token)
end
it 'creates a contact for a regular browser' do