Merge branch 'develop' into codex/fix-inbox-finish-setup-guidance

This commit is contained in:
Muhsin Keloth
2026-06-04 10:48:39 +04:00
committed by GitHub
2024 changed files with 111349 additions and 11499 deletions
@@ -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
@@ -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)
@@ -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
@@ -101,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
@@ -112,7 +112,7 @@ 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",
get "/api/v1/accounts/#{account.id}/contacts?include_contact_inboxes=false&sort=-company_name",
headers: admin.create_new_auth_token,
as: :json
@@ -101,6 +101,76 @@ 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(
'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
@@ -777,6 +847,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)
@@ -847,6 +934,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
@@ -1039,6 +1142,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
@@ -568,8 +568,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,
@@ -578,7 +580,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
@@ -587,6 +590,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
@@ -1004,6 +1008,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
@@ -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
@@ -239,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,
@@ -551,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,114 @@
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' },
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' },
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
it 'clears onboarding_step' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com' },
headers: admin.create_new_auth_token, as: :json
expect(account.reload.custom_attributes).not_to have_key('onboarding_step')
end
it 'invokes HelpCenterCreationService when website is present', skip: 'help center generation wiring disabled until UI is ready' do
service = instance_double(Onboarding::HelpCenterCreationService, perform: nil)
allow(Onboarding::HelpCenterCreationService).to receive(:new).and_return(service)
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com' },
headers: admin.create_new_auth_token, as: :json
expect(Onboarding::HelpCenterCreationService).to have_received(:new) do |arg_account, arg_user|
expect(arg_account.id).to eq(account.id)
expect(arg_user.id).to eq(admin.id)
end
expect(service).to have_received(:perform)
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' },
headers: admin.create_new_auth_token, as: :json
end.not_to change(account.portals, :count)
end
end
context 'when onboarding_step is not account_details' do
before { account.update!(custom_attributes: { 'onboarding_step' => 'invite_team' }) }
it 'does not clear onboarding_step' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com' },
headers: admin.create_new_auth_token, as: :json
expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team')
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
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
@@ -135,7 +170,10 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
'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' => {}
}
)
end
@@ -180,6 +218,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)
@@ -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
@@ -212,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)