Merge remote-tracking branch 'origin/develop' into codex/super-admin-email-templates-merge

This commit is contained in:
Sojan Jose
2026-07-02 14:55:38 -07:00
3641 changed files with 272805 additions and 69320 deletions
@@ -25,6 +25,7 @@ RSpec.describe 'Agents API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(response.parsed_body.size).to eq(account.users.count)
end
@@ -122,6 +123,7 @@ RSpec.describe 'Agents API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(other_agent.reload.name).to eq(params[:name])
end
@@ -171,6 +173,7 @@ RSpec.describe 'Agents API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(response.parsed_body['email']).to eq(params[:email])
expect(account.users.last.name).to eq('NewUser')
end
@@ -0,0 +1,141 @@
require 'rails_helper'
RSpec.describe 'Article Bulk Actions API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let!(:portal) { create(:portal, name: 'test_portal', account: account, config: { allowed_locales: %w[en es] }) }
let!(:category) { create(:category, portal: portal, account: account, locale: 'en', slug: 'getting-started') }
let!(:article_one) { create(:article, category: category, portal: portal, account: account, author: admin, status: :draft) }
let!(:article_two) { create(:article, category: category, portal: portal, account: account, author: admin, status: :draft) }
let!(:article_three) { create(:article, category: category, portal: portal, account: account, author: admin, status: :published) }
let(:base_url) { "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/bulk_actions" }
describe 'PATCH articles/bulk_actions/update_status' do
let(:update_status_url) { "#{base_url}/update_status" }
context 'when unauthenticated' do
it 'returns unauthorized' do
patch update_status_url, params: { ids: [article_one.id], status: 'published' }, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated as agent' do
it 'returns unauthorized' do
patch update_status_url,
headers: agent.create_new_auth_token,
params: { ids: [article_one.id], status: 'published' },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated as admin' do
it 'publishes multiple articles' do
patch update_status_url,
headers: admin.create_new_auth_token,
params: { ids: [article_one.id, article_two.id], status: 'published' },
as: :json
expect(response).to have_http_status(:ok)
expect(article_one.reload.status).to eq('published')
expect(article_two.reload.status).to eq('published')
end
it 'archives multiple articles' do
patch update_status_url,
headers: admin.create_new_auth_token,
params: { ids: [article_one.id, article_three.id], status: 'archived' },
as: :json
expect(response).to have_http_status(:ok)
expect(article_one.reload.status).to eq('archived')
expect(article_three.reload.status).to eq('archived')
end
it 'sets articles to draft' do
patch update_status_url,
headers: admin.create_new_auth_token,
params: { ids: [article_three.id], status: 'draft' },
as: :json
expect(response).to have_http_status(:ok)
expect(article_three.reload.status).to eq('draft')
end
it 'does not affect articles not in the list' do
patch update_status_url,
headers: admin.create_new_auth_token,
params: { ids: [article_one.id], status: 'published' },
as: :json
expect(article_one.reload.status).to eq('published')
expect(article_three.reload.status).to eq('published')
end
it 'returns unprocessable entity when no articles found' do
patch update_status_url,
headers: admin.create_new_auth_token,
params: { ids: [0], status: 'published' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe 'DELETE articles/bulk_actions/delete_articles' do
let(:destroy_url) { "#{base_url}/delete_articles" }
context 'when unauthenticated' do
it 'returns unauthorized' do
delete destroy_url, params: { ids: [article_one.id] }, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated as agent' do
it 'returns unauthorized' do
delete destroy_url,
headers: agent.create_new_auth_token,
params: { ids: [article_one.id] },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated as admin' do
it 'deletes multiple articles' do
expect do
delete destroy_url,
headers: admin.create_new_auth_token,
params: { ids: [article_one.id, article_two.id] },
as: :json
end.to change(Article, :count).by(-2)
expect(response).to have_http_status(:ok)
end
it 'does not delete articles not in the list' do
delete destroy_url,
headers: admin.create_new_auth_token,
params: { ids: [article_one.id] },
as: :json
expect(Article.exists?(article_one.id)).to be(false)
expect(Article.exists?(article_three.id)).to be(true)
end
it 'returns unprocessable entity when no articles found' do
delete destroy_url,
headers: admin.create_new_auth_token,
params: { ids: [0] },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
end
@@ -192,6 +192,38 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
end
end
describe 'POST /api/v1/accounts/{account.id}/portals/{portal.slug}/articles/reorder' do
let!(:article_2) do
create(:article, category: category, portal: portal, account_id: account.id, author_id: agent.id, position: 20)
end
let(:positions_hash) do
{
article.id => 20,
article_2.id => 10
}
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/reorder",
params: { positions_hash: positions_hash }
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
it 'reorders articles' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/reorder",
params: { positions_hash: positions_hash },
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
expect(article.reload.position).to eq(20)
expect(article_2.reload.position).to eq(10)
end
end
end
describe 'GET /api/v1/accounts/{account.id}/portals/{portal.slug}/articles' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
@@ -68,6 +68,12 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
'action_name': :assign_team,
'action_params': [1]
},
{
'action_name': :remove_assigned_agent
},
{
'action_name': :remove_assigned_team
},
{
'action_name': :add_label,
'action_params': %w[support priority_customer]
@@ -34,6 +34,10 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
context 'when it is an authenticated user' do
let!(:agent) { create(:user, account: account, role: :agent) }
before do
Conversation.all.find_each { |conversation| create(:inbox_member, inbox: conversation.inbox, user: agent) }
end
it 'Ignores bulk_actions for wrong type' do
post "/api/v1/accounts/#{account.id}/bulk_actions",
headers: agent.create_new_auth_token,
@@ -202,6 +206,10 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
context 'when it is an authenticated user' do
let!(:agent) { create(:user, account: account, role: :agent) }
before do
Conversation.all.find_each { |conversation| create(:inbox_member, inbox: conversation.inbox, user: agent) }
end
it 'Bulk delete conversation labels' do
Conversation.first.add_labels(%w[support priority_customer])
Conversation.second.add_labels(%w[support priority_customer])
@@ -255,6 +263,31 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
expect(response).to have_http_status(:success)
end
it 'permits contact label removal params' do
contact_one = create(:contact, account: account)
contact_two = create(:contact, account: account)
expect do
post "/api/v1/accounts/#{account.id}/bulk_actions",
headers: agent.create_new_auth_token,
params: {
type: 'Contact',
ids: [contact_one.id, contact_two.id],
labels: { remove: %w[vip support] },
extra: 'ignored'
}
end.to have_enqueued_job(Contacts::BulkActionJob).with(
account.id,
agent.id,
hash_including(
'ids' => [contact_one.id.to_s, contact_two.id.to_s],
'labels' => hash_including('remove' => %w[vip support])
)
)
expect(response).to have_http_status(:success)
end
it 'returns unauthorized for delete action when user is not admin' do
contact = create(:contact, account: account)
@@ -45,6 +45,28 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
expect(json_response).to have_key(:models)
expect(json_response).to have_key(:features)
end
it 'returns effective model provider and source for each feature' do
account.update!(captain_models: { 'editor' => 'gpt-4.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, :editor)).to include(
model: 'gpt-4.1',
selected: 'gpt-4.1',
provider: 'openai',
source: 'account_override'
)
expect(json_response.dig(:features, :label_suggestion)).to include(
model: Llm::Models.default_model_for('label_suggestion'),
selected: Llm::Models.default_model_for('label_suggestion'),
provider: 'openai',
source: 'default'
)
end
end
end
@@ -84,6 +106,65 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
expect(account.reload.captain_models['editor']).to eq('gpt-4.1-mini')
end
it 'does not persist unknown captain model feature keys' do
put "/api/v1/accounts/#{account.id}/captain/preferences",
headers: admin.create_new_auth_token,
params: { captain_models: { editor: 'gpt-4.1-mini', unknown_feature: 'gpt-4.1' } },
as: :json
expect(response).to have_http_status(:success)
expect(account.reload.captain_models).to eq('editor' => 'gpt-4.1-mini')
end
it 'rejects invalid captain model values for the feature' do
put "/api/v1/accounts/#{account.id}/captain/preferences",
headers: admin.create_new_auth_token,
params: { captain_models: { label_suggestion: 'gpt-5.1' } },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(json_response[:message]).to include('not a valid model for label_suggestion')
expect(account.reload.captain_models).to be_nil
end
it 'removes blank captain model overrides' do
account.update!(captain_models: { 'editor' => 'gpt-4.1' })
put "/api/v1/accounts/#{account.id}/captain/preferences",
headers: admin.create_new_auth_token,
params: { captain_models: { editor: '' } },
as: :json
expect(response).to have_http_status(:success)
expect(account.reload.captain_models).to be_nil
expect(json_response.dig(:features, :editor)).to include(
selected: Llm::Models.default_model_for('editor'),
source: 'default'
)
end
it 'updates captain_models for document FAQ generation' do
put "/api/v1/accounts/#{account.id}/captain/preferences",
headers: admin.create_new_auth_token,
params: { captain_models: { document_faq_generation: 'gpt-5.2' } },
as: :json
expect(response).to have_http_status(:success)
expect(json_response.dig(:features, :document_faq_generation, :selected)).to eq('gpt-5.2')
expect(account.reload.captain_models['document_faq_generation']).to eq('gpt-5.2')
end
it 'updates captain_models for PDF FAQ generation' do
put "/api/v1/accounts/#{account.id}/captain/preferences",
headers: admin.create_new_auth_token,
params: { captain_models: { pdf_faq_generation: 'gpt-5.2' } },
as: :json
expect(response).to have_http_status(:success)
expect(json_response.dig(:features, :pdf_faq_generation, :selected)).to eq('gpt-5.2')
expect(account.reload.captain_models['pdf_faq_generation']).to eq('gpt-5.2')
end
it 'updates captain_features' do
put "/api/v1/accounts/#{account.id}/captain/preferences",
headers: admin.create_new_auth_token,
@@ -237,6 +237,47 @@ RSpec.describe 'Api::V1::Accounts::Categories', type: :request do
end
end
describe 'POST /api/v1/accounts/{account.id}/portals/{portal.slug}/categories/reorder' do
let(:positions_hash) do
{
category.id => 40,
category_to_associate.id => 10,
related_category_1.id => 30,
related_category_2.id => 20
}
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/reorder",
params: { positions_hash: positions_hash }
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
it 'reorders categories' do
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/reorder",
params: { positions_hash: positions_hash },
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
expect(category.reload.position).to eq(40)
expect(category_to_associate.reload.position).to eq(10)
expect(related_category_1.reload.position).to eq(30)
expect(related_category_2.reload.position).to eq(20)
end
it 'returns not found when portal does not exist' do
post "/api/v1/accounts/#{account.id}/portals/invalid-portal-slug/categories/reorder",
params: { positions_hash: positions_hash },
headers: admin.create_new_auth_token
expect(response).to have_http_status(:not_found)
end
end
end
describe 'GET /api/v1/accounts/{account.id}/portals/{portal.slug}/categories' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
@@ -0,0 +1,84 @@
require 'rails_helper'
RSpec.describe '/api/v1/accounts/{account.id}/contacts/:id/attachments', type: :request do
let(:account) { create(:account) }
let(:contact) { create(:contact, account: account) }
let(:inbox_1) { create(:inbox, account: account) }
let(:inbox_2) { create(:inbox, account: account) }
let(:contact_inbox_1) { create(:contact_inbox, contact: contact, inbox: inbox_1) }
let(:contact_inbox_2) { create(:contact_inbox, contact: contact, inbox: inbox_2) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:unknown) { create(:user, account: account, role: nil) }
before do
create(:inbox_member, user: agent, inbox: inbox_1)
conversation_1 = create(:conversation, account: account, inbox: inbox_1, contact: contact, contact_inbox: contact_inbox_1)
conversation_2 = create(:conversation, account: account, inbox: inbox_2, contact: contact, contact_inbox: contact_inbox_2)
create(:message, :with_attachment, conversation: conversation_1, account: account, inbox: inbox_1, message_type: 'incoming')
create(:message, :with_attachment, conversation: conversation_2, account: account, inbox: inbox_2, message_type: 'incoming')
end
describe 'GET /api/v1/accounts/{account.id}/contacts/:id/attachments' do
context 'when unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/attachments"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when user is logged in' do
context 'with user as administrator' do
it 'returns attachments from all the contact conversations' do
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/attachments",
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload'].length).to eq 2
expect(json_response['meta']['total_count']).to eq 2
end
it 'serialises the conversation display id as conversation_id' do
conversation = contact.conversations.first
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/attachments",
headers: admin.create_new_auth_token
payload = response.parsed_body['payload']
attachment = payload.find { |a| a['conversation_id'] == conversation.display_id }
expect(attachment).not_to be_nil
expect(attachment).to include('id', 'message_id', 'data_url', 'file_type', 'created_at', 'sender')
end
end
context 'with user as agent' do
it 'returns attachments only from inboxes the agent has access to' do
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/attachments",
headers: agent.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload'].length).to eq 1
expect(json_response['meta']['total_count']).to eq 1
end
end
context 'with user as unknown role' do
it 'returns no attachments' do
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/attachments",
headers: unknown.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']).to be_empty
end
end
end
end
end
@@ -45,6 +45,7 @@ RSpec.describe 'Contacts API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
response_body = response.parsed_body
contact_emails = response_body['payload'].pluck('email')
contact_inboxes_source_ids = response_body['payload'].flat_map { |c| c['contact_inboxes'].pluck('source_id') }
@@ -100,7 +101,7 @@ RSpec.describe 'Contacts API', type: :request do
end
it 'returns all contacts with company name desc order' do
get "/api/v1/accounts/#{account.id}/contacts?include_contact_inboxes=false&sort=-company",
get "/api/v1/accounts/#{account.id}/contacts?include_contact_inboxes=false&sort=-company_name",
headers: admin.create_new_auth_token,
as: :json
@@ -111,15 +112,15 @@ RSpec.describe 'Contacts API', type: :request do
end
it 'returns all contacts with company name asc order with null values at last' do
get "/api/v1/accounts/#{account.id}/contacts?include_contact_inboxes=false&sort=-company",
contact_3
get "/api/v1/accounts/#{account.id}/contacts?include_contact_inboxes=false&sort=-company_name",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload'].first['email']).to eq(contact_1.email)
expect(response_body['payload'].first['id']).to eq(contact_1.id)
expect(response_body['payload'].last['email']).to eq(contact_4.email)
expect(response_body['payload'].first(2).pluck('id')).to contain_exactly(contact.id, contact_1.id)
expect(response_body['payload'].last(2).pluck('id')).to contain_exactly(contact_3.id, contact_4.id)
end
it 'returns all contacts with country name desc order with null values at last' do
@@ -331,6 +332,7 @@ RSpec.describe 'Contacts API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(response.body).to include(contact2.email)
expect(response.body).not_to include(contact1.email)
end
@@ -443,6 +445,7 @@ RSpec.describe 'Contacts API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(response.body).to include(contact2.email)
expect(response.body).to include(contact1.email)
end
@@ -497,6 +500,7 @@ RSpec.describe 'Contacts API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(response.body).to include(contact.name)
end
end
@@ -620,6 +624,7 @@ RSpec.describe 'Contacts API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(contact.reload.name).to eq('Test Blub')
# custom attributes are merged properly without overwriting existing ones
expect(contact.custom_attributes).to eq({ 'test' => 'new test', 'test1' => 'test1', 'test2' => 'test2' })
@@ -31,6 +31,7 @@ RSpec.describe 'Conversation Messages API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(conversation.messages.count).to eq(1)
expect(conversation.messages.first.content).to eq(params[:content])
end
@@ -182,6 +183,7 @@ RSpec.describe 'Conversation Messages API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(JSON.parse(response.body, symbolize_names: true)[:meta][:contact][:id]).to eq(conversation.contact_id)
end
end
@@ -27,6 +27,7 @@ RSpec.describe 'Conversations API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
body = JSON.parse(response.body, symbolize_names: true)
expect(body[:data][:meta][:all_count]).to eq(1)
expect(body[:data][:meta].keys).to include(:all_count, :mine_count, :assigned_count, :unassigned_count)
@@ -100,6 +101,77 @@ RSpec.describe 'Conversations API', type: :request do
end
end
describe 'GET /api/v1/accounts/{account.id}/conversations/unread_counts' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/conversations/unread_counts"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:visible_inbox) { create(:inbox, account: account) }
let(:hidden_inbox) { create(:inbox, account: account) }
let(:label) { create(:label, account: account, title: 'billing', show_on_sidebar: true) }
let(:team) { create(:team, account: account, allow_auto_assign: false) }
before do
create(:inbox_member, user: agent, inbox: visible_inbox)
create(:team_member, user: agent, team: team)
end
after do
Conversations::UnreadCounts::Store.clear_account!(account.id)
end
context 'when conversation unread counts feature is enabled' do
before do
account.enable_features!(:conversation_unread_counts)
end
it 'returns unread conversation counts scoped to the signed-in user' do
create_unread_conversation(account: account, inbox: visible_inbox, labels: [label.title])
create_unread_conversation(account: account, inbox: hidden_inbox, labels: [label.title])
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 eq(
'all_count' => 1,
'inboxes' => { visible_inbox.id.to_s => 1 },
'labels' => { label.id.to_s => 1 },
'teams' => {}
)
end
it 'returns unread team conversation counts scoped to the signed-in user' do
create_unread_conversation(account: account, inbox: visible_inbox, team: team)
create_unread_conversation(account: account, inbox: hidden_inbox, team: team)
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']['teams']).to eq(team.id.to_s => 1)
end
end
it 'returns forbidden when conversation unread counts feature is disabled' do
get "/api/v1/accounts/#{account.id}/conversations/unread_counts",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:forbidden)
expect(response.parsed_body['error']).to eq('Conversation unread counts feature not enabled for this account')
end
end
end
describe 'GET /api/v1/accounts/{account.id}/conversations/search' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
@@ -165,6 +237,7 @@ RSpec.describe 'Conversations API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
response_data = JSON.parse(response.body, symbolize_names: true)
expect(response_data.count).to eq(2)
end
@@ -234,6 +307,7 @@ RSpec.describe 'Conversations API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(JSON.parse(response.body, symbolize_names: true)[:id]).to eq(conversation.display_id)
end
@@ -282,6 +356,7 @@ RSpec.describe 'Conversations API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(JSON.parse(response.body, symbolize_names: true)[:priority]).to eq('high')
end
@@ -342,6 +417,7 @@ RSpec.describe 'Conversations API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
response_data = JSON.parse(response.body, symbolize_names: true)
expect(response_data[:additional_attributes]).to eq(additional_attributes)
end
@@ -449,9 +525,11 @@ RSpec.describe 'Conversations API', type: :request do
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
headers: agent.create_new_auth_token,
params: { status: 'open' },
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(conversation.reload.status).to eq('open')
end
@@ -647,6 +725,37 @@ RSpec.describe 'Conversations API', type: :request do
.with(Conversation::CONVERSATION_TYPING_ON, kind_of(Time), { conversation: conversation, user: agent, is_private: true })
end
end
context 'when it is an authenticated bot' do
let(:agent_bot) { create(:agent_bot, account: account) }
it 'toggles the conversation typing status' do
create(:agent_bot_inbox, inbox: conversation.inbox, agent_bot: agent_bot)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_typing_status",
headers: { api_access_token: agent_bot.access_token.token },
params: { typing_status: 'on', is_private: false },
as: :json
expect(response).to have_http_status(:success)
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(Conversation::CONVERSATION_TYPING_ON, kind_of(Time), { conversation: conversation, user: agent_bot, is_private: false })
end
end
context 'when it is an authenticated platform app token' do
let(:platform_app) { create(:platform_app) }
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_typing_status",
headers: { api_access_token: platform_app.access_token.token },
params: { typing_status: 'on', is_private: false },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/conversations/:id/update_last_seen' do
@@ -691,6 +800,23 @@ RSpec.describe 'Conversations API', type: :request do
expect(conversation.reload.assignee_last_seen_at).not_to be_nil
end
it 'marks unread notifications as read when updating last seen' do
allow(Rails.configuration.dispatcher).to receive(:dispatch)
notification = create(:notification, account: account, user: agent, primary_actor: conversation, read_at: nil)
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(notification.reload.read_at).to be_present
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'notification.updated',
kind_of(Time),
hash_including(notification: have_attributes(id: notification.id))
)
end
it 'throttles updates within an hour when there are no unread messages' do
conversation.update!(agent_last_seen_at: 30.minutes.ago)
# Ensure all messages are older than agent_last_seen_at (no unread messages)
@@ -722,6 +848,23 @@ RSpec.describe 'Conversations API', type: :request do
expect(conversation.reload.agent_last_seen_at).to be > initial_last_seen
end
it 'refreshes unread count cache when conversation is marked read' do
account.enable_features!(:conversation_unread_counts)
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)
Conversations::UnreadCounts::Builder.new(account).build_base!
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
headers: agent.create_new_auth_token,
as: :json
inbox_key = Conversations::UnreadCounts::Store.inbox_key(account.id, conversation.inbox_id)
expect(response).to have_http_status(:success)
expect(Conversations::UnreadCounts::Store.counts_for_keys([inbox_key])).to eq(inbox_key => 0)
ensure
Conversations::UnreadCounts::Store.clear_account!(account.id)
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)
@@ -792,6 +935,22 @@ RSpec.describe 'Conversations API', type: :request do
expect(conversation.reload.agent_last_seen_at).to eq(last_seen_at)
expect(conversation.reload.assignee_last_seen_at).to eq(last_seen_at)
end
it 'refreshes unread count cache when conversation is marked unread' do
account.enable_features!(:conversation_unread_counts)
conversation.update!(agent_last_seen_at: 1.minute.from_now, assignee_last_seen_at: 1.minute.from_now)
Conversations::UnreadCounts::Builder.new(account).build_base!
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/unread",
headers: agent.create_new_auth_token,
as: :json
inbox_key = Conversations::UnreadCounts::Store.inbox_key(account.id, conversation.inbox_id)
expect(response).to have_http_status(:success)
expect(Conversations::UnreadCounts::Store.counts_for_keys([inbox_key])).to eq(inbox_key => 1)
ensure
Conversations::UnreadCounts::Store.clear_account!(account.id)
end
end
end
@@ -984,6 +1143,8 @@ RSpec.describe 'Conversations API', type: :request do
expect(response).to have_http_status(:success)
response_body = response.parsed_body
attachment = conversation.messages.last.attachments.first
expect(response_body['payload'].first['id']).to eq(attachment.id)
expect(response_body['payload'].first['file_type']).to eq('image')
expect(response_body['payload'].first['sender']['id']).to eq(conversation.messages.last.sender.id)
end
@@ -2,7 +2,8 @@ require 'rails_helper'
RSpec.describe 'Custom Attribute Definitions API', type: :request do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:admin) { create(:user, account: account, role: :administrator) }
describe 'GET /api/v1/accounts/{account.id}/custom_attribute_definitions' do
context 'when it is an unauthenticated user' do
@@ -19,7 +20,7 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
create(:custom_attribute_definition, attribute_model: 'contact_attribute', account: account)
get "/api/v1/accounts/#{account.id}/custom_attribute_definitions",
headers: user.create_new_auth_token,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
@@ -45,7 +46,7 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
context 'when it is an authenticated user' do
it 'shows the custom attribute definition' do
get "/api/v1/accounts/#{account.id}/custom_attribute_definitions/#{custom_attribute_definition.id}",
headers: user.create_new_auth_token,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
@@ -81,7 +82,7 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
context 'when it is an authenticated user' do
it 'creates the filter' do
expect do
post "/api/v1/accounts/#{account.id}/custom_attribute_definitions", headers: user.create_new_auth_token,
post "/api/v1/accounts/#{account.id}/custom_attribute_definitions", headers: admin.create_new_auth_token,
params: payload
end.to change(CustomAttributeDefinition, :count).by(1)
@@ -90,6 +91,18 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
expect(json_response['attribute_key']).to eq 'developer_id'
end
context 'when it is an agent' do
it 'returns forbidden and does not create the custom attribute' do
expect do
post "/api/v1/accounts/#{account.id}/custom_attribute_definitions",
headers: agent.create_new_auth_token,
params: payload
end.not_to change(CustomAttributeDefinition, :count)
expect(response).to have_http_status(:unauthorized)
end
end
context 'when creating with a conflicting attribute_key' do
let(:standard_key) { CustomAttributeDefinition::STANDARD_ATTRIBUTES[:conversation].first }
let(:conflicting_payload) do
@@ -105,7 +118,7 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
it 'returns error for conflicting key' do
post "/api/v1/accounts/#{account.id}/custom_attribute_definitions",
headers: user.create_new_auth_token,
headers: admin.create_new_auth_token,
params: conflicting_payload
expect(response).to have_http_status(:unprocessable_entity)
@@ -132,7 +145,7 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
context 'when it is an authenticated user' do
it 'updates the custom attribute definition' do
patch "/api/v1/accounts/#{account.id}/custom_attribute_definitions/#{custom_attribute_definition.id}",
headers: user.create_new_auth_token,
headers: admin.create_new_auth_token,
params: payload,
as: :json
expect(response).to have_http_status(:success)
@@ -141,6 +154,19 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
expect(custom_attribute_definition.reload.attribute_model).to eq('conversation_attribute')
end
end
context 'when it is an agent' do
it 'returns forbidden and does not update the custom attribute' do
original_name = custom_attribute_definition.attribute_display_name
patch "/api/v1/accounts/#{account.id}/custom_attribute_definitions/#{custom_attribute_definition.id}",
headers: agent.create_new_auth_token,
params: payload,
as: :json
expect(response).to have_http_status(:unauthorized)
expect(custom_attribute_definition.reload.attribute_display_name).to eq(original_name)
end
end
end
describe 'DELETE /api/v1/accounts/{account.id}/custom_attribute_definitions/:id' do
@@ -156,11 +182,22 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
context 'when it is an authenticated admin user' do
it 'deletes custom attribute' do
delete "/api/v1/accounts/#{account.id}/custom_attribute_definitions/#{custom_attribute_definition.id}",
headers: user.create_new_auth_token,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:no_content)
expect(account.custom_attribute_definitions.count).to be 0
end
end
context 'when it is an agent' do
it 'returns forbidden and does not delete the custom attribute' do
delete "/api/v1/accounts/#{account.id}/custom_attribute_definitions/#{custom_attribute_definition.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
expect(account.custom_attribute_definitions.count).to be 1
end
end
end
end
@@ -32,6 +32,7 @@ RSpec.describe 'Inboxes API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(JSON.parse(response.body, symbolize_names: true)[:payload].size).to eq(2)
end
@@ -95,9 +96,39 @@ RSpec.describe 'Inboxes API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(JSON.parse(response.body, symbolize_names: true)[:id]).to eq(inbox.id)
end
it 'returns reauthorization_required for embedded signup whatsapp channel when reauth required' do
whatsapp_channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false,
validate_provider_config: false)
whatsapp_inbox = create(:inbox, channel: whatsapp_channel, account: account)
whatsapp_channel.prompt_reauthorization!
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['reauthorization_required']).to be(true)
end
it 'does not flag reauthorization_required for manual whatsapp channel even when reauth required' do
whatsapp_channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false,
validate_provider_config: false)
whatsapp_channel.update!(provider_config: whatsapp_channel.provider_config.merge('source' => 'manual'))
whatsapp_inbox = create(:inbox, channel: whatsapp_channel, account: account)
whatsapp_channel.prompt_reauthorization!
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['reauthorization_required']).to be(false)
end
it 'returns the inbox if assigned inbox is assigned as agent' do
create(:inbox_member, user: agent, inbox: inbox)
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
@@ -383,6 +414,7 @@ RSpec.describe 'Inboxes API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(response.body).to include('test.com')
end
@@ -478,6 +510,7 @@ RSpec.describe 'Inboxes API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(inbox.reload.enable_auto_assignment).to be_falsey
expect(inbox.reload.portal_id).to eq(portal.id)
expect(response.parsed_body['name']).to eq 'new test inbox'
@@ -564,8 +597,10 @@ RSpec.describe 'Inboxes API', type: :request do
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
imap_connection = double
allow(Mail).to receive(:connection).and_return(imap_connection)
imap_connection = instance_double(Net::IMAP, disconnected?: false)
allow(Net::IMAP).to receive(:new).and_return(imap_connection)
allow(imap_connection).to receive(:login)
allow(imap_connection).to receive(:disconnect)
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
@@ -574,7 +609,8 @@ RSpec.describe 'Inboxes API', type: :request do
imap_enabled: true,
imap_address: 'imap.gmail.com',
imap_port: 993,
imap_login: 'imaptest@gmail.com'
imap_login: 'imaptest@gmail.com',
imap_authentication: 'login'
}
},
as: :json
@@ -583,6 +619,7 @@ RSpec.describe 'Inboxes API', type: :request do
expect(email_channel.reload.imap_enabled).to be true
expect(email_channel.reload.imap_address).to eq('imap.gmail.com')
expect(email_channel.reload.imap_port).to eq(993)
expect(email_channel.reload.imap_authentication).to eq('login')
end
it 'updates avatar when administrator' do
@@ -1000,6 +1037,19 @@ RSpec.describe 'Inboxes API', type: :request do
expect(response).to have_http_status(:unauthorized)
end
it 'does not allow binding an agent bot from another account' do
other_account = create(:account)
foreign_bot = create(:agent_bot, account: other_account)
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/set_agent_bot",
headers: admin.create_new_auth_token,
params: { agent_bot: foreign_bot.id },
as: :json
expect(response).to have_http_status(:not_found)
expect(inbox.reload.agent_bot).to be_nil
end
end
end
@@ -3,6 +3,8 @@ require 'rails_helper'
RSpec.describe 'Integration Apps API', type: :request do
let(:account) { create(:account) }
before { allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) }
describe 'GET /api/v1/integrations/apps' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
@@ -40,7 +42,7 @@ RSpec.describe 'Integration Apps API', type: :request do
expect(app['hooks'].first['settings']).to be_nil
end
it 'returns all active apps with sensitive information if user is an admin' do
it 'returns all active apps with admin metadata if user is an admin' do
first_app = Integrations::App.all.find { |app| app.active?(account) }
get api_v1_account_integrations_apps_url(account),
headers: admin.create_new_auth_token,
@@ -54,19 +56,21 @@ RSpec.describe 'Integration Apps API', type: :request do
end
it 'returns slack app with appropriate redirect url when configured' do
with_modified_env SLACK_CLIENT_ID: 'client_id', SLACK_CLIENT_SECRET: 'client_secret' do
get api_v1_account_integrations_apps_url(account),
headers: admin.create_new_auth_token,
as: :json
allow(GlobalConfigService).to receive(:load).and_call_original
allow(GlobalConfigService).to receive(:load).with('SLACK_CLIENT_ID', nil).and_return('client_id')
allow(GlobalConfigService).to receive(:load).with('SLACK_CLIENT_SECRET', nil).and_return('client_secret')
expect(response).to have_http_status(:success)
apps = response.parsed_body['payload']
slack_app = apps.find { |app| app['id'] == 'slack' }
expect(slack_app['action']).to include('client_id=client_id')
end
get api_v1_account_integrations_apps_url(account),
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
apps = response.parsed_body['payload']
slack_app = apps.find { |app| app['id'] == 'slack' }
expect(slack_app['action']).to include('client_id=client_id')
end
it 'will return sensitive information for openai app for admins' do
it 'returns visible hook settings for openai app for admins' do
openai = create(:integrations_hook, :openai, account: account)
get api_v1_account_integrations_apps_url(account),
headers: admin.create_new_auth_token,
@@ -77,6 +81,34 @@ RSpec.describe 'Integration Apps API', type: :request do
app = response.parsed_body['payload'].find { |int_app| int_app['id'] == openai.app.id }
expect(app['hooks'].first['settings']).not_to be_nil
end
it 'redacts secrets and only returns visible settings for openai hooks' do
openai = create(
:integrations_hook,
:openai,
account: account,
settings: { api_key: 'sk-secret', label_suggestion: true }
)
get api_v1_account_integrations_apps_url(account),
headers: admin.create_new_auth_token,
as: :json
app = response.parsed_body['payload'].find { |int_app| int_app['id'] == openai.app.id }
expect(app['hooks'].first['settings']).to eq('label_suggestion' => true)
end
it 'keeps slack channel display settings while redacting unspecified settings' do
create(:integrations_hook, account: account, settings: { channel_name: 'support', signing_secret: 'secret' })
allow(GlobalConfigService).to receive(:load).and_call_original
allow(GlobalConfigService).to receive(:load).with('SLACK_CLIENT_SECRET', nil).and_return('client_secret')
get api_v1_account_integrations_apps_url(account),
headers: admin.create_new_auth_token,
as: :json
app = response.parsed_body['payload'].find { |int_app| int_app['id'] == 'slack' }
expect(app['hooks'].first['settings']).to eq('channel_name' => 'support')
end
end
end
@@ -115,7 +147,7 @@ RSpec.describe 'Integration Apps API', type: :request do
expect(app['hooks'].first['settings']).to be_nil
end
it 'will return sensitive information for openai app for admins' do
it 'returns visible hook settings for openai app for admins' do
openai = create(:integrations_hook, :openai, account: account)
get api_v1_account_integrations_app_url(account_id: account.id, id: openai.app.id),
headers: admin.create_new_auth_token,
@@ -126,6 +158,53 @@ RSpec.describe 'Integration Apps API', type: :request do
app = response.parsed_body
expect(app['hooks'].first['settings']).not_to be_nil
end
it 'hides credentials and keeps visible settings for google credential integrations' do
hook = create(:integrations_hook, :google_translate, account: account,
settings: { project_id: 'project-1',
credentials: { private_key: 'secret' } })
get api_v1_account_integrations_app_url(account_id: account.id, id: hook.app.id),
headers: admin.create_new_auth_token,
as: :json
app = response.parsed_body
expect(app['hooks'].first['settings']).to eq('project_id' => 'project-1')
end
it 'returns empty settings for oauth integrations with no visible properties' do
hook = create(
:integrations_hook,
:linear,
account: account,
settings: { token_type: 'Bearer', refresh_token: 'refresh-secret', expires_in: 7200 }
)
get api_v1_account_integrations_app_url(account_id: account.id, id: hook.app.id),
headers: admin.create_new_auth_token,
as: :json
app = response.parsed_body
expect(app['hooks'].first['settings']).to eq({})
end
it 'does not expose leadsquared credential keys in visible settings' do
account.enable_features('crm_integration')
hook = create(:integrations_hook, :leadsquared, account: account,
settings: {
'access_key' => 'access-secret',
'secret_key' => 'secret',
'endpoint_url' => 'https://api.leadsquared.com/',
'enable_conversation_activity' => true
})
get api_v1_account_integrations_app_url(account_id: account.id, id: hook.app.id),
headers: admin.create_new_auth_token,
as: :json
settings = response.parsed_body['hooks'].first['settings']
expect(settings).to eq(
'endpoint_url' => 'https://api.leadsquared.com/',
'enable_conversation_activity' => true
)
end
end
end
end
@@ -15,6 +15,8 @@ RSpec.describe 'Dyte Integration API', type: :request do
let(:unauthorized_agent) { create(:user, account: account, role: :agent) }
before do
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
.and_return(Integrations::Cloudflare::RealtimeKitCredentialsValidator::Result.new(true, nil))
create(:integrations_hook, :dyte, account: account)
create(:inbox_member, user: agent, inbox: conversation.inbox)
end
@@ -39,7 +41,7 @@ RSpec.describe 'Dyte Integration API', type: :request do
context 'when it is an agent with inbox access and the Dyte API is a success' do
before do
stub_request(:post, 'https://api.dyte.io/v2/meetings')
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings')
.to_return(
status: 200,
body: { success: true, data: { id: 'meeting_id' } }.to_json,
@@ -62,7 +64,7 @@ RSpec.describe 'Dyte Integration API', type: :request do
context 'when it is an agent with inbox access and the Dyte API is errored' do
before do
stub_request(:post, 'https://api.dyte.io/v2/meetings')
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings')
.to_return(
status: 422,
body: { success: false, data: { message: 'Title is required' } }.to_json,
@@ -112,15 +114,15 @@ RSpec.describe 'Dyte Integration API', type: :request do
context 'when it is an agent with inbox access and message_type is integrations' do
before do
stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants')
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants')
.to_return(
status: 200,
body: { success: true, data: { id: 'random_uuid', auth_token: 'json-web-token' } }.to_json,
body: { success: true, data: { id: 'random_uuid', token: 'json-web-token' } }.to_json,
headers: headers
)
end
it 'returns auth_token' do
it 'returns token' do
post add_participant_to_meeting_api_v1_account_integrations_dyte_url(account),
params: { message_id: integration_message.id },
headers: agent.create_new_auth_token,
@@ -129,7 +131,7 @@ RSpec.describe 'Dyte Integration API', type: :request do
response_body = response.parsed_body
expect(response_body).to eq(
{
'id' => 'random_uuid', 'auth_token' => 'json-web-token'
'id' => 'random_uuid', 'token' => 'json-web-token'
}
)
end
@@ -38,6 +38,19 @@ RSpec.describe 'Integration Hooks API', type: :request do
data = response.parsed_body
expect(data['app_id']).to eq params[:app_id]
end
it 'validates Cloudflare RealtimeKit credentials before creating the hook' do
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
.and_return(Integrations::Cloudflare::RealtimeKitCredentialsValidator::Result.new(false, :invalid_api_token))
post api_v1_account_integrations_hooks_url(account_id: account.id),
params: { app_id: 'dyte', settings: { account_id: 'bad', app_id: 'bad', api_token: 'bad' } },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['message']).to include(I18n.t('errors.cloudflare.realtimekit.invalid_api_token'))
end
end
end
@@ -3,6 +3,7 @@ require 'rails_helper'
RSpec.describe 'Label API', type: :request do
let!(:account) { create(:account) }
let!(:label) { create(:label, account: account) }
let!(:conversation) { create(:conversation, account: account) }
describe 'GET /api/v1/accounts/{account.id}/labels' do
context 'when it is an unauthenticated user' do
@@ -101,4 +102,39 @@ RSpec.describe 'Label API', type: :request do
end
end
end
describe 'DELETE /api/v1/accounts/{account.id}/labels/:id' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/labels/#{label.id}"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
it 'deletes the label and enqueues label cleanup' do
label_deleted_at = Time.zone.parse('2026-05-07 10:00:00 UTC')
conversation.label_list.add(label.title)
conversation.save!
clear_enqueued_jobs
travel_to(label_deleted_at) do
expect do
delete "/api/v1/accounts/#{account.id}/labels/#{label.id}", headers: admin.create_new_auth_token, as: :json
end.to have_enqueued_job(Labels::RemoveAssociationsJob).with(
label_title: label.title,
account_id: account.id,
label_deleted_at: label_deleted_at
)
end
expect(response).to have_http_status(:ok)
expect(Label.exists?(label.id)).to be(false)
end
end
end
end
@@ -78,6 +78,9 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
'action_name': :add_label,
'action_params': %w[support priority_customer]
},
{
'action_name': :remove_assigned_agent
},
{
'action_name': :remove_assigned_team
},
@@ -236,6 +239,22 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
expect(json_response['error']).to eq('You are not authorized to do this action')
end
# A public macro can still point to an agent when an admin who authored it
# is later changed to the agent role. Public macros should remain
# admin-managed even when the original author is no longer an admin.
it 'does not allow agents to update public macros they created' do
macro = create(:macro, account: account, created_by: agent, updated_by: agent, visibility: :global)
put "/api/v1/accounts/#{account.id}/macros/#{macro.id}",
params: params,
headers: agent.create_new_auth_token
json_response = response.parsed_body
expect(response).to have_http_status(:unauthorized)
expect(json_response['error']).to eq('You are not authorized to do this action')
end
it 'allows update with existing blob_id' do
blob = ActiveStorage::Blob.create_and_upload!(
io: Rails.root.join('spec/assets/avatar.png').open,
@@ -484,6 +503,22 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
expect(conversation.reload.team_id).to be_nil
end
it 'Unassign the agent' do
macro.update!(actions: [
{ 'action_name' => 'remove_assigned_agent' }
])
conversation.update!(assignee: user_1)
expect(conversation.reload.assignee).to be_present
perform_enqueued_jobs do
post "/api/v1/accounts/#{account.id}/macros/#{macro.id}/execute",
params: { conversation_ids: [conversation.display_id] },
headers: administrator.create_new_auth_token
end
expect(conversation.reload.assignee).to be_nil
end
end
end
end
@@ -532,6 +567,21 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
expect(json_response['error']).to eq('You are not authorized to do this action')
end
# A public macro can still point to an agent when an admin who authored it
# is later changed to the agent role. Public macros should remain
# admin-managed even when the original author is no longer an admin.
it 'does not allow agents to delete public macros they created' do
macro = create(:macro, account: account, created_by: agent, updated_by: agent, visibility: :global)
delete "/api/v1/accounts/#{account.id}/macros/#{macro.id}",
headers: agent.create_new_auth_token
json_response = response.parsed_body
expect(response).to have_http_status(:unauthorized)
expect(json_response['error']).to eq('You are not authorized to do this action')
end
it 'Unauthorize to delete the macro' do
macro = create(:macro, account: account, created_by: agent, updated_by: agent)
@@ -43,6 +43,7 @@ RSpec.describe 'Microsoft Authorization API', type: :request do
]
expect(params['scope']).to eq(expected_scope)
expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback"])
expect(url).not_to match(/(?:\?|&)prompt=/)
# Validate state parameter exists and can be decoded back to the account
expect(params['state']).to be_present
@@ -101,6 +101,20 @@ RSpec.describe 'Notifications API', type: :request do
expect(response).to have_http_status(:success)
expect(notification.reload.read_at).not_to eq('')
end
it 'does not update a notification reached via a different account that the user belongs to' do
other_account = create(:account)
create(:account_user, account: other_account, user: admin, role: :administrator)
original_read_at = notification.read_at
patch "/api/v1/accounts/#{other_account.id}/notifications/#{notification.id}",
headers: admin.create_new_auth_token,
params: { read_at: true },
as: :json
expect(response).to have_http_status(:not_found)
expect(notification.reload.read_at).to eq(original_read_at)
end
end
end
@@ -227,7 +241,7 @@ RSpec.describe 'Notifications API', type: :request do
let(:admin) { create(:user, account: account, role: :administrator) }
it 'deletes all the read notifications' do
expect(Notification::DeleteNotificationJob).to receive(:perform_later).with(admin, type: :read)
expect(Notification::DeleteNotificationJob).to receive(:perform_later).with(admin, account, type: :read)
post "/api/v1/accounts/#{account.id}/notifications/destroy_all",
headers: admin.create_new_auth_token,
@@ -238,7 +252,7 @@ RSpec.describe 'Notifications API', type: :request do
end
it 'deletes all the notifications' do
expect(Notification::DeleteNotificationJob).to receive(:perform_later).with(admin, type: :all)
expect(Notification::DeleteNotificationJob).to receive(:perform_later).with(admin, account, type: :all)
post "/api/v1/accounts/#{account.id}/notifications/destroy_all",
headers: admin.create_new_auth_token,
@@ -0,0 +1,237 @@
require 'rails_helper'
RSpec.describe 'Onboarding API', type: :request do
let(:account) { create(:account, domain: 'example.com') }
let(:admin) { create(:user, account: account, role: :administrator) }
describe 'PATCH /api/v1/accounts/{account.id}/onboarding' do
context 'when unauthenticated' do
it 'returns unauthorized' do
patch "/api/v1/accounts/#{account.id}/onboarding", params: { website: 'acme.com' }, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated as an agent (non-admin)' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'returns unauthorized and does not change the account' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { name: 'Hijacked', website: 'attacker.com' },
headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:unauthorized)
expect(account.reload.name).not_to eq('Hijacked')
end
it 'does not create a help center portal' do
account.update!(custom_attributes: { 'onboarding_step' => 'account_details' })
expect do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'attacker.com' },
headers: agent.create_new_auth_token, as: :json
end.not_to change(account.portals, :count)
end
end
context 'when finalizing account_details' do
before { account.update!(custom_attributes: { 'onboarding_step' => 'account_details' }) }
it 'saves name and locale' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { name: 'Acme Inc', locale: 'fr', onboarding_step: 'account_details' },
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
expect(account.reload.name).to eq('Acme Inc')
expect(account.locale).to eq('fr')
end
it 'merges custom_attributes' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com', industry: 'tech', company_size: '10-50', onboarding_step: 'account_details' },
headers: admin.create_new_auth_token, as: :json
attrs = account.reload.custom_attributes
expect(attrs['website']).to eq('acme.com')
expect(attrs['industry']).to eq('tech')
expect(attrs['company_size']).to eq('10-50')
end
context 'when on cloud (inbox setup is a cloud-only step)' do
before { allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) }
it 'advances onboarding_step to inbox_setup' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com', onboarding_step: 'account_details' },
headers: admin.create_new_auth_token, as: :json
expect(account.reload.custom_attributes['onboarding_step']).to eq('inbox_setup')
end
it 'does not create a help center portal when website is blank' do
expect do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { name: 'Acme Inc', onboarding_step: 'account_details' },
headers: admin.create_new_auth_token, as: :json
end.not_to change(account.portals, :count)
end
it 'is idempotent when the account_details completion is replayed' do
2.times do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com', onboarding_step: 'account_details' },
headers: admin.create_new_auth_token, as: :json
end
# Replaying step 1 always lands on inbox_setup; it never skips to done.
expect(account.reload.custom_attributes['onboarding_step']).to eq('inbox_setup')
end
end
context 'when off cloud (inbox setup is skipped)' do
before { allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false) }
it 'finishes onboarding instead of advancing to inbox_setup' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com', onboarding_step: 'account_details' },
headers: admin.create_new_auth_token, as: :json
expect(account.reload.custom_attributes).not_to have_key('onboarding_step')
end
it 'does not auto-create onboarding inboxes' do
expect(Onboarding::WebWidgetCreationService).not_to receive(:new)
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com', onboarding_step: 'account_details' },
headers: admin.create_new_auth_token, as: :json
end
end
end
context 'when replaying account_details after onboarding has finished' do
before { account.update!(custom_attributes: { 'website' => 'acme.com' }) }
it 'does not re-enter onboarding or persist the stale payload' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'stale.com', onboarding_step: 'account_details' },
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
expect(account.reload.custom_attributes).not_to have_key('onboarding_step')
expect(account.custom_attributes['website']).to eq('acme.com')
end
end
context 'when finalizing inbox_setup' do
before { account.update!(custom_attributes: { 'onboarding_step' => 'inbox_setup' }) }
it 'clears onboarding_step' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { onboarding_step: 'inbox_setup' },
headers: admin.create_new_auth_token, as: :json
expect(account.reload.custom_attributes).not_to have_key('onboarding_step')
end
it 'does not create another web widget inbox' do
expect(Onboarding::WebWidgetCreationService).not_to receive(:new)
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { onboarding_step: 'inbox_setup' },
headers: admin.create_new_auth_token, as: :json
end
it 'is idempotent when the finalize request is replayed' do
2.times do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { onboarding_step: 'inbox_setup' },
headers: admin.create_new_auth_token, as: :json
end
expect(account.reload.custom_attributes).not_to have_key('onboarding_step')
end
end
context 'when the declared onboarding_step is missing or unknown' do
before { account.update!(custom_attributes: { 'onboarding_step' => 'invite_team' }) }
it 'rejects a request without an onboarding_step and changes nothing' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com' },
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team')
expect(account.custom_attributes['website']).to be_nil
end
it 'rejects an unknown onboarding_step' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { onboarding_step: 'invite_team' },
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
it 'does not create a help center portal' do
expect do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com' },
headers: admin.create_new_auth_token, as: :json
end.not_to change(account.portals, :count)
end
end
context 'when completing inbox_setup out of order' do
before { account.update!(custom_attributes: { 'onboarding_step' => 'account_details' }) }
it 'does not clear onboarding_step while the account is still on account_details' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { onboarding_step: 'inbox_setup' },
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
expect(account.reload.custom_attributes['onboarding_step']).to eq('account_details')
end
end
end
describe 'GET /api/v1/accounts/{account.id}/onboarding/help_center_generation' do
context 'when unauthenticated' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation", as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated as an agent (non-admin)' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation",
headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when no help center generation has started' do
it 'returns not_started with zero counts' do
get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation",
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body).to include(
'generation_id' => nil,
'state' => nil,
'articles_count' => 0,
'categories_count' => 0
)
end
end
end
end
@@ -100,6 +100,41 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
expect(json_response['name']).to eql('test_portal')
expect(json_response['custom_domain']).to eql('support.chatwoot.dev')
end
it 'creates portal when custom_domain is omitted from request body' do
portal_params = {
portal: {
name: 'test_portal_no_domain',
slug: 'test_kbase_no_domain'
}
}
post "/api/v1/accounts/#{account.id}/portals",
params: portal_params,
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['name']).to eql('test_portal_no_domain')
expect(json_response['custom_domain']).to be_nil
end
it 'creates portal when custom_domain is blank' do
portal_params = {
portal: {
name: 'test_portal_blank_domain',
slug: 'test_kbase_blank_domain',
custom_domain: ''
}
}
post "/api/v1/accounts/#{account.id}/portals",
params: portal_params,
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['name']).to eql('test_portal_blank_domain')
expect(json_response['custom_domain']).to be_blank
end
end
end
@@ -117,7 +152,7 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
portal_params = {
portal: {
name: 'updated_test_portal',
config: { 'allowed_locales' => %w[en es] }
config: { 'allowed_locales' => %w[en es], 'draft_locales' => ['es'], 'default_locale' => 'en' }
}
}
@@ -130,8 +165,37 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['name']).to eql(portal_params[:portal][:name])
expect(json_response['config']).to eql({ 'allowed_locales' => [{ 'articles_count' => 0, 'categories_count' => 0, 'code' => 'en' },
{ 'articles_count' => 0, 'categories_count' => 0, 'code' => 'es' }] })
expect(json_response['config']).to eql(
{
'allowed_locales' => [
{ 'articles_count' => 0, 'categories_count' => 0, 'code' => 'en', 'draft' => false },
{ 'articles_count' => 0, 'categories_count' => 0, 'code' => 'es', 'draft' => true }
],
'default_locale' => 'en',
'layout' => 'classic',
'social_profiles' => {},
'locale_translations' => {}
}
)
end
it 'preserves drafted locales when draft_locales is omitted' do
portal.update!(config: { allowed_locales: %w[en es fr], draft_locales: ['es'], default_locale: 'en' })
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
params: {
portal: {
config: { allowed_locales: %w[en es fr], default_locale: 'en' }
}
},
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
portal.reload
expect(portal.draft_locale_codes).to eq(['es'])
expect(response.parsed_body.dig('config', 'allowed_locales')).to include(
a_hash_including('code' => 'es', 'draft' => true)
)
end
it 'archive portal' do
@@ -155,6 +219,33 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
expect(portal.archived).to be_truthy
end
it 'does not raise when blob_id is an integer (existing logo re-sent by frontend)' do
portal.logo.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
params: { portal: { name: 'updated_name' }, blob_id: portal.logo.blob.id },
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
expect(response.parsed_body['name']).to eq('updated_name')
expect(portal.reload.logo).to be_attached
end
it 'does not allow associating an inbox from another account' do
other_account = create(:account)
foreign_inbox = create(:inbox, account: other_account)
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
params: {
portal: { name: portal.name },
inbox_id: foreign_inbox.id
},
headers: admin.create_new_auth_token
expect(response).to have_http_status(:not_found)
expect(portal.reload.channel_web_widget_id).to be_nil
end
it 'clears associated web widget when inbox selection is blank' do
web_widget_inbox = create(:inbox, account: account)
portal.update!(channel_web_widget: web_widget_inbox.channel)
@@ -22,6 +22,7 @@ RSpec.describe 'Teams API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(response.parsed_body.first['id']).to eq(account.teams.first.id)
end
end
@@ -45,6 +46,7 @@ RSpec.describe 'Teams API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(response.parsed_body['id']).to eq(team.id)
end
end
@@ -83,6 +85,7 @@ RSpec.describe 'Teams API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(Team.count).to eq(2)
end
end
@@ -121,6 +124,7 @@ RSpec.describe 'Teams API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(team.reload.name).to eq('new-team')
end
end
@@ -26,8 +26,8 @@ RSpec.describe 'Accounts API', type: :request do
expect(AccountBuilder).to have_received(:new).with(params.except(:password).merge(user_password: params[:password]))
expect(account_builder).to have_received(:perform)
expect(response.headers.keys).to include('access-token', 'token-type', 'client', 'expiry', 'uid')
expect(response.body).to include('en')
expect(response.headers.keys).not_to include('access-token', 'token-type', 'client', 'expiry', 'uid')
expect(response.parsed_body['email']).to eq(email)
end
end
@@ -46,8 +46,8 @@ RSpec.describe 'Accounts API', type: :request do
as: :json
expect(ChatwootCaptcha).to have_received(:new).with('123')
expect(response.headers.keys).to include('access-token', 'token-type', 'client', 'expiry', 'uid')
expect(response.body).to include('en')
expect(response.headers.keys).not_to include('access-token', 'token-type', 'client', 'expiry', 'uid')
expect(response.parsed_body['email']).to eq(email)
end
end
@@ -68,6 +68,23 @@ RSpec.describe 'Accounts API', type: :request do
end
end
context 'when an authenticated user creates a second account' do
let(:existing_user) { create(:user, password: 'Password1!') }
it 'returns the full response with account_id' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
post api_v1_accounts_url,
params: { account_name: 'Second Account', email: existing_user.email,
user_full_name: existing_user.name, password: 'Password1!' },
headers: existing_user.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body.dig('data', 'account_id')).to be_present
end
end
end
context 'when ENABLE_ACCOUNT_SIGNUP env variable is set to false' do
it 'responds 404 on requests' do
params = { account_name: 'test', email: email, user_full_name: user_full_name }
@@ -81,8 +98,41 @@ RSpec.describe 'Accounts API', type: :request do
end
end
context 'when ENABLE_ACCOUNT_SIGNUP is stored as boolean false' do
before do
GlobalConfig.clear_cache
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
InstallationConfig.create!(name: 'ENABLE_ACCOUNT_SIGNUP', value: false, locked: false)
end
after do
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
GlobalConfig.clear_cache
end
it 'responds 404 on requests' do
params = { account_name: 'test', email: email, user_full_name: user_full_name, password: 'Password1!' }
post api_v1_accounts_url,
params: params,
as: :json
expect(response).to have_http_status(:not_found)
end
end
context 'when ENABLE_ACCOUNT_SIGNUP env variable is set to api_only' do
it 'does not respond 404 on requests' do
before do
GlobalConfig.clear_cache
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
end
after do
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
GlobalConfig.clear_cache
end
it 'returns auth headers and full response for api_only signup' do
params = { account_name: 'test', email: email, user_full_name: user_full_name, password: 'Password1!' }
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'api_only' do
post api_v1_accounts_url,
@@ -90,6 +140,21 @@ RSpec.describe 'Accounts API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response.headers.keys).to include('access-token', 'token-type', 'client', 'expiry', 'uid')
end
end
end
context 'when CW_API_ONLY_SERVER is true' do
it 'returns auth headers and full response' do
params = { account_name: 'test', email: email, user_full_name: user_full_name, password: 'Password1!' }
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true', CW_API_ONLY_SERVER: 'true' do
post api_v1_accounts_url,
params: params,
as: :json
expect(response).to have_http_status(:success)
expect(response.headers.keys).to include('access-token', 'token-type', 'client', 'expiry', 'uid')
end
end
end
@@ -126,6 +191,7 @@ RSpec.describe 'Accounts API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(response.body).to include(account.name)
expect(response.body).to include(account.locale)
expect(response.body).to include(account.domain)
@@ -161,22 +227,22 @@ RSpec.describe 'Accounts API', type: :request do
end
end
describe 'PUT /api/v1/accounts/{account.id}' do
describe 'PATCH /api/v1/accounts/{account.id}' do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:admin) { create(:user, account: account, role: :administrator) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
put "/api/v1/accounts/#{account.id}"
patch "/api/v1/accounts/#{account.id}"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an unauthorized user' do
it 'returns unauthorized' do
put "/api/v1/accounts/#{account.id}",
headers: agent.create_new_auth_token
patch "/api/v1/accounts/#{account.id}",
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unauthorized)
end
@@ -196,11 +262,20 @@ RSpec.describe 'Accounts API', type: :request do
company_size: '1-10'
}
it 'returns a valid schema' do
patch "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(response).to conform_schema(200)
end
it 'modifies an account' do
put "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
patch "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(account.reload.name).to eq(params[:name])
@@ -219,19 +294,19 @@ RSpec.describe 'Accounts API', type: :request do
it 'updates onboarding step to invite_team if onboarding step is present in account custom attributes' do
account.update(custom_attributes: { onboarding_step: 'account_update' })
put "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
patch "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team')
end
it 'will not update onboarding step if onboarding step is not present in account custom attributes' do
put "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
patch "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(account.reload.custom_attributes['onboarding_step']).to be_nil
end
@@ -239,10 +314,10 @@ RSpec.describe 'Accounts API', type: :request do
it 'Throws error 422' do
params[:name] = 'test' * 999
put "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
patch "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
@@ -106,6 +106,21 @@ RSpec.describe 'Notifications Subscriptions API', type: :request do
expect(response).to have_http_status(:success)
expect { subscription.reload }.to raise_exception(ActiveRecord::RecordNotFound)
end
it 'does not delete another user notification subscription with the same push token' do
victim = create(:user, account: account, role: :agent)
victim_subscription = create(:notification_subscription, subscription_type: 'fcm',
subscription_attributes: { push_token: 'victimToken' },
user: victim)
delete '/api/v1/notification_subscriptions',
params: { push_token: 'victimToken' },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect { victim_subscription.reload }.not_to raise_error
end
end
end
end
@@ -21,6 +21,7 @@ RSpec.describe 'Profile API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
json_response = response.parsed_body
expect(json_response['id']).to eq(agent.id)
expect(json_response['email']).to eq(agent.email)
@@ -50,6 +51,7 @@ RSpec.describe 'Profile API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
json_response = response.parsed_body
agent.reload
expect(json_response['id']).to eq(agent.id)
@@ -64,6 +66,7 @@ RSpec.describe 'Profile API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
agent.reload
expect(agent.custom_attributes['phone_number']).to eq('+123456789')
@@ -91,6 +94,7 @@ RSpec.describe 'Profile API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(agent.reload.valid_password?('Test1234!')).to be true
end
@@ -39,6 +39,11 @@ RSpec.describe 'Api::V1::Accounts::UploadController', type: :request do
let(:valid_external_url) { 'http://example.com/image.jpg' }
before do
allow(Resolv).to receive(:getaddresses).and_call_original
allow(Resolv).to receive(:getaddresses).with('example.com').and_return(['93.184.216.34'])
allow(Resolv).to receive(:getaddresses).with('error.example.com').and_return(['93.184.216.34'])
allow(Resolv).to receive(:getaddresses).with('nonexistent.example.com').and_return(['93.184.216.34'])
stub_request(:get, valid_external_url)
.to_return(status: 200, body: File.new(Rails.root.join('spec/assets/avatar.png')), headers: { 'Content-Type' => 'image/png' })
end
@@ -82,7 +87,7 @@ RSpec.describe 'Api::V1::Accounts::UploadController', type: :request do
params: { external_url: 'http://nonexistent.example.com' }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Invalid URL provided')
expect(response.parsed_body['error']).to eq('Failed to fetch file from URL')
end
it 'handles HTTP errors' do
@@ -96,6 +101,112 @@ RSpec.describe 'Api::V1::Accounts::UploadController', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to start_with('Failed to fetch file from URL')
end
it 'rejects oversized responses with a file-size message' do
stub_request(:get, valid_external_url)
.to_return(status: 200,
body: 'x' * (41 * 1024 * 1024),
headers: { 'Content-Type' => 'image/png' })
post upload_url,
headers: user.create_new_auth_token,
params: { external_url: valid_external_url }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('File exceeds the maximum allowed size')
end
it 'rejects unsupported content types with a file-type message' do
stub_request(:get, valid_external_url)
.to_return(status: 200,
body: '<html></html>',
headers: { 'Content-Type' => 'text/html' })
post upload_url,
headers: user.create_new_auth_token,
params: { external_url: valid_external_url }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('File type not supported (only images and videos are allowed)')
end
context 'with SSRF attack vectors' do
it 'blocks requests to private IP ranges (10.x.x.x)' do
post upload_url,
headers: user.create_new_auth_token,
params: { external_url: 'http://10.0.0.1/secret' }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Invalid URL provided')
end
it 'blocks requests to private IP ranges (172.16.x.x)' do
post upload_url,
headers: user.create_new_auth_token,
params: { external_url: 'http://172.16.0.1/secret' }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Invalid URL provided')
end
it 'blocks requests to private IP ranges (192.168.x.x)' do
post upload_url,
headers: user.create_new_auth_token,
params: { external_url: 'http://192.168.1.1/secret' }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Invalid URL provided')
end
it 'blocks requests to loopback addresses' do
post upload_url,
headers: user.create_new_auth_token,
params: { external_url: 'http://127.0.0.1/secret' }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Invalid URL provided')
end
it 'blocks requests to AWS metadata service (169.254.169.254)' do
post upload_url,
headers: user.create_new_auth_token,
params: { external_url: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/' }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Invalid URL provided')
end
it 'blocks requests to localhost' do
post upload_url,
headers: user.create_new_auth_token,
params: { external_url: 'http://localhost/secret' }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Invalid URL provided')
end
it 'blocks requests to .local domains' do
allow(Resolv).to receive(:getaddresses).with('server.local').and_return(['192.168.1.100'])
post upload_url,
headers: user.create_new_auth_token,
params: { external_url: 'http://server.local/secret' }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Invalid URL provided')
end
it 'blocks DNS rebinding attacks (hostname resolving to private IP)' do
allow(Resolv).to receive(:getaddresses).with('evil.attacker.com').and_return(['10.0.0.1'])
post upload_url,
headers: user.create_new_auth_token,
params: { external_url: 'http://evil.attacker.com/secret' }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Invalid URL provided')
end
end
end
it 'returns an error when no file or URL is provided' do
@@ -16,6 +16,8 @@ RSpec.describe '/api/v1/widget/integrations/dyte', type: :request do
end
before do
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
.and_return(Integrations::Cloudflare::RealtimeKitCredentialsValidator::Result.new(true, nil))
create(:integrations_hook, :dyte, account: account)
end
@@ -46,15 +48,15 @@ RSpec.describe '/api/v1/widget/integrations/dyte', type: :request do
context 'when message is an integration message' do
before do
stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants')
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants')
.to_return(
status: 200,
body: { success: true, data: { id: 'random_uuid', auth_token: 'json-web-token' } }.to_json,
body: { success: true, data: { id: 'random_uuid', token: 'json-web-token' } }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns auth_token' do
it 'returns token' do
post add_participant_to_meeting_api_v1_widget_integrations_dyte_url,
headers: { 'X-Auth-Token' => token },
params: { website_token: web_widget.website_token, message_id: integration_message.id },
@@ -64,7 +66,7 @@ RSpec.describe '/api/v1/widget/integrations/dyte', type: :request do
response_body = response.parsed_body
expect(response_body).to eq(
{
'id' => 'random_uuid', 'auth_token' => 'json-web-token'
'id' => 'random_uuid', 'token' => 'json-web-token'
}
)
end
@@ -56,6 +56,65 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
expect(json_response['content']).to eq(message_params[:content])
end
it 'creates conversation with custom_attributes when first message is sent' do
conversation.destroy!
message_params = { content: 'hello world', timestamp: Time.current }
custom_attributes = { plan: 'enterprise', source: 'website' }
post api_v1_widget_messages_url,
params: { website_token: web_widget.website_token, message: message_params, custom_attributes: custom_attributes },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
new_conversation = contact.conversations.last
expect(new_conversation.custom_attributes).to include('plan' => 'enterprise', 'source' => 'website')
end
it 'creates conversation with labels when first message is sent' do
conversation.destroy!
label = create(:label, title: 'vip', account: account)
message_params = { content: 'hello world', timestamp: Time.current }
post api_v1_widget_messages_url,
params: { website_token: web_widget.website_token, message: message_params, labels: [label.title] },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
new_conversation = contact.conversations.last
expect(new_conversation.label_list).to include('vip')
end
it 'ignores invalid labels when creating conversation with first message' do
conversation.destroy!
create(:label, title: 'valid-label', account: account)
message_params = { content: 'hello world', timestamp: Time.current }
post api_v1_widget_messages_url,
params: { website_token: web_widget.website_token, message: message_params, labels: %w[valid-label nonexistent] },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
new_conversation = contact.conversations.last
expect(new_conversation.label_list).to include('valid-label')
expect(new_conversation.label_list).not_to include('nonexistent')
end
it 'does not apply labels or custom_attributes when conversation already exists' do
create(:label, title: 'vip', account: account)
message_params = { content: 'hello world', timestamp: Time.current }
custom_attributes = { plan: 'enterprise' }
post api_v1_widget_messages_url,
params: { website_token: web_widget.website_token, message: message_params,
custom_attributes: custom_attributes, labels: ['vip'] },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
conversation.reload
expect(conversation.custom_attributes).not_to include('plan' => 'enterprise')
expect(conversation.label_list).not_to include('vip')
end
it 'does not create the message' do
conversation.destroy! # Test all params
message_params = { content: "#{'h' * 150 * 1000}a", timestamp: Time.current }
@@ -153,6 +212,26 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
end
describe 'PUT /api/v1/widget/messages' do
context 'when put request targets a message from another visitor in the same inbox' do
it 'does not update the foreign message' do
other_contact = create(:contact, account: account, email: nil)
other_contact_inbox = create(:contact_inbox, contact: other_contact, inbox: web_widget.inbox)
other_conversation = create(:conversation, contact: other_contact, account: account,
inbox: web_widget.inbox, contact_inbox: other_contact_inbox)
foreign_message = create(:message, content_type: 'input_email', account: account,
inbox: web_widget.inbox, conversation: other_conversation)
original_email = foreign_message.submitted_email
put api_v1_widget_message_url(foreign_message.id),
params: { website_token: web_widget.website_token, contact: { email: Faker::Internet.email } },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:not_found)
expect(foreign_message.reload.submitted_email).to eq(original_email)
end
end
context 'when put request is made with non existing email' do
it 'updates message in conversation and creates a new contact' do
message = create(:message, content_type: 'input_email', account: account, inbox: web_widget.inbox, conversation: conversation)
@@ -233,6 +233,107 @@ RSpec.describe 'Reports API', type: :request do
end
end
describe 'GET /api/v2/accounts/:account_id/reports/drilldown' do
let(:params) do
super().merge(
metric: 'conversations_count',
type: :account,
since: start_of_today.to_s,
until: end_of_today.to_s,
bucket_timestamp: start_of_today.to_s,
group_by: 'day'
)
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v2/accounts/#{account.id}/reports/drilldown"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
it 'returns unauthorized for agents' do
get "/api/v2/accounts/#{account.id}/reports/drilldown",
params: params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'returns drilldown records for the selected bucket' do
get "/api/v2/accounts/#{account.id}/reports/drilldown",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['meta']['metric']).to eq('conversations_count')
expect(json_response['meta']['record_type']).to eq('conversation')
expect(json_response['meta']['total_count']).to eq(10)
expect(json_response['payload'].first['conversation']).to include('display_id', 'contact_name', 'inbox_name')
end
it 'returns unprocessable entity for missing bucket timestamp' do
get "/api/v2/accounts/#{account.id}/reports/drilldown",
params: params.except(:bucket_timestamp),
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
it 'returns unprocessable entity for invalid bucket timestamp' do
get "/api/v2/accounts/#{account.id}/reports/drilldown",
params: params.merge(bucket_timestamp: 'abc'),
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
it 'returns unprocessable entity for bucket timestamp outside the requested range' do
get "/api/v2/accounts/#{account.id}/reports/drilldown",
params: params.merge(bucket_timestamp: end_of_today.to_s),
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
it 'returns drilldown records for a partial first weekly bucket' do
range_start = Time.zone.local(2026, 5, 20, 12)
range_end = Time.zone.local(2026, 5, 27, 12)
week_start = range_start.beginning_of_week(:sunday)
get "/api/v2/accounts/#{account.id}/reports/drilldown",
params: params.merge(
since: range_start.to_i.to_s,
until: range_end.to_i.to_s,
bucket_timestamp: week_start.to_i.to_s,
group_by: 'week'
),
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
end
it 'returns unprocessable entity for unsupported drilldown type' do
get "/api/v2/accounts/#{account.id}/reports/drilldown",
params: params.merge(type: :unsupported),
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe 'GET /api/v2/accounts/:account_id/reports/agents' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
@@ -45,7 +45,10 @@ RSpec.describe 'Summary Reports API', type: :request do
headers: admin.create_new_auth_token,
as: :json
expect(V2::Reports::AgentSummaryBuilder).to have_received(:new).with(account: account, params: params)
expect(V2::Reports::AgentSummaryBuilder).to have_received(:new).with(
account: account,
params: params.merge(type: :agent)
)
expect(agent_summary_builder).to have_received(:build)
expect(response).to have_http_status(:success)
@@ -96,7 +99,10 @@ RSpec.describe 'Summary Reports API', type: :request do
headers: admin.create_new_auth_token,
as: :json
expect(V2::Reports::InboxSummaryBuilder).to have_received(:new).with(account: account, params: params)
expect(V2::Reports::InboxSummaryBuilder).to have_received(:new).with(
account: account,
params: params.merge(type: :inbox)
)
expect(inbox_summary_builder).to have_received(:build)
expect(response).to have_http_status(:success)
@@ -147,7 +153,10 @@ RSpec.describe 'Summary Reports API', type: :request do
headers: admin.create_new_auth_token,
as: :json
expect(V2::Reports::TeamSummaryBuilder).to have_received(:new).with(account: account, params: params)
expect(V2::Reports::TeamSummaryBuilder).to have_received(:new).with(
account: account,
params: params.merge(type: :team)
)
expect(team_summary_builder).to have_received(:build)
expect(response).to have_http_status(:success)
@@ -94,6 +94,29 @@ RSpec.describe 'Accounts API', type: :request do
end
end
context 'when ENABLE_ACCOUNT_SIGNUP is stored as boolean false' do
before do
GlobalConfig.clear_cache
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
InstallationConfig.create!(name: 'ENABLE_ACCOUNT_SIGNUP', value: false, locked: false)
end
after do
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
GlobalConfig.clear_cache
end
it 'responds 404 on requests' do
params = { email: email, password: 'Password1!' }
post api_v2_accounts_url,
params: params,
as: :json
expect(response).to have_http_status(:not_found)
end
end
context 'when ENABLE_ACCOUNT_SIGNUP env variable is set to api_only' do
let(:account_builder) { double }
let(:account) { create(:account) }
@@ -0,0 +1,74 @@
require 'rails_helper'
RSpec.describe 'Resend Confirmations API', type: :request do
describe 'POST /resend_confirmation' do
let(:email) { 'unconfirmed@example.com' }
context 'when the user exists and is unconfirmed' do
before { create(:user, email: email, skip_confirmation: false) }
it 'sends confirmation instructions and returns 200' do
expect do
post '/resend_confirmation', params: { email: email }, as: :json
end.to have_enqueued_mail(Devise::Mailer, :confirmation_instructions)
expect(response).to have_http_status(:ok)
end
end
context 'when the user exists and is already confirmed' do
before { create(:user, email: email) }
it 'returns 200 without sending confirmation' do
expect do
post '/resend_confirmation', params: { email: email }, as: :json
end.not_to have_enqueued_mail(Devise::Mailer, :confirmation_instructions)
expect(response).to have_http_status(:ok)
end
end
context 'when the email does not exist' do
it 'returns 200 without leaking email existence' do
post '/resend_confirmation', params: { email: 'nobody@example.com' }, as: :json
expect(response).to have_http_status(:ok)
end
end
context 'when hCaptcha is configured' do
before do
create(:user, email: email, skip_confirmation: false)
allow(ChatwootCaptcha).to receive(:new).and_return(captcha)
end
context 'with a valid captcha response' do
let(:captcha) { instance_double(ChatwootCaptcha, valid?: true) }
it 'sends confirmation instructions' do
expect do
post '/resend_confirmation',
params: { email: email, h_captcha_client_response: 'valid-token' },
as: :json
end.to have_enqueued_mail(Devise::Mailer, :confirmation_instructions)
expect(response).to have_http_status(:ok)
end
end
context 'with an invalid captcha response' do
let(:captcha) { instance_double(ChatwootCaptcha, valid?: false) }
it 'returns 200 without sending confirmation' do
expect do
post '/resend_confirmation',
params: { email: email, h_captcha_client_response: 'bad-token' },
as: :json
end.not_to have_enqueued_mail(Devise::Mailer, :confirmation_instructions)
expect(response).to have_http_status(:ok)
end
end
end
end
end
@@ -0,0 +1,50 @@
require 'rails_helper'
describe 'GET / on a help center custom domain', type: :request do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
around do |example|
with_modified_env FRONTEND_URL: 'http://www.chatwoot.test' do
example.run
end
end
context 'when the portal uses the documentation layout' do
let!(:portal) do
create(:portal, account: account, slug: 'doc-portal', custom_domain: 'docs.example.com',
config: { allowed_locales: ['en'], default_locale: 'en', layout: 'documentation' })
end
let!(:category) do
create(:category, name: 'Getting Started', portal: portal, account_id: account.id, locale: 'en', slug: 'getting-started')
end
before do
create(:article, category: category, portal: portal, account: account, author: agent, locale: 'en', status: :published)
end
it 'renders the documentation home in place without redirecting' do
host! portal.custom_domain
get '/'
expect(response).to have_http_status(:success)
expect(response.body).to include('sidebar-drawer-checkbox')
expect(response.body).to include('Getting Started')
end
end
context 'when the portal uses the classic layout' do
let!(:portal) do
create(:portal, account: account, slug: 'classic-portal', custom_domain: 'classic.example.com',
config: { allowed_locales: ['en'], default_locale: 'en', layout: 'classic' })
end
it 'renders the classic home without the documentation layout' do
host! portal.custom_domain
get '/'
expect(response).to have_http_status(:success)
expect(response.body).not_to include('sidebar-drawer-checkbox')
end
end
end
@@ -106,6 +106,26 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
end
end
it 'blocks signup if config is stored as boolean false' do
GlobalConfig.clear_cache
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
InstallationConfig.create!(name: 'ENABLE_ACCOUNT_SIGNUP', value: false, locked: false)
with_modified_env FRONTEND_URL: 'http://www.example.com' do
set_omniauth_config('does-not-exist-for-sure@example.com')
allow(email_validation_service).to receive(:perform).and_return(true)
get '/omniauth/google_oauth2/callback'
expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback')
follow_redirect!
expect(response).to redirect_to(%r{/app/login\?error=no-account-found$})
end
ensure
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
GlobalConfig.clear_cache
end
it 'allows login' do
with_modified_env FRONTEND_URL: 'http://www.example.com' do
create(:user, email: 'test@example.com')
@@ -144,5 +164,21 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
expect(response).to have_http_status(:ok)
end
end
it 'resets password for an unconfirmed persisted user on OAuth login' do
with_modified_env FRONTEND_URL: 'http://www.example.com' do
user = create(:user, email: 'unconfirmed-oauth@example.com', skip_confirmation: false)
original_password_digest = user.encrypted_password
set_omniauth_config('unconfirmed-oauth@example.com')
get '/omniauth/google_oauth2/callback'
expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback')
follow_redirect!
user.reload
expect(user).to be_confirmed
expect(user.encrypted_password).not_to eq(original_password_digest)
end
end
end
end
@@ -16,6 +16,22 @@ RSpec.describe 'Session', type: :request do
end
end
context 'when the user is unconfirmed' do
let!(:user) { create(:user, password: 'Password1!', account: account, skip_confirmation: false) }
it 'returns an unconfirmed user error code' do
params = { email: user.email, password: 'Password1!' }
post new_user_session_url,
params: params,
as: :json
expect(response).to have_http_status(:unauthorized)
expect(response.parsed_body['error_code']).to eq('user_not_confirmed')
expect(response.parsed_body['errors'].first).to include(user.email)
end
end
context 'when it is valid credentials' do
let!(:user) { create(:user, password: 'Password1!', account: account) }
let!(:user_with_new_pwd) { create(:user, password: 'Password1!.><?', account: account) }
@@ -88,9 +104,11 @@ RSpec.describe 'Session', type: :request do
describe 'GET /auth/sign_in' do
it 'redirects to the frontend login page with error' do
get new_user_session_url
with_modified_env FRONTEND_URL: '' do
get new_user_session_url
expect(response).to redirect_to(%r{/app/login\?error=access-denied$})
expect(response).to redirect_to(%r{/app/login\?error=access-denied$})
end
end
end
end
@@ -163,4 +163,221 @@ RSpec.describe DeviseOverrides::SessionsController, type: :controller do
expect(response).to redirect_to('/frontend/app/login?error=access-denied')
end
end
describe 'session limit enforcement' do
before { stub_const('DeviseOverrides::SessionsController::MAX_SESSIONS', 5) }
let(:user) { create(:user, password: 'Test@123456') }
let(:browser_ua) { 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15' }
let(:mobile_ua) { 'okhttp/4.9.3' }
def seed_token(client_id, expiry_offset_days: 30, with_session: true)
user.tokens = user.tokens.merge(
client_id => { 'token' => 'x', 'expiry' => (Time.current + expiry_offset_days.days).to_i }
)
user.save!
user.user_sessions.create!(client_id: client_id, last_activity_at: Time.current) if with_session
end
def login_params
{ email: user.email, password: 'Test@123456' }
end
context 'when under the limit' do
it 'allows login without intervention' do
request.env['HTTP_USER_AGENT'] = browser_ua
3.times { |i| seed_token("c#{i}", expiry_offset_days: 30) }
post :create, params: login_params
expect(response).to have_http_status(:success)
end
it 'does not count expired tokens toward the cap' do
request.env['HTTP_USER_AGENT'] = browser_ua
# 3 expired + 2 active = 5 raw entries, but only 2 active
3.times { |i| seed_token("expired#{i}", expiry_offset_days: -1, with_session: false) }
2.times { |i| seed_token("active#{i}", expiry_offset_days: 30) }
post :create, params: login_params
expect(response).to have_http_status(:success)
end
end
context 'when at the limit from a browser with full tracking' do
before do
request.env['HTTP_USER_AGENT'] = browser_ua
5.times { |i| seed_token("c#{i}", expiry_offset_days: 30) }
end
it 'returns 409 with the session list (picker)' do
post :create, params: login_params
expect(response).to have_http_status(:conflict)
body = response.parsed_body
expect(body['sessions_limit_reached']).to be true
expect(body['sessions'].size).to eq(5)
end
it 'does not create a new session row' do
expect { post :create, params: login_params }.not_to change(user.user_sessions, :count)
end
end
context 'when at the limit from a non-browser client' do
before do
request.env['HTTP_USER_AGENT'] = mobile_ua
5.times { |i| seed_token("c#{i}", expiry_offset_days: 30 + i, with_session: false) }
end
it 'silently evicts the oldest token and lets login proceed' do
post :create, params: login_params
expect(response).to have_http_status(:success)
expect(user.reload.tokens.keys).not_to include('c0')
end
end
context 'when at the limit but tracking is partial (legacy tokens present)' do
before do
request.env['HTTP_USER_AGENT'] = browser_ua
# one tracked, four legacy (no user_session rows)
seed_token('tracked', expiry_offset_days: 60, with_session: true)
4.times { |i| seed_token("legacy#{i}", expiry_offset_days: 10 + i, with_session: false) }
end
it 'silent-evicts instead of showing a partial picker' do
post :create, params: login_params
expect(response).to have_http_status(:success)
end
it 'drops an untracked token first, keeping the tracked session alive' do
post :create, params: login_params
tokens = user.reload.tokens.keys
expect(tokens).to include('tracked')
# legacy0 expires soonest -> evict_oldest_token picks it
expect(tokens).not_to include('legacy0')
end
end
context 'when at the limit with full tracking (no legacy gap)' do
before do
request.env['HTTP_USER_AGENT'] = mobile_ua
# Five tracked sessions, varying activity timestamps
5.times do |i|
seed_token("tracked#{i}", expiry_offset_days: 30)
user.user_sessions.find_by(client_id: "tracked#{i}").update!(last_activity_at: (5 - i).days.ago)
end
end
it 'evicts the oldest tracked session by last_activity_at' do
post :create, params: login_params
expect(response).to have_http_status(:success)
# tracked0 had the oldest last_activity_at (5 days ago)
expect(user.reload.tokens.keys).not_to include('tracked0')
expect(user.user_sessions.exists?(client_id: 'tracked0')).to be false
end
end
context 'with revoke_session_id during login' do
before do
request.env['HTTP_USER_AGENT'] = browser_ua
5.times { |i| seed_token("c#{i}", expiry_offset_days: 30) }
end
it 'revokes the chosen session and proceeds with login' do
target = user.user_sessions.find_by(client_id: 'c2')
post :create, params: login_params.merge(revoke_session_id: target.id)
expect(response).to have_http_status(:success)
expect(user.reload.tokens.keys).not_to include('c2')
expect(user.user_sessions.exists?(id: target.id)).to be false
end
end
context 'with revoke_all_sessions during login' do
before do
request.env['HTTP_USER_AGENT'] = browser_ua
5.times { |i| seed_token("c#{i}", expiry_offset_days: 30) }
end
it 'wipes all sessions and tokens, then proceeds with login' do
post :create, params: login_params.merge(revoke_all_sessions: true)
expect(response).to have_http_status(:success)
expect(user.reload.tokens.keys).not_to include('c0', 'c1', 'c2', 'c3', 'c4')
# the new login adds one fresh token
expect(user.tokens.keys.size).to eq(1)
end
end
context 'with a successful login' do
before { request.env['HTTP_USER_AGENT'] = browser_ua }
it 'creates a UserSession row for the new client_id' do
expect { post :create, params: login_params }.to change(user.user_sessions, :count).by(1)
session = user.user_sessions.last
expect(session.browser_name).to eq('Safari')
expect(session.platform_name).to eq('macOS')
end
end
end
describe 'impersonation SSO login' do
let(:user) { create(:user, password: 'Test@123456') }
let(:browser_ua) { 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15' }
before { request.env['HTTP_USER_AGENT'] = browser_ua }
it 'does not create a UserSession row for impersonation login' do
sso_token = user.generate_sso_auth_token(impersonation: true)
expect do
post :create, params: { email: user.email, sso_auth_token: sso_token }
end.not_to change(user.user_sessions, :count)
expect(response).to have_http_status(:success)
end
it 'creates a short-lived token for impersonation login' do
sso_token = user.generate_sso_auth_token(impersonation: true)
post :create, params: { email: user.email, sso_auth_token: sso_token }
expect(response).to have_http_status(:success)
token_entry = user.reload.tokens.values.last
# 2-day lifespan: expiry should be within ~3 days from now (token creation + lifespan)
expect(token_entry['expiry']).to be < (3.days.from_now).to_i
end
it 'creates a normal UserSession row for regular SSO login' do
sso_token = user.generate_sso_auth_token
expect do
post :create, params: { email: user.email, sso_auth_token: sso_token }
end.to change(user.user_sessions, :count).by(1)
end
it 'preserves the impersonation token when target user is at the device cap' do
allow(DeviseTokenAuth).to receive(:max_number_of_devices).and_return(5)
5.times do |i|
user.tokens["existing#{i}"] = { 'token' => 'x', 'expiry' => (Time.current + (30 + i).days).to_i }
end
user.save!
sso_token = user.generate_sso_auth_token(impersonation: true)
post :create, params: { email: user.email, sso_auth_token: sso_token }
expect(response).to have_http_status(:success)
new_client_id = response.headers['client']
expect(user.reload.tokens.keys).to include(new_client_id)
expect(user.tokens.size).to eq(5)
end
end
end
@@ -8,12 +8,12 @@ RSpec.describe 'Google::CallbacksController', type: :request do
describe 'GET /google/callback' do
let(:response_body_success) do
{ id_token: JWT.encode({ email: email, name: 'test' }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
{ id_token: JWT.encode({ email: email, name: 'test' }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer',
refresh_token: SecureRandom.hex(10) }
end
let(:response_body_success_without_name) do
{ id_token: JWT.encode({ email: email }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
{ id_token: JWT.encode({ email: email }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer',
refresh_token: SecureRandom.hex(10) }
end
@@ -9,9 +9,11 @@ RSpec.describe Linear::CallbacksController, type: :request do
describe 'GET /linear/callback' do
let(:access_token) { SecureRandom.hex(10) }
let(:refresh_token) { SecureRandom.hex(10) }
let(:response_body) do
{
'access_token' => access_token,
'refresh_token' => refresh_token,
'token_type' => 'Bearer',
'expires_in' => 7200,
'scope' => 'read,write'
@@ -35,7 +37,7 @@ RSpec.describe Linear::CallbacksController, type: :request do
)
end
it 'creates a new integration hook' do
it 'creates a new integration hook', :aggregate_failures do
expect do
get linear_callback_path, params: { code: code, state: state }
end.to change(Integrations::Hook, :count).by(1)
@@ -44,11 +46,11 @@ RSpec.describe Linear::CallbacksController, type: :request do
expect(hook.access_token).to eq(access_token)
expect(hook.app_id).to eq('linear')
expect(hook.status).to eq('enabled')
expect(hook.settings).to eq(
'token_type' => 'Bearer',
'expires_in' => 7200,
'scope' => 'read,write'
)
expect(hook.settings['token_type']).to eq('Bearer')
expect(hook.settings['expires_in']).to eq(7200)
expect(hook.settings['scope']).to eq('read,write')
expect(hook.settings['refresh_token']).to eq(refresh_token)
expect(hook.settings['expires_on']).to be_present
expect(response).to redirect_to(linear_redirect_uri)
end
end
@@ -69,6 +71,106 @@ RSpec.describe Linear::CallbacksController, type: :request do
end
end
context 'when state is missing' do
it 'redirects to frontend root' do
get linear_callback_path, params: { code: code }
expect(response).to redirect_to('http://www.example.com')
end
end
context 'when state is invalid' do
it 'redirects to frontend root' do
get linear_callback_path, params: { code: code, state: 'invalid-state' }
expect(response).to redirect_to('http://www.example.com')
end
end
context 'when hook exists and response omits refresh_token' do
let!(:existing_hook) do
create(
:integrations_hook,
:linear,
account: account,
settings: {
'refresh_token' => 'existing_refresh_token',
'token_type' => 'Bearer',
'scope' => 'read,write',
'expires_on' => 1.day.from_now.utc.to_s
}
)
end
let(:response_body) do
{
'access_token' => access_token,
'token_type' => 'Bearer',
'expires_in' => 7200,
'scope' => 'read,write'
}
end
before do
stub_request(:post, 'https://api.linear.app/oauth/token')
.to_return(
status: 200,
body: response_body.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'preserves existing refresh token', :aggregate_failures do
get linear_callback_path, params: { code: code, state: state }
existing_hook.reload
expect(existing_hook.access_token).to eq(access_token)
expect(existing_hook.settings['refresh_token']).to eq('existing_refresh_token')
end
end
context 'when hook exists and response omits access_token' do
let!(:existing_hook) do
create(
:integrations_hook,
:linear,
account: account,
access_token: 'existing_access_token',
settings: {
'refresh_token' => 'existing_refresh_token',
'token_type' => 'Bearer',
'scope' => 'read,write',
'expires_on' => 1.day.from_now.utc.to_s
}
)
end
let(:response_body) do
{
'refresh_token' => refresh_token,
'token_type' => 'Bearer',
'expires_in' => 7200,
'scope' => 'read,write'
}
end
before do
stub_request(:post, 'https://api.linear.app/oauth/token')
.to_return(
status: 200,
body: response_body.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'does not overwrite the existing hook', :aggregate_failures do
expect do
get linear_callback_path, params: { code: code, state: state }
end.not_to change(Integrations::Hook, :count)
existing_hook.reload
expect(existing_hook.access_token).to eq('existing_access_token')
expect(existing_hook.settings['refresh_token']).to eq('existing_refresh_token')
expect(response).to redirect_to(linear_redirect_uri)
end
end
context 'when the token is invalid' do
before do
stub_request(:post, 'https://api.linear.app/oauth/token')
@@ -8,12 +8,12 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do
describe 'GET /microsoft/callback' do
let(:response_body_success) do
{ id_token: JWT.encode({ email: email, name: 'test' }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
{ id_token: JWT.encode({ email: email, name: 'test' }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer',
refresh_token: SecureRandom.hex(10) }
end
let(:response_body_success_without_name) do
{ id_token: JWT.encode({ email: email }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
{ id_token: JWT.encode({ email: email }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer',
refresh_token: SecureRandom.hex(10) }
end
@@ -34,6 +34,25 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do
expect(inbox.channel.imap_address).to eq 'outlook.office365.com'
end
it 'sets imap_login from preferred_username when the id_token carries a UPN that differs from email' do
upn = 'testaccount@primary-domain.example'
mailbox = 'TestAccount@mailbox-domain.example'
response_body = {
id_token: JWT.encode({ email: mailbox, preferred_username: upn, name: 'test' }, nil, 'none'),
access_token: SecureRandom.hex(10), token_type: 'Bearer', refresh_token: SecureRandom.hex(10)
}
stub_request(:post, 'https://login.microsoftonline.com/common/oauth2/v2.0/token')
.with(body: { 'code' => code, 'grant_type' => 'authorization_code',
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback" })
.to_return(status: 200, body: response_body.to_json, headers: { 'Content-Type' => 'application/json' })
get microsoft_callback_url, params: { code: code, state: state }
channel = account.inboxes.last.channel
expect(channel.imap_login).to eq upn
expect(channel.email).to eq mailbox
end
it 'creates updates inbox channel config if inbox exists and authentication is successful' do
inbox = create(:channel_email, account: account, email: email)&.inbox
expect(inbox.channel.provider_config).to eq({})
@@ -144,6 +144,7 @@ RSpec.describe 'Platform Accounts API', type: :request do
headers: { api_access_token: platform_app.access_token.token }, as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(response.body).to include(account.name)
end
end
@@ -37,6 +37,20 @@ RSpec.describe 'Platform Agent Bot API', type: :request do
expect(response).to have_http_status(:success)
data = response.parsed_body
expect(data.length).to eq(1)
expect(data.first['outgoing_url']).to eq(agent_bot.outgoing_url)
end
it 'returns 200 and skips orphaned permissibles when an agent bot has been deleted' do
create(:platform_app_permissible, platform_app: platform_app, permissible: agent_bot)
# Use delete (not destroy!) to bypass dependent: :destroy callbacks so the
# permissible row survives — exactly the orphan scenario described in the issue.
agent_bot.delete
get '/platform/api/v1/agent_bots',
headers: { api_access_token: platform_app.access_token.token }, as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body).to be_empty
end
end
end
@@ -73,6 +87,7 @@ RSpec.describe 'Platform Agent Bot API', type: :request do
expect(response).to have_http_status(:success)
data = response.parsed_body
expect(data['name']).to eq(agent_bot.name)
expect(data['outgoing_url']).to eq(agent_bot.outgoing_url)
end
end
end
@@ -0,0 +1,266 @@
require 'rails_helper'
RSpec.describe 'Platform Email Channel Migrations API', type: :request do
let!(:account) { create(:account) }
let(:platform_app) { create(:platform_app) }
let(:base_url) { "/platform/api/v1/accounts/#{account.id}/email_channel_migrations" }
let(:headers) { { api_access_token: platform_app.access_token.token } }
let(:google_provider_config) do
{ access_token: 'ya29.test-access-token', refresh_token: '1//test-refresh-token', expires_on: 1.hour.from_now.to_s }
end
let(:valid_migration_params) do
{
migrations: [
{
email: 'support@example.com',
provider: 'google',
provider_config: google_provider_config,
inbox_name: 'Migrated Support'
}
]
}
end
before do
create(:platform_app_permissible, platform_app: platform_app, permissible: account)
end
describe 'POST /platform/api/v1/accounts/:account_id/email_channel_migrations' do
context 'when unauthenticated' do
it 'returns unauthorized without token' do
with_modified_env EMAIL_CHANNEL_MIGRATION: 'true' do
post base_url, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
it 'returns unauthorized with invalid token' do
with_modified_env EMAIL_CHANNEL_MIGRATION: 'true' do
post base_url, params: valid_migration_params, headers: { api_access_token: 'invalid' }, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
context 'when account is not permissible' do
let(:other_account) { create(:account) }
let(:other_url) { "/platform/api/v1/accounts/#{other_account.id}/email_channel_migrations" }
it 'returns unauthorized' do
with_modified_env EMAIL_CHANNEL_MIGRATION: other_account.id.to_s do
post other_url, params: valid_migration_params, headers: headers, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
context 'when account is not in allowed list' do
it 'returns forbidden' do
with_modified_env EMAIL_CHANNEL_MIGRATION: '' do
post base_url, params: valid_migration_params, headers: headers, as: :json
expect(response).to have_http_status(:forbidden)
expect(response.parsed_body['error']).to eq('Email channel migration is not enabled')
end
end
end
context 'when authenticated with permissible account' do
around do |example|
with_modified_env EMAIL_CHANNEL_MIGRATION: 'true' do
example.run
end
end
it 'creates a google email channel and inbox' do
expect do
post base_url, params: valid_migration_params, headers: headers, as: :json
end.to change(Channel::Email, :count).by(1).and change(Inbox, :count).by(1)
expect(response).to have_http_status(:ok)
result = response.parsed_body['results'].first
expect(result['status']).to eq('success')
expect(result['email']).to eq('support@example.com')
expect(result['inbox_id']).to be_present
expect(result['channel_id']).to be_present
end
it 'sets correct google channel attributes' do
post base_url, params: valid_migration_params, headers: headers, as: :json
channel = Channel::Email.find(response.parsed_body['results'].first['channel_id'])
expect(channel.provider).to eq('google')
expect(channel.imap_enabled).to be(true)
expect(channel.imap_address).to eq('imap.gmail.com')
expect(channel.imap_port).to eq(993)
expect(channel.imap_login).to eq('support@example.com')
expect(channel.provider_config['refresh_token']).to eq('1//test-refresh-token')
end
it 'sets correct inbox attributes' do
post base_url, params: valid_migration_params, headers: headers, as: :json
inbox = Inbox.find(response.parsed_body['results'].first['inbox_id'])
expect(inbox.name).to eq('Migrated Support')
expect(inbox.account_id).to eq(account.id)
end
it 'creates a microsoft email channel with correct defaults' do
params = {
migrations: [
{
email: 'support@outlook.com',
provider: 'microsoft',
provider_config: { access_token: 'test', refresh_token: 'test', expires_on: 1.hour.from_now.to_s }
}
]
}
post base_url, params: params, headers: headers, as: :json
expect(response).to have_http_status(:ok)
result = response.parsed_body['results'].first
channel = Channel::Email.find(result['channel_id'])
expect(channel.provider).to eq('microsoft')
expect(channel.imap_address).to eq('outlook.office365.com')
end
it 'uses default inbox name when not provided' do
params = { migrations: [{ email: 'test@example.com', provider: 'google', provider_config: google_provider_config }] }
post base_url, params: params, headers: headers, as: :json
inbox = Inbox.find(response.parsed_body['results'].first['inbox_id'])
expect(inbox.name).to eq('Migrated Google: test@example.com')
end
it 'defaults imap_login to email address' do
post base_url, params: valid_migration_params, headers: headers, as: :json
channel = Channel::Email.find(response.parsed_body['results'].first['channel_id'])
expect(channel.imap_login).to eq('support@example.com')
end
it 'allows overriding imap settings' do
params = {
migrations: [
{
email: 'custom@example.com',
provider: 'google',
provider_config: google_provider_config,
imap_address: 'custom.imap.server.com',
imap_port: 143,
imap_login: 'custom-login@example.com',
imap_enable_ssl: false
}
]
}
post base_url, params: params, headers: headers, as: :json
channel = Channel::Email.find(response.parsed_body['results'].first['channel_id'])
expect(channel.imap_address).to eq('custom.imap.server.com')
expect(channel.imap_port).to eq(143)
expect(channel.imap_login).to eq('custom-login@example.com')
expect(channel.imap_enable_ssl).to be(false)
end
end
context 'when migrating multiple channels' do
around do |example|
with_modified_env EMAIL_CHANNEL_MIGRATION: 'true' do
example.run
end
end
let(:bulk_params) do
{
migrations: [
{ email: 'first@example.com', provider: 'google', provider_config: google_provider_config },
{ email: 'second@example.com', provider: 'google', provider_config: google_provider_config },
{ email: 'third@example.com', provider: 'microsoft',
provider_config: { access_token: 'test', refresh_token: 'test', expires_on: 1.hour.from_now.to_s } }
]
}
end
it 'creates all channels and inboxes' do
expect do
post base_url, params: bulk_params, headers: headers, as: :json
end.to change(Channel::Email, :count).by(3).and change(Inbox, :count).by(3)
results = response.parsed_body['results']
expect(results.map { |r| r['status'] }).to all(eq('success'))
expect(results.map { |r| r['email'] }).to match_array(%w[first@example.com second@example.com third@example.com])
end
it 'continues processing when one migration fails' do
create(:channel_email, email: 'first@example.com', account: account)
expect do
post base_url, params: bulk_params, headers: headers, as: :json
end.to change(Channel::Email, :count).by(2).and change(Inbox, :count).by(2)
results = response.parsed_body['results']
failed = results.find { |r| r['email'] == 'first@example.com' }
succeeded = results.reject { |r| r['email'] == 'first@example.com' }
expect(failed['status']).to eq('error')
expect(failed['message']).to include('Email has already been taken')
expect(succeeded.map { |r| r['status'] }).to all(eq('success'))
end
end
context 'when params are invalid' do
around do |example|
with_modified_env EMAIL_CHANNEL_MIGRATION: 'true' do
example.run
end
end
it 'returns unprocessable entity when migrations param is missing' do
post base_url, params: {}, headers: headers, as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
it 'returns unprocessable entity when migrations exceed max batch size' do
params = {
migrations: Array.new(26) { |i| { email: "user#{i}@example.com", provider: 'google', provider_config: google_provider_config } }
}
post base_url, params: params, headers: headers, as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to include('Too many migrations')
end
it 'returns error for unsupported provider' do
params = {
migrations: [{ email: 'test@example.com', provider: 'Yahoo', provider_config: google_provider_config }]
}
post base_url, params: params, headers: headers, as: :json
result = response.parsed_body['results'].first
expect(result['status']).to eq('error')
expect(result['message']).to include("Unsupported provider 'Yahoo'")
end
it 'returns error for duplicate email' do
create(:channel_email, email: 'existing@example.com', account: account)
params = {
migrations: [{ email: 'existing@example.com', provider: 'google', provider_config: google_provider_config }]
}
post base_url, params: params, headers: headers, as: :json
result = response.parsed_body['results'].first
expect(result['status']).to eq('error')
expect(result['message']).to include('Email has already been taken')
end
end
end
end
@@ -16,6 +16,19 @@ RSpec.describe 'Public Inbox Contact Conversation Messages API', type: :request
data = response.parsed_body
expect(data.length).to eq 2
end
it 'does not return messages from a conversation in another inbox even when both share the same contact' do
other_channel = create(:channel_api, account: conversation.account)
other_contact_inbox = create(:contact_inbox, contact: contact, inbox: other_channel.inbox)
foreign_conversation = create(:conversation, contact: contact, account: conversation.account,
inbox: other_channel.inbox, contact_inbox: other_contact_inbox)
create(:message, account: foreign_conversation.account, inbox: foreign_conversation.inbox, conversation: foreign_conversation)
get "/public/api/v1/inboxes/#{api_channel.identifier}/contacts/#{contact_inbox.source_id}/conversations/" \
"#{foreign_conversation.display_id}/messages"
expect(response).to have_http_status(:not_found)
end
end
describe 'POST /public/api/v1/inboxes/{identifier}/contact/{source_id}/conversations/{conversation_id}/messages' do
@@ -34,18 +34,53 @@ RSpec.describe 'Public Articles API', type: :request do
end
it 'get all articles with searched text query' do
article2 = create(:article,
account_id: account.id,
portal: portal,
category: category,
author_id: agent.id,
content: 'this is some test and funny content')
expect(article2.id).not_to be_nil
long_content = ([('intro ' * 30).strip, 'funny', ('tail ' * 30).strip].join(' ')).strip
create(:article,
account_id: account.id,
portal: portal,
category: category,
author_id: agent.id,
content: long_content)
get "/hc/#{portal.slug}/#{category.locale}/categories/#{category.slug}/articles.json", params: { query: 'funny' }
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)[:payload]
expect(response_data.length).to eq(1)
expect(response_data[0].keys).to match_array(%i[id category_id title content link])
expect(response_data[0][:content]).to include('funny')
expect(response_data[0][:content].length).to be < long_content.length
end
it 'limits search results to the current locale' do
create(:article,
account_id: account.id,
portal: portal,
category: category,
author_id: agent.id,
title: 'English locale result',
content: 'shared-search-term in english')
create(:article,
account_id: account.id,
portal: portal,
category: category_2,
author_id: agent.id,
title: 'Spanish locale result',
content: 'shared-search-term in spanish')
get "/hc/#{portal.slug}/#{category.locale}/articles.json", params: { query: 'shared-search-term' }
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)[:payload]
expect(response_data.pluck(:title)).to eq(['English locale result'])
end
it 'treats whitespace-only queries as empty searches' do
get "/hc/#{portal.slug}/#{category.locale}/articles.json", params: { query: ' ' }
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)[:payload]
expect(response_data.length).to eq(3)
expect(response_data.first).to include(:description, :slug, :portal)
end
it 'get all popular articles if sort params is passed' do
@@ -105,6 +140,38 @@ RSpec.describe 'Public Articles API', type: :request do
get "/hc/#{portal.slug}/articles/#{article_in_locale.slug}"
expect(response).to have_http_status(:success)
end
it 'resolves the locale from the article itself for an uncategorized article' do
uncategorized_article = create(:article, category: nil, locale: 'es', portal: portal,
account_id: account.id, author_id: agent.id)
get "/hc/#{portal.slug}/articles/#{uncategorized_article.slug}"
expect(response).to have_http_status(:success)
expect(response.body).to include('lang="es"')
end
end
describe 'GET /public/api/v1/portals/:slug/articles/:slug.md (markdown)' do
it 'serves the raw article markdown for a published article' do
get "/hc/#{portal.slug}/articles/#{article.slug}.md"
expect(response).to have_http_status(:success)
expect(response.headers['Content-Type']).to include('text/markdown')
expect(response.body).to eq(article.content)
end
it 'returns 404 for a draft article' do
draft_article = create(:article, category: category, status: :draft, portal: portal, account_id: account.id, author_id: agent.id)
get "/hc/#{portal.slug}/articles/#{draft_article.slug}.md"
expect(response).to have_http_status(:not_found)
end
it 'returns 404 if the article does not exist' do
get "/hc/#{portal.slug}/articles/non-existent-article.md"
expect(response).to have_http_status(:not_found)
end
end
describe 'GET /public/api/v1/portals/:slug/articles/:slug.png (tracking pixel)' do
@@ -144,4 +211,30 @@ RSpec.describe 'Public Articles API', type: :request do
expect(response.headers['Content-Type']).to eq('image/png')
end
end
describe 'documentation layout sidebar for a region-variant locale' do
let!(:th_portal) do
create(:portal, slug: 'th-portal', custom_domain: 'th.example.com',
config: { allowed_locales: ['th_TH'], default_locale: 'th_TH', layout: 'documentation' })
end
let!(:th_category) do
create(:category, name: 'TH Category', portal: th_portal, account_id: account.id, locale: 'th_TH', slug: 'th-cat')
end
let!(:th_article) do
create(:article, category: th_category, portal: th_portal, account_id: account.id, author_id: agent.id, locale: 'th_TH')
end
before do
create(:article, category: th_category, portal: th_portal, account_id: account.id, author_id: agent.id,
locale: 'th_TH', title: 'Sibling In Sidebar', status: :published)
end
it 'lists the category and sibling articles using the full portal locale' do
host! 'th.example.com'
get "/hc/#{th_portal.slug}/articles/#{th_article.slug}"
expect(response).to have_http_status(:success)
expect(response.body).to include('Sibling In Sidebar')
end
end
end
@@ -11,12 +11,13 @@ RSpec.describe 'Public Categories API', type: :request do
end
describe 'GET /public/api/v1/portals/:portal_slug/categories' do
it 'Fetch all categories in the portal' do
it 'redirects to the locale home page' do
category = portal.categories.first
get "/hc/#{portal.slug}/#{category.locale}/categories"
expect(response).to have_http_status(:success)
expect(response).to have_http_status(:moved_permanently)
expect(response).to redirect_to("/hc/#{portal.slug}/#{category.locale}")
end
end
@@ -13,6 +13,12 @@ RSpec.describe Public::Api::V1::PortalsController, type: :request do
end
describe 'GET /public/api/v1/portals/{portal_slug}' do
it 'redirects to the portal default locale when locale is not present' do
get "/hc/#{portal.slug}"
expect(response).to redirect_to("/hc/#{portal.slug}/#{portal.default_locale}")
end
it 'Show portal and categories belonging to the portal' do
get "/hc/#{portal.slug}/en"
@@ -56,6 +62,48 @@ RSpec.describe Public::Api::V1::PortalsController, type: :request do
expect(response.body).not_to include('<link rel="icon" href=')
end
end
it 'hides drafted locales from the public locale switcher' do
portal.update!(config: { allowed_locales: %w[en es], draft_locales: ['es'], default_locale: 'en' })
get "/hc/#{portal.slug}/en"
expect(response).to have_http_status(:success)
expect(response.body).not_to include('value="es"')
expect(response.body).not_to include('locale-switcher')
end
it 'allows direct access to drafted locale pages' do
portal.update!(config: { allowed_locales: %w[en es], draft_locales: ['es'], default_locale: 'en' })
get "/hc/#{portal.slug}/es"
expect(response).to have_http_status(:success)
end
it 'shows the active drafted locale in the switcher state on direct locale access' do
portal.update!(config: { allowed_locales: %w[en es fr], draft_locales: ['es'], default_locale: 'en' })
get "/hc/#{portal.slug}/es"
expect(response).to have_http_status(:success)
document = Nokogiri::HTML(response.body)
switchers = document.css('select.locale-switcher')
expect(switchers).not_to be_empty
switchers.each do |switcher|
options = switcher.css('option')
expect(options.map { |option| option['value'] }).to include('en', 'es', 'fr')
expect(
options.any? do |option|
option['value'] == 'es' && option['selected'].present? && option['disabled'].present?
end
).to be(true)
end
end
end
describe 'GET /public/api/v1/portals/{portal_slug}/sitemap' do
@@ -25,6 +25,98 @@ RSpec.describe 'Super Admin accounts API', type: :request do
end
end
describe 'GET /super_admin/accounts/{account_id}' do
context 'when it is an authenticated user' do
it 'shows effective Captain model routing', if: ChatwootApp.enterprise? do
account.update!(captain_models: { 'editor' => 'gpt-4.1' })
sign_in(super_admin, scope: :super_admin)
get "/super_admin/accounts/#{account.id}"
document = Nokogiri::HTML(response.body)
summaries = document.css('details summary').map { |summary| summary.text.squish }
expect(response).to have_http_status(:success)
expect(document.at_css('#captain_models').text.squish).to eq('Captain models')
expect(summaries).to include('View model routing')
expect(summaries).not_to include('All features')
expect(summaries).not_to include('Captain models')
expect(response.body).to include('Editor', 'OpenAI', 'openai', 'gpt-4.1', 'Account override', 'Label suggestion', 'Default')
end
end
end
describe 'GET /super_admin/accounts/{account_id}/edit' do
context 'when it is an authenticated user' do
it 'renders a Captain model selector for every AI feature', if: ChatwootApp.enterprise? do
account.update!(captain_models: { 'editor' => 'gpt-4.1' })
sign_in(super_admin, scope: :super_admin)
get "/super_admin/accounts/#{account.id}/edit"
expect(response).to have_http_status(:success)
Llm::Models.feature_keys.each do |feature_key|
expect(response.body).to include("account[captain_models][#{feature_key}]")
end
document = Nokogiri::HTML(response.body)
editor_select = document.at_css('select[name="account[captain_models][editor]"]')
default_model_id = Llm::Models.default_model_for('editor')
default_model = Llm::Models.model_config(default_model_id)['display_name']
expect(editor_select.at_css('option[value=""]').text.squish).to eq("Use default: #{default_model} (#{default_model_id})")
end
end
end
describe 'PATCH /super_admin/accounts/{account_id}' do
context 'when it is an authenticated user' do
it 'updates Captain model overrides without changing unrelated settings' do
account.update!(
captain_models: { 'editor' => 'gpt-4.1' },
keep_pending_on_bot_failure: true
)
sign_in(super_admin, scope: :super_admin)
patch "/super_admin/accounts/#{account.id}",
params: {
account: {
name: account.name,
locale: account.locale,
status: account.status,
captain_models: {
editor: '',
assistant: 'gpt-5.2'
}
}
}
expect(response).to have_http_status(:redirect)
expect(account.reload.captain_models).to eq('assistant' => 'gpt-5.2')
expect(account.keep_pending_on_bot_failure).to be true
end
it 'rejects invalid Captain model overrides' do
sign_in(super_admin, scope: :super_admin)
patch "/super_admin/accounts/#{account.id}",
params: {
account: {
name: account.name,
locale: account.locale,
status: account.status,
captain_models: {
label_suggestion: 'gpt-5.1'
}
}
}
expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).to include('not a valid model for label_suggestion')
expect(account.reload.captain_models).to be_nil
end
end
end
describe 'POST /super_admin/accounts/{account_id}/reset_cache' do
before do
create(:label, account: account)
@@ -32,6 +124,10 @@ RSpec.describe 'Super Admin accounts API', type: :request do
create(:team, account: account)
end
after do
Conversations::UnreadCounts::Store.clear_account!(account.id)
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/super_admin/accounts/#{account.id}/reset_cache"
@@ -52,6 +148,21 @@ RSpec.describe 'Super Admin accounts API', type: :request do
range = now_timestamp..(now_timestamp + 10)
expect(account.reload.cache_keys.values.all? { |v| range.cover?(v.to_i) }).to be(true)
end
it 'clears conversation unread count cache' do
inbox = account.inboxes.first
store = Conversations::UnreadCounts::Store
inbox_key = store.inbox_key(account.id, inbox.id)
store.mark_base_ready!(account.id)
store.add_base_membership(account_id: account.id, inbox_id: inbox.id, label_ids: [], conversation_id: 1)
sign_in(super_admin, scope: :super_admin)
post "/super_admin/accounts/#{account.id}/reset_cache"
expect(response).to have_http_status(:redirect)
expect(store.base_ready?(account.id)).to be(false)
expect(store.counts_for_keys([inbox_key])).to eq(inbox_key => 0)
end
end
end
@@ -71,10 +71,24 @@ RSpec.describe 'Super Admin Application Config API', type: :request do
expect(response).to have_http_status(:found)
expect(response).to redirect_to(super_admin_settings_path)
expect(flash[:notice]).to be_present
expect(flash[:alert]).to be_blank
expect(flash[:success]).to be_blank
config = GlobalConfig.get('FB_APP_ID')
expect(config['FB_APP_ID']).to eq('FB_APP_ID')
end
it 'asks admins to restart web and worker processes for runtime config changes' do
sign_in(super_admin, scope: :super_admin)
post '/super_admin/app_config?config=captain', params: { app_config: { CAPTAIN_OPEN_AI_ENDPOINT: 'https://api.openai.com' } }
expect(response).to have_http_status(:found)
expect(response).to redirect_to(super_admin_settings_path)
expect(flash[:success]).to be_present
expect(flash[:alert]).to be_blank
expect(flash[:notice]).to be_blank
end
end
end
end
@@ -39,4 +39,34 @@ RSpec.describe 'Super Admin Installation Config API', type: :request do
end
end
end
describe 'PATCH /super_admin/installation_configs/:id' do
context 'when it is an authenticated super admin' do
it 'shows a regular success notice for config that does not require restart' do
sign_in(super_admin, scope: :super_admin)
config = create(:installation_config, name: 'TESTCONFIG', value: 'TESTVALUE', locked: false)
patch "/super_admin/installation_configs/#{config.id}", params: {
installation_config: { name: config.name, value: 'UPDATEDVALUE' }
}
expect(response).to have_http_status(:found)
expect(flash[:notice]).to be_present
expect(flash[:success]).to be_blank
end
it 'shows a restart success notice for runtime config changes' do
sign_in(super_admin, scope: :super_admin)
config = create(:installation_config, name: 'OTEL_PROVIDER', value: 'langfuse', locked: false)
patch "/super_admin/installation_configs/#{config.id}", params: {
installation_config: { name: config.name, value: 'langfuse' }
}
expect(response).to have_http_status(:found)
expect(flash[:success]).to be_present
expect(flash[:notice]).to be_blank
end
end
end
end
@@ -12,7 +12,7 @@ RSpec.describe 'Super Admin Users API', type: :request do
end
context 'when it is an authenticated super admin' do
let!(:user) { create(:user) }
let!(:user) { create(:user, name: 'Disabled User') }
let!(:params) do
{ user: {
name: 'admin@example.com',
@@ -23,13 +23,46 @@ RSpec.describe 'Super Admin Users API', type: :request do
type: 'SuperAdmin'
} }
end
let!(:params_without_confirmed_at) do
{ user: {
name: 'agent@example.com',
display_name: 'agent@example.com',
email: 'agent@example.com',
password: 'Password1!',
type: 'SuperAdmin'
} }
end
let!(:params_with_blank_confirmed_at) do
{ user: {
name: 'agent-2@example.com',
display_name: 'agent-2@example.com',
email: 'agent-2@example.com',
password: 'Password1!',
confirmed_at: '',
type: 'SuperAdmin'
} }
end
it 'shows the list of users' do
sign_in(super_admin, scope: :super_admin)
get '/super_admin/users'
doc = Nokogiri::HTML(response.body)
header_texts = doc.css('table thead th').map { |header| header.text.squish }
expect(response).to have_http_status(:success)
expect(response.body).to include('New user')
expect(response.body).to include(CGI.escapeHTML(user.name))
expect(header_texts).not_to include('MFA')
end
it 'prefills confirmed_at on new user form' do
sign_in(super_admin, scope: :super_admin)
get '/super_admin/users/new'
expect(response).to have_http_status(:success)
expect(response.body).to include('name="user[confirmed_at]"')
confirmed_at_value = response.body[/name="user\[confirmed_at\]".*?value="([^"]+)"/m, 1]
expect(confirmed_at_value).to be_present
end
it 'creates the new super_admin record' do
@@ -43,6 +76,24 @@ RSpec.describe 'Super Admin Users API', type: :request do
post '/super_admin/users', params: params
expect(response).to redirect_to('http://www.example.com/super_admin/users/new')
end
it 'creates unconfirmed users when confirmed_at is not provided in payload' do
sign_in(super_admin, scope: :super_admin)
post '/super_admin/users', params: params_without_confirmed_at
expect(response).to redirect_to("http://www.example.com/super_admin/users/#{User.last.id}")
expect(User.last).not_to be_confirmed
end
it 'creates unconfirmed users when confirmed_at is explicitly cleared' do
sign_in(super_admin, scope: :super_admin)
post '/super_admin/users', params: params_with_blank_confirmed_at
expect(response).to redirect_to("http://www.example.com/super_admin/users/#{User.last.id}")
expect(User.last).not_to be_confirmed
end
end
end
@@ -100,4 +151,21 @@ RSpec.describe 'Super Admin Users API', type: :request do
expect(mail_jobs.count).to be >= 1
end
end
describe 'GET /super_admin/users/:id' do
let!(:user) { create(:user, name: 'MFA Enabled User', otp_required_for_login: true) }
it 'shows the MFA status on the user detail page' do
sign_in(super_admin, scope: :super_admin)
get "/super_admin/users/#{user.id}"
doc = Nokogiri::HTML(response.body)
labels = doc.css('dt.attribute-label').map { |label| label.text.squish }
expect(response).to have_http_status(:success)
expect(labels).to include('MFA')
expect(response.body).to include('Enabled')
expect(response.body).to include(CGI.escapeHTML(user.name))
end
end
end
@@ -8,5 +8,10 @@ describe '/swagger', type: :request do
expect(response.body).to include('redoc')
expect(response.body).to include('/swagger.json')
end
it 'does not render files outside the swagger directory' do
get '/swagger/%2Fetc%2Fpasswd'
expect(response).to have_http_status(:not_found)
end
end
end
@@ -10,7 +10,18 @@ RSpec.describe 'Twilio::CallbacksController', type: :request do
'To' => '+0987654321',
'Body' => 'Test message',
'AccountSid' => 'AC123',
'SmsSid' => 'SM123'
'SmsSid' => 'SM123',
'ExternalUserId' => 'IN.2081978709342942',
'ParentExternalUserId' => 'IN.ENT.9081726354',
'ProfileUsername' => 'muhsin',
'ReferralCtwaClid' => 'AfjyUDlaIoiweZDnlzmDTEaG',
'ReferralSourceId' => '120237244350960485',
'ReferralSourceUrl' => 'https://fb.me/4tBfhWhjr',
'ReferralSourceType' => 'ad',
'ReferralHeadline' => 'German citizenship lawyer',
'ReferralBody' => 'Fast-track your German citizenship',
'ReferralMediaId' => '',
'ReferralNumMedia' => '0'
}
end
@@ -1,6 +1,25 @@
require 'rails_helper'
RSpec.describe 'Webhooks::InstagramController', type: :request do
let(:client_secret) { 'test-instagram-secret' }
def signature_for(body, secret = client_secret)
"sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}"
end
def post_instagram_webhook(body, signature: signature_for(body), env: { INSTAGRAM_APP_SECRET: client_secret })
with_modified_env env do
post '/webhooks/instagram',
params: body,
headers: { 'CONTENT_TYPE' => 'application/json', 'X-Hub-Signature-256' => signature }
end
end
before do
InstallationConfig.where(name: %w[FB_APP_SECRET IG_VERIFY_TOKEN INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN]).delete_all
GlobalConfig.clear_cache
end
describe 'GET /webhooks/verify' do
it 'returns 401 when valid params are not present' do
get '/webhooks/instagram/verify'
@@ -24,26 +43,62 @@ RSpec.describe 'Webhooks::InstagramController', type: :request do
describe 'POST /webhooks/instagram' do
let!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access }
let(:body) { dm_params.merge(object: 'instagram').to_json }
it 'call the instagram events job with the params' do
it 'calls the instagram events job with the params for a valid signature' do
allow(Webhooks::InstagramEventsJob).to receive(:perform_later)
expect(Webhooks::InstagramEventsJob).to receive(:perform_later)
instagram_params = dm_params.merge(object: 'instagram')
post '/webhooks/instagram', params: instagram_params
post_instagram_webhook(body)
expect(response).to have_http_status(:success)
end
it 'accepts webhook payloads signed with the Facebook app secret' do
allow(Webhooks::InstagramEventsJob).to receive(:perform_later)
expect(Webhooks::InstagramEventsJob).to receive(:perform_later)
facebook_secret = 'test-facebook-secret'
post_instagram_webhook(
body,
signature: signature_for(body, facebook_secret),
env: { FB_APP_SECRET: facebook_secret }
)
expect(response).to have_http_status(:success)
end
it 'returns unauthorized when signature is missing' do
allow(Webhooks::InstagramEventsJob).to receive(:perform_later)
with_modified_env INSTAGRAM_APP_SECRET: client_secret do
post '/webhooks/instagram',
params: body,
headers: { 'CONTENT_TYPE' => 'application/json' }
end
expect(response).to have_http_status(:unauthorized)
expect(Webhooks::InstagramEventsJob).not_to have_received(:perform_later)
end
it 'returns unauthorized when signature is invalid' do
allow(Webhooks::InstagramEventsJob).to receive(:perform_later)
post_instagram_webhook(body, signature: 'sha256=invalid-signature')
expect(response).to have_http_status(:unauthorized)
expect(Webhooks::InstagramEventsJob).not_to have_received(:perform_later)
end
context 'when processing echo events' do
let!(:echo_params) { build(:instagram_story_mention_event_with_echo).with_indifferent_access }
let(:echo_body) { echo_params.merge(object: 'instagram').to_json }
it 'delays processing for echo events by 2 seconds' do
job_double = class_double(Webhooks::InstagramEventsJob)
allow(Webhooks::InstagramEventsJob).to receive(:set).with(wait: 2.seconds).and_return(job_double)
allow(job_double).to receive(:perform_later)
instagram_params = echo_params.merge(object: 'instagram')
post '/webhooks/instagram', params: instagram_params
post_instagram_webhook(echo_body)
expect(response).to have_http_status(:success)
expect(Webhooks::InstagramEventsJob).to have_received(:set).with(wait: 2.seconds)
expect(job_double).to have_received(:perform_later)
@@ -2,6 +2,33 @@ require 'rails_helper'
RSpec.describe 'Webhooks::WhatsappController', type: :request do
let(:channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) }
let(:client_secret) { 'test-whatsapp-secret' }
let(:body) { { content: 'hello' }.to_json }
def signature_for(body, secret = client_secret)
"sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}"
end
def post_whatsapp_webhook(path, body, signature: signature_for(body), env: { WHATSAPP_APP_SECRET: client_secret })
with_modified_env env do
post path,
params: body,
headers: { 'CONTENT_TYPE' => 'application/json', 'X-Hub-Signature-256' => signature }
end
end
def post_unsigned_whatsapp_webhook(path, body, env: { WHATSAPP_APP_SECRET: client_secret })
with_modified_env env do
post path,
params: body,
headers: { 'CONTENT_TYPE' => 'application/json' }
end
end
before do
InstallationConfig.where(name: 'WHATSAPP_APP_SECRET').delete_all
GlobalConfig.clear_cache
end
describe 'GET /webhooks/verify' do
it 'returns 401 when valid params are not present' do
@@ -23,13 +50,103 @@ RSpec.describe 'Webhooks::WhatsappController', type: :request do
end
describe 'POST /webhooks/whatsapp/{:phone_number}' do
it 'call the whatsapp events job with the params' do
it 'calls the whatsapp events job with the params for a valid signature' do
allow(Webhooks::WhatsappEventsJob).to receive(:perform_later)
expect(Webhooks::WhatsappEventsJob).to receive(:perform_later)
post '/webhooks/whatsapp/123221321', params: { content: 'hello' }
post_whatsapp_webhook('/webhooks/whatsapp/123221321', body)
expect(response).to have_http_status(:success)
end
it 'accepts webhook payloads signed with the channel app secret' do
channel_secret = 'channel-whatsapp-secret'
channel.provider_config = channel.provider_config.merge('app_secret' => channel_secret)
channel.save!
allow(Webhooks::WhatsappEventsJob).to receive(:perform_later)
expect(Webhooks::WhatsappEventsJob).to receive(:perform_later)
channel_body = {
object: 'whatsapp_business_account',
entry: [{
changes: [{
value: {
metadata: {
display_phone_number: channel.phone_number.delete_prefix('+'),
phone_number_id: channel.provider_config['phone_number_id']
}
}
}]
}]
}.to_json
post_whatsapp_webhook(
"/webhooks/whatsapp/#{channel.phone_number}",
channel_body,
signature: signature_for(channel_body, channel_secret),
env: {}
)
expect(response).to have_http_status(:success)
end
it 'skips signature validation for 360dialog channels' do
dialog_channel = create(:channel_whatsapp, provider: 'default', sync_templates: false, validate_provider_config: false)
allow(Webhooks::WhatsappEventsJob).to receive(:perform_later)
expect(Webhooks::WhatsappEventsJob).to receive(:perform_later)
post_unsigned_whatsapp_webhook("/webhooks/whatsapp/#{dialog_channel.phone_number}", body)
expect(response).to have_http_status(:success)
end
it 'skips signature validation for manual whatsapp cloud channels without an app secret' do
channel.update!(
provider_config: channel.provider_config.except('app_secret', 'app_secret_key', 'api_secret', 'client_secret', 'source')
)
allow(Webhooks::WhatsappEventsJob).to receive(:perform_later)
expect(Webhooks::WhatsappEventsJob).to receive(:perform_later)
channel_body = {
object: 'whatsapp_business_account',
entry: [{
changes: [{
value: {
metadata: {
display_phone_number: channel.phone_number.delete_prefix('+'),
phone_number_id: channel.provider_config['phone_number_id']
}
}
}]
}]
}.to_json
post_unsigned_whatsapp_webhook("/webhooks/whatsapp/#{channel.phone_number}", channel_body)
expect(response).to have_http_status(:success)
end
it 'returns unauthorized when signature is missing' do
allow(Webhooks::WhatsappEventsJob).to receive(:perform_later)
with_modified_env WHATSAPP_APP_SECRET: client_secret do
post '/webhooks/whatsapp/123221321',
params: body,
headers: { 'CONTENT_TYPE' => 'application/json' }
end
expect(response).to have_http_status(:unauthorized)
expect(Webhooks::WhatsappEventsJob).not_to have_received(:perform_later)
end
it 'returns unauthorized when signature is invalid' do
allow(Webhooks::WhatsappEventsJob).to receive(:perform_later)
post_whatsapp_webhook('/webhooks/whatsapp/123221321', body, signature: 'sha256=invalid-signature')
expect(response).to have_http_status(:unauthorized)
expect(Webhooks::WhatsappEventsJob).not_to have_received(:perform_later)
end
context 'when phone number is in inactive list' do
before do
allow(GlobalConfig).to receive(:get_value).with('INACTIVE_WHATSAPP_NUMBERS').and_return('+1234567890,+9876543210')
@@ -39,7 +156,7 @@ RSpec.describe 'Webhooks::WhatsappController', type: :request do
allow(Rails.logger).to receive(:warn)
expect(Rails.logger).to receive(:warn).with('Rejected webhook for inactive WhatsApp number: +1234567890')
post '/webhooks/whatsapp/+1234567890', params: { content: 'hello' }
post_whatsapp_webhook('/webhooks/whatsapp/+1234567890', body)
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Inactive WhatsApp number')
end
@@ -54,7 +171,7 @@ RSpec.describe 'Webhooks::WhatsappController', type: :request do
allow(Webhooks::WhatsappEventsJob).to receive(:perform_later)
expect(Webhooks::WhatsappEventsJob).to receive(:perform_later)
post '/webhooks/whatsapp/+1234567890', params: { content: 'hello' }
post_whatsapp_webhook('/webhooks/whatsapp/+1234567890', body)
expect(response).to have_http_status(:success)
end
end