Merge branch 'develop' into fix/cw-6921-server-hmac-verification
This commit is contained in:
@@ -62,6 +62,24 @@ RSpec.describe 'Assignable Agents API', type: :request do
|
||||
expect(response_data.size).to eq(2)
|
||||
expect(response_data.pluck(:role)).to include('agent', 'administrator')
|
||||
end
|
||||
|
||||
context 'with Agent Bots' do
|
||||
let!(:account_bot) { create(:agent_bot, account: account, name: 'Account bot') }
|
||||
let!(:global_bot) { create(:agent_bot, account: nil, name: 'Global bot') }
|
||||
|
||||
it 'returns assignable agents and accessible agent bots' do
|
||||
get "/api/v1/accounts/#{account.id}/assignable_agents",
|
||||
params: { inbox_ids: [inbox1.id, inbox2.id], include_agent_bots: true },
|
||||
headers: agent1.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
response_data = response.parsed_body['payload']
|
||||
expect(response_data.pluck('assignee_type')).to include('User', 'AgentBot')
|
||||
expect(response_data.pluck('name')).to include(agent1.name, admin.name, account_bot.name, global_bot.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -67,6 +67,50 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
|
||||
source: 'default'
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the assistant YAML default for V1 accounts' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response.dig(:features, :assistant)).to include(
|
||||
default: Llm::Models.default_model_for('assistant'),
|
||||
selected: Llm::Models.default_model_for('assistant'),
|
||||
source: 'default'
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns GPT-5.2 as the assistant default for V2 accounts' do
|
||||
account.enable_features!('captain_integration_v2')
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response.dig(:features, :assistant)).to include(
|
||||
default: Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL,
|
||||
selected: Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL,
|
||||
source: 'default'
|
||||
)
|
||||
end
|
||||
|
||||
it 'keeps the V2 assistant default when an account override is selected' do
|
||||
account.enable_features!('captain_integration_v2')
|
||||
account.update!(captain_models: { 'assistant' => 'gpt-5.1' })
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response.dig(:features, :assistant)).to include(
|
||||
default: Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL,
|
||||
selected: 'gpt-5.1',
|
||||
source: 'account_override'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -51,6 +51,22 @@ RSpec.describe 'Conversation Messages API', type: :request do
|
||||
expect(json_response['error']).to eq('Validation failed: Content is too long (maximum is 150000 characters)')
|
||||
end
|
||||
|
||||
it 'returns a customer-safe error when the database query is canceled' do
|
||||
message_builder = instance_double(Messages::MessageBuilder)
|
||||
allow(Messages::MessageBuilder).to receive(:new).and_return(message_builder)
|
||||
allow(message_builder).to receive(:perform)
|
||||
.and_raise(ActiveRecord::QueryCanceled, 'PG::QueryCanceled: ERROR: canceling statement due to statement timeout')
|
||||
|
||||
post api_v1_account_conversation_messages_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||
params: { content: 'test-message', private: true },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq(I18n.t('errors.database.query_canceled'))
|
||||
expect(response.parsed_body['error']).not_to include('PG::QueryCanceled')
|
||||
end
|
||||
|
||||
it 'creates an outgoing text message with a specific bot sender' do
|
||||
agent_bot = create(:agent_bot)
|
||||
time_stamp = Time.now.utc.to_s
|
||||
|
||||
@@ -68,6 +68,23 @@ RSpec.describe 'Conversation Participants API', type: :request do
|
||||
expect(response.body).to include(participant.email)
|
||||
expect(conversation.conversation_participants.count).to eq(1)
|
||||
end
|
||||
|
||||
it 'notifies unread counts when a participant is added' do
|
||||
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
params = { user_ids: [participant.id] }
|
||||
|
||||
post api_v1_account_conversation_participants_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
|
||||
'conversation.unread_count_changed',
|
||||
kind_of(ActiveSupport::TimeWithZone),
|
||||
conversation: conversation
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -106,6 +123,25 @@ RSpec.describe 'Conversation Participants API', type: :request do
|
||||
expect(response.body).to include(participant_to_be_added.email)
|
||||
expect(conversation.conversation_participants.count).to eq(2)
|
||||
end
|
||||
|
||||
it 'notifies unread counts when participant membership changes' do
|
||||
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
params = { user_ids: [participant.id, participant_to_be_added.id] }
|
||||
create(:conversation_participant, conversation: conversation, user: participant)
|
||||
create(:conversation_participant, conversation: conversation, user: participant_to_be_removed)
|
||||
|
||||
put api_v1_account_conversation_participants_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
|
||||
'conversation.unread_count_changed',
|
||||
kind_of(ActiveSupport::TimeWithZone),
|
||||
conversation: conversation
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -137,6 +173,24 @@ RSpec.describe 'Conversation Participants API', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(conversation.conversation_participants.count).to eq(0)
|
||||
end
|
||||
|
||||
it 'notifies unread counts when a participant is removed' do
|
||||
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
params = { user_ids: [participant.id] }
|
||||
create(:conversation_participant, conversation: conversation, user: participant)
|
||||
|
||||
delete api_v1_account_conversation_participants_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
|
||||
'conversation.unread_count_changed',
|
||||
kind_of(ActiveSupport::TimeWithZone),
|
||||
conversation: conversation
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -159,6 +159,28 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['payload']['teams']).to eq(team.id.to_s => 1)
|
||||
end
|
||||
|
||||
it 'returns filtered unread counts when the filtered count feature is enabled' do
|
||||
account.enable_features!(:unread_count_for_filters)
|
||||
allow(Conversations::UnreadCounts::FilteredCountInstrumentation).to receive(:summarize_request) do |**_attributes, &block|
|
||||
block.call
|
||||
end
|
||||
mentioned = create_unread_conversation(account: account, inbox: visible_inbox)
|
||||
create(:mention, account: account, conversation: mentioned, user: agent)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/conversations/unread_counts",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['payload']).to include(
|
||||
'mentions_count' => 1,
|
||||
'participating_count' => 0,
|
||||
'unattended_count' => 1,
|
||||
'folders' => {}
|
||||
)
|
||||
expect(Conversations::UnreadCounts::FilteredCountInstrumentation).to have_received(:summarize_request).with(account_id: account.id)
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns forbidden when conversation unread counts feature is disabled' do
|
||||
@@ -865,6 +887,59 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
Conversations::UnreadCounts::Store.clear_account!(account.id)
|
||||
end
|
||||
|
||||
it 'refreshes unread count cache before invalidating filtered counts when conversation is marked read' do
|
||||
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
|
||||
conversation.update!(agent_last_seen_at: 1.hour.ago)
|
||||
create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming, created_at: 5.minutes.ago)
|
||||
notifier = instance_double(Conversations::UnreadCounts::Notifier)
|
||||
invalidator = instance_double(Conversations::UnreadCounts::FilteredCountInvalidator)
|
||||
|
||||
allow(Conversations::UnreadCounts::Notifier).to receive(:new).with(conversation).and_return(notifier)
|
||||
allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).with(account).and_return(invalidator)
|
||||
expect(notifier).to receive(:perform).ordered.and_return(true)
|
||||
expect(invalidator).to receive(:conversation_changed!).ordered.and_return(true)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'invalidates filtered unread counts when conversation is marked read' do
|
||||
conversation.update!(agent_last_seen_at: 1.hour.ago)
|
||||
create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming, created_at: 5.minutes.ago)
|
||||
account.enable_features!(:unread_count_for_filters)
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change { Conversations::UnreadCounts::FilteredCountStore.conversation_version(account.id) }.by(1)
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'notifies clients when marking read only affects filtered counts' do
|
||||
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
|
||||
conversation.update!(agent_last_seen_at: 1.hour.ago)
|
||||
create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming, created_at: 5.minutes.ago)
|
||||
allow(Conversations::UnreadCounts::Refresher).to receive(:new).and_return(
|
||||
instance_double(Conversations::UnreadCounts::Refresher, perform: false)
|
||||
)
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
|
||||
'conversation.unread_count_changed',
|
||||
kind_of(Time),
|
||||
conversation: conversation
|
||||
)
|
||||
end
|
||||
|
||||
it 'updates both if one timestamp is old even when the other is recent' do
|
||||
conversation.update!(assignee_id: agent.id, agent_last_seen_at: 2.hours.ago, assignee_last_seen_at: 30.minutes.ago)
|
||||
# Ensure all messages are older than assignee_last_seen_at (no unread messages)
|
||||
@@ -951,6 +1026,56 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
ensure
|
||||
Conversations::UnreadCounts::Store.clear_account!(account.id)
|
||||
end
|
||||
|
||||
it 'refreshes unread count cache before invalidating filtered counts when conversation is marked unread' do
|
||||
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
|
||||
conversation.update!(agent_last_seen_at: 1.minute.from_now, assignee_last_seen_at: 1.minute.from_now)
|
||||
notifier = instance_double(Conversations::UnreadCounts::Notifier)
|
||||
invalidator = instance_double(Conversations::UnreadCounts::FilteredCountInvalidator)
|
||||
|
||||
allow(Conversations::UnreadCounts::Notifier).to receive(:new).with(conversation).and_return(notifier)
|
||||
allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).with(account).and_return(invalidator)
|
||||
expect(notifier).to receive(:perform).ordered.and_return(true)
|
||||
expect(invalidator).to receive(:conversation_changed!).ordered.and_return(true)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/unread",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'invalidates filtered unread counts when conversation is marked unread' do
|
||||
conversation.update!(agent_last_seen_at: 1.minute.from_now, assignee_last_seen_at: 1.minute.from_now)
|
||||
account.enable_features!(:unread_count_for_filters)
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/unread",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change { Conversations::UnreadCounts::FilteredCountStore.conversation_version(account.id) }.by(1)
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'notifies clients when marking unread only affects filtered counts' do
|
||||
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
|
||||
conversation.update!(agent_last_seen_at: 1.minute.from_now, assignee_last_seen_at: 1.minute.from_now)
|
||||
allow(Conversations::UnreadCounts::Refresher).to receive(:new).and_return(
|
||||
instance_double(Conversations::UnreadCounts::Refresher, perform: false)
|
||||
)
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/unread",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
|
||||
'conversation.unread_count_changed',
|
||||
kind_of(Time),
|
||||
conversation: conversation
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -116,6 +116,78 @@ RSpec.describe '/api/v1/widget/contacts', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/widget/contact with HMAC enforcement' do
|
||||
let(:web_widget) { create(:channel_widget, account: account, hmac_mandatory: true) }
|
||||
let!(:victim) { create(:contact, account: account, identifier: 'victim-identifier', name: 'Victim') }
|
||||
let(:correct_identifier_hash) { OpenSSL::HMAC.hexdigest('sha256', web_widget.hmac_token, 'victim-identifier') }
|
||||
|
||||
context 'when an identifier is supplied on a mandatory-hmac inbox' do
|
||||
it 'rejects when identifier_hash is omitted' do
|
||||
patch '/api/v1/widget/contact',
|
||||
params: { website_token: web_widget.website_token, identifier: 'victim-identifier', name: 'Attacker' },
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(victim.reload.name).to eq('Victim')
|
||||
end
|
||||
|
||||
it 'rejects when identifier_hash is blank' do
|
||||
patch '/api/v1/widget/contact',
|
||||
params: { website_token: web_widget.website_token, identifier: 'victim-identifier', identifier_hash: '', name: 'Attacker' },
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(victim.reload.name).to eq('Victim')
|
||||
end
|
||||
|
||||
it 'rejects when identifier_hash is null' do
|
||||
patch '/api/v1/widget/contact',
|
||||
params: { website_token: web_widget.website_token, identifier: 'victim-identifier', identifier_hash: nil, name: 'Attacker' },
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(victim.reload.name).to eq('Victim')
|
||||
end
|
||||
|
||||
it 'rejects when identifier_hash is invalid' do
|
||||
patch '/api/v1/widget/contact',
|
||||
params: { website_token: web_widget.website_token, identifier: 'victim-identifier',
|
||||
identifier_hash: 'DEFINITELY_INVALID_AAAAA_NOT_A_REAL_HMAC', name: 'Attacker' },
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(victim.reload.name).to eq('Victim')
|
||||
end
|
||||
|
||||
it 'succeeds when a valid identifier_hash is provided' do
|
||||
patch '/api/v1/widget/contact',
|
||||
params: { website_token: web_widget.website_token, identifier: 'victim-identifier',
|
||||
identifier_hash: correct_identifier_hash, name: 'Legit' },
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no identifier is supplied (anonymous prechat update)' do
|
||||
it 'allows updating name/email without an identifier_hash' do
|
||||
patch '/api/v1/widget/contact',
|
||||
params: { website_token: web_widget.website_token, email: 'prechat@test.com', name: 'Prechat User' },
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
as: :json
|
||||
|
||||
expect(victim.reload.email).to be_nil
|
||||
expect(Contact.from_email('prechat@test.com')).to be_present
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/widget/contact/set_user' do
|
||||
let(:params) { { website_token: web_widget.website_token, identifier: 'test' } }
|
||||
let(:web_widget) { create(:channel_widget, account: account, hmac_mandatory: true) }
|
||||
|
||||
Reference in New Issue
Block a user