Merge branch 'develop' into fix/cw-6921-server-hmac-verification

This commit is contained in:
Vishnu Narayanan
2026-07-09 12:53:37 +05:30
committed by GitHub
195 changed files with 8014 additions and 182 deletions
@@ -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) }