Merge remote-tracking branch 'origin/develop' into codex/super-admin-email-templates-merge
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user