Merge branch 'feature/cw-7495-llm' into feature/cw-7495-api
This commit is contained in:
@@ -23,6 +23,52 @@ RSpec.describe 'API Base', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API and webhook access is disabled for the account' do
|
||||
let!(:admin) { create(:user, :administrator, account: account) }
|
||||
let!(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
before do
|
||||
allow(Account).to receive(:find).and_call_original
|
||||
allow(Account).to receive(:find).with(account.id.to_s).and_return(account)
|
||||
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
|
||||
end
|
||||
|
||||
it 'returns forbidden for token authenticated requests' do
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
|
||||
headers: { api_access_token: admin.access_token.token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(response.parsed_body['error']).to eq('API access is not enabled for this account')
|
||||
end
|
||||
|
||||
it 'allows session authenticated requests' do
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a self-hosted account has the feature flag disabled' do
|
||||
let!(:admin) { create(:user, :administrator, account: account) }
|
||||
let!(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
|
||||
account.disable_features!('api_and_webhooks')
|
||||
end
|
||||
|
||||
it 'allows token authenticated requests' do
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
|
||||
headers: { api_access_token: admin.access_token.token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an invalid api_access_token' do
|
||||
it 'returns unauthorized' do
|
||||
get '/api/v1/profile',
|
||||
@@ -94,6 +140,21 @@ RSpec.describe 'API Base', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API and webhook access is disabled for the account' do
|
||||
it 'returns forbidden for accessible bot endpoints' do
|
||||
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
|
||||
allow(Account).to receive(:find).and_call_original
|
||||
allow(Account).to receive(:find).with(account.id.to_s).and_return(account)
|
||||
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
|
||||
headers: { api_access_token: agent_bot.access_token.token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the account is suspended' do
|
||||
it 'returns 401 unauthorized' do
|
||||
account.update!(status: :suspended)
|
||||
|
||||
@@ -15,7 +15,7 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
context 'when it is an authenticated agent' do
|
||||
it 'returns all the agent_bots in account along with global agent bots' do
|
||||
global_bot = create(:agent_bot)
|
||||
get "/api/v1/accounts/#{account.id}/agent_bots",
|
||||
@@ -25,7 +25,7 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include(agent_bot.name)
|
||||
expect(response.body).to include(global_bot.name)
|
||||
expect(response.body).to include(agent_bot.access_token.token)
|
||||
expect(response.body).not_to include(agent_bot.access_token.token)
|
||||
expect(response.body).not_to include(global_bot.access_token.token)
|
||||
end
|
||||
|
||||
@@ -54,6 +54,17 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
expect(account_bot_response).to include('thumbnail')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated administrator' do
|
||||
it 'returns the account bot access token' do
|
||||
get "/api/v1/accounts/#{account.id}/agent_bots",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include(agent_bot.access_token.token)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/agent_bots/:id' do
|
||||
@@ -65,7 +76,7 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
context 'when it is an authenticated agent' do
|
||||
it 'shows the agent bot' do
|
||||
get "/api/v1/accounts/#{account.id}/agent_bots/#{agent_bot.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
@@ -73,7 +84,7 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include(agent_bot.name)
|
||||
expect(response.body).to include(agent_bot.access_token.token)
|
||||
expect(response.body).not_to include(agent_bot.access_token.token)
|
||||
end
|
||||
|
||||
it 'will show a global agent bot' do
|
||||
@@ -91,6 +102,17 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
expect(response.parsed_body).not_to include('outgoing_url')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated administrator' do
|
||||
it 'returns the account bot access token' do
|
||||
get "/api/v1/accounts/#{account.id}/agent_bots/#{agent_bot.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include(agent_bot.access_token.token)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/agent_bots' do
|
||||
|
||||
@@ -170,6 +170,29 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
|
||||
expect(json_response['payload']['status']).to eql(article_params[:article][:status])
|
||||
expect(json_response['payload']['position']).to eql(article_params[:article][:position])
|
||||
end
|
||||
|
||||
it 'stages draft-only fields without bumping updated_at' do
|
||||
expect do
|
||||
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
|
||||
params: { article: { draft_title: 'Draft title', draft_content: 'Draft body' } },
|
||||
headers: admin.create_new_auth_token
|
||||
end.not_to(change { article.reload.updated_at })
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(article.draft_title).to eq('Draft title')
|
||||
expect(article.draft_content).to eq('Draft body')
|
||||
end
|
||||
|
||||
it 'rejects an over-length draft without persisting it' do
|
||||
expect do
|
||||
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
|
||||
params: { article: { draft_content: 'a' * 20_001 } },
|
||||
headers: admin.create_new_auth_token
|
||||
end.not_to(change { article.reload.draft_content })
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['message']).to include('too long')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -119,7 +119,13 @@ RSpec.describe 'Conversation Messages API', type: :request do
|
||||
expect(Conversations::ActivityMessageJob)
|
||||
.to(have_been_enqueued.at_least(:once)
|
||||
.with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity,
|
||||
content: 'System reopened the conversation due to a new incoming message.' }))
|
||||
content: 'System reopened the conversation due to a new incoming message.',
|
||||
content_attributes: {
|
||||
activity: {
|
||||
type: 'conversation_status_changed',
|
||||
status: 'open'
|
||||
}
|
||||
} }))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -70,8 +70,8 @@ RSpec.describe 'DashboardAppsController', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
context 'when it is an authenticated administrator' do
|
||||
let(:user) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'creates the dashboard app' do
|
||||
expect do
|
||||
@@ -130,11 +130,26 @@ RSpec.describe 'DashboardAppsController', type: :request do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'does not create account-wide dashboard apps' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/dashboard_apps",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: payload,
|
||||
as: :json
|
||||
end.not_to change(DashboardApp, :count)
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/accounts/{account.id}/dashboard_apps/:id' do
|
||||
let(:payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'https://link.com' }] } } }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:user) { create(:user, account: account, role: :administrator) }
|
||||
let!(:dashboard_app) { create(:dashboard_app, user: user, account: account) }
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
@@ -160,10 +175,24 @@ RSpec.describe 'DashboardAppsController', type: :request do
|
||||
expect(json_response['content'][0]['type']).to eq payload[:dashboard_app][:content][0][:type]
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'does not update account-wide dashboard apps' do
|
||||
patch "/api/v1/accounts/#{account.id}/dashboard_apps/#{dashboard_app.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: payload,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(dashboard_app.reload.title).not_to eq('CRM Dashboard')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/{account.id}/dashboard_apps/:id' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:user) { create(:user, account: account, role: :administrator) }
|
||||
let!(:dashboard_app) { create(:dashboard_app, user: user, account: account) }
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
@@ -182,5 +211,18 @@ RSpec.describe 'DashboardAppsController', type: :request do
|
||||
expect(user.dashboard_apps.count).to be 0
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'does not delete account-wide dashboard apps' do
|
||||
delete "/api/v1/accounts/#{account.id}/dashboard_apps/#{dashboard_app.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(DashboardApp.exists?(dashboard_app.id)).to be(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -26,6 +26,17 @@ RSpec.describe 'Webhooks API', type: :request do
|
||||
expect(response.parsed_body['payload']['webhooks'].count).to eql account.webhooks.count
|
||||
end
|
||||
end
|
||||
|
||||
context 'when api_and_webhooks feature is disabled' do
|
||||
it 'allows session authenticated admins to manage webhooks' do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
account.disable_features!('api_and_webhooks')
|
||||
get "/api/v1/accounts/#{account.id}/webhooks",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/<account_id>/webhooks' do
|
||||
|
||||
@@ -456,29 +456,18 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do
|
||||
create(:inbox_member, inbox: whatsapp_inbox, user: agent)
|
||||
end
|
||||
|
||||
it 'returns unprocessable_entity error' do
|
||||
it 'returns unauthorized error' do
|
||||
allow(whatsapp_channel).to receive(:reauthorization_required?).and_return(true)
|
||||
|
||||
# Stub the embedded signup service to prevent HTTP calls
|
||||
embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
|
||||
allow(Whatsapp::EmbeddedSignupService).to receive(:new).with(
|
||||
account: account,
|
||||
params: {
|
||||
code: 'test',
|
||||
business_id: 'test',
|
||||
waba_id: 'test'
|
||||
},
|
||||
inbox_id: whatsapp_inbox.id
|
||||
).and_return(embedded_signup_service)
|
||||
allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
|
||||
expect(Whatsapp::EmbeddedSignupService).not_to receive(:new)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
|
||||
params: { inbox_id: whatsapp_inbox.id, code: 'test', business_id: 'test', waba_id: 'test' },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
# Agents should get unprocessable_entity since they can find the inbox but channel doesn't need reauth
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
# Reauthorizing an existing inbox swaps live credentials, so it is restricted to admins.
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -199,6 +199,22 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
expect(response.body).to include(account.locale)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API and webhook access is disabled for the account' do
|
||||
it 'returns forbidden for API token authentication' do
|
||||
account_scope = double
|
||||
allow(account_scope).to receive(:find).with(account.id.to_s).and_return(account)
|
||||
allow_any_instance_of(User).to receive(:accounts).and_return(account_scope) # rubocop:disable RSpec/AnyInstance
|
||||
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}",
|
||||
headers: { api_access_token: admin.access_token.token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(response.parsed_body['error']).to eq('API access is not enabled for this account')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/cache_keys' do
|
||||
@@ -225,6 +241,21 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
expect(response.headers['Cache-Control']).to include('private')
|
||||
expect(response.headers['Cache-Control']).to include('stale-while-revalidate=300')
|
||||
end
|
||||
|
||||
context 'when API and webhook access is disabled for the account' do
|
||||
it 'returns forbidden for API token authentication' do
|
||||
account_scope = double
|
||||
allow(account_scope).to receive(:find).with(account.id.to_s).and_return(account)
|
||||
allow_any_instance_of(User).to receive(:accounts).and_return(account_scope) # rubocop:disable RSpec/AnyInstance
|
||||
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/cache_keys",
|
||||
headers: { api_access_token: admin.access_token.token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/accounts/{account.id}' do
|
||||
@@ -324,6 +355,24 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
expect(json_response['message']).to eq('Name is too long (maximum is 255 characters)')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API and webhook access is disabled for the account' do
|
||||
it 'returns forbidden without modifying the account for API token authentication' do
|
||||
account_scope = double
|
||||
allow(account_scope).to receive(:find).with(account.id.to_s).and_return(account)
|
||||
allow_any_instance_of(User).to receive(:accounts).and_return(account_scope) # rubocop:disable RSpec/AnyInstance
|
||||
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
|
||||
|
||||
expect do
|
||||
patch "/api/v1/accounts/#{account.id}",
|
||||
params: { name: 'Updated through API' },
|
||||
headers: { api_access_token: admin.access_token.token },
|
||||
as: :json
|
||||
end.not_to(change { account.reload.name })
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/update_active_at' do
|
||||
@@ -349,5 +398,22 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
expect(agent.account_users.first.active_at).not_to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API and webhook access is disabled for the account' do
|
||||
it 'returns forbidden without updating active_at for API token authentication' do
|
||||
account_scope = double
|
||||
allow(account_scope).to receive(:find).with(account.id.to_s).and_return(account)
|
||||
allow_any_instance_of(User).to receive(:accounts).and_return(account_scope) # rubocop:disable RSpec/AnyInstance
|
||||
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
|
||||
account_user = agent.account_users.first
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/update_active_at",
|
||||
headers: { api_access_token: agent.access_token.token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(account_user.reload.active_at).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -29,6 +29,49 @@ RSpec.describe 'Profile API', type: :request do
|
||||
expect(json_response['custom_attributes']['test']).to eq('test')
|
||||
expect(json_response['message_signature']).to be_nil
|
||||
end
|
||||
|
||||
it 'returns an empty access token when all accounts have API and webhook access disabled' do
|
||||
account.disable_features!('api_and_webhooks')
|
||||
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
|
||||
allow_any_instance_of(User).to receive(:accounts).and_return([account]) # rubocop:disable RSpec/AnyInstance
|
||||
|
||||
get '/api/v1/profile',
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['access_token']).to eq('')
|
||||
expect(json_response['accounts'].first['api_and_webhooks']).to be false
|
||||
end
|
||||
|
||||
it 'returns the access token when any account has API and webhook access enabled' do
|
||||
account.disable_features!('api_and_webhooks')
|
||||
enabled_account = create(:account)
|
||||
enabled_account.enable_features!('api_and_webhooks')
|
||||
create(:account_user, account: enabled_account, user: agent)
|
||||
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
|
||||
allow(enabled_account).to receive(:api_and_webhooks_enabled?).and_return(true)
|
||||
allow_any_instance_of(User).to receive(:accounts).and_return([account, enabled_account]) # rubocop:disable RSpec/AnyInstance
|
||||
|
||||
get '/api/v1/profile',
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['access_token']).to eq(agent.access_token.token)
|
||||
expect(json_response['accounts'].find { |item| item['id'] == enabled_account.id }['api_and_webhooks']).to be true
|
||||
end
|
||||
|
||||
it 'returns the access token for self-hosted accounts even when the stored feature flag is disabled' do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
|
||||
account.disable_features!('api_and_webhooks')
|
||||
|
||||
get '/api/v1/profile',
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response.parsed_body['access_token']).to eq(agent.access_token.token)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -338,6 +381,21 @@ RSpec.describe 'Profile API', type: :request do
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['access_token']).to eq(agent.access_token.token)
|
||||
end
|
||||
|
||||
it 'regenerates the stored token but returns an empty token when no account has API and webhook access enabled' do
|
||||
account.disable_features!('api_and_webhooks')
|
||||
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
|
||||
allow_any_instance_of(User).to receive(:accounts).and_return([account]) # rubocop:disable RSpec/AnyInstance
|
||||
old_token = agent.access_token.token
|
||||
|
||||
post '/api/v1/profile/reset_access_token',
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(agent.reload.access_token.token).not_to eq(old_token)
|
||||
expect(response.parsed_body['access_token']).to eq('')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -285,7 +285,8 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: "Conversation was resolved by #{contact.name}"
|
||||
content: "Conversation was resolved by #{contact.name}",
|
||||
content_attributes: { activity: { type: 'conversation_status_changed', status: 'resolved' } }
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
@@ -202,7 +202,8 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: "Conversation was resolved by #{contact.name}"
|
||||
content: "Conversation was resolved by #{contact.name}",
|
||||
content_attributes: { activity: { type: 'conversation_status_changed', status: 'resolved' } }
|
||||
}
|
||||
)
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
@@ -5,6 +5,30 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let!(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
describe 'API token access' do
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
account.disable_features!('api_and_webhooks')
|
||||
end
|
||||
|
||||
it 'returns forbidden when API and webhook access is disabled for the account' do
|
||||
get "/enterprise/api/v1/accounts/#{account.id}/limits",
|
||||
headers: { api_access_token: admin.access_token.token },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(response.parsed_body['error']).to eq('API access is not enabled for this account')
|
||||
end
|
||||
|
||||
it 'allows session-authenticated requests' do
|
||||
get "/enterprise/api/v1/accounts/#{account.id}/limits",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /enterprise/api/v1/accounts/{account.id}/subscription' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
|
||||
@@ -49,6 +49,23 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
expect(conversation.messages.last.content).to eq('Hey, welcome to Captain Specs')
|
||||
end
|
||||
|
||||
it 'keeps the default message history limited to public chat messages' do
|
||||
create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
message_type: :activity,
|
||||
content: 'Conversation was marked resolved',
|
||||
content_attributes: { activity: { type: 'conversation_status_changed', status: 'resolved' } }
|
||||
)
|
||||
create(:message, conversation: conversation, content: 'Private note', message_type: :outgoing, private: true)
|
||||
|
||||
expect(mock_llm_chat_service).to receive(:generate_response).with(
|
||||
message_history: [{ content: 'Hello', role: 'user' }]
|
||||
).and_return({ 'response' => 'Hey, welcome to Captain Specs' })
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end
|
||||
|
||||
it 'increments usage response' do
|
||||
described_class.perform_now(conversation, assistant)
|
||||
account.reload
|
||||
@@ -342,9 +359,30 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
expect(conversation.messages.last.content).to eq('Hey, welcome to Captain V2')
|
||||
end
|
||||
|
||||
it 'passes message history to agent runner service' do
|
||||
it 'passes message history with resolution markers to agent runner service' do
|
||||
same_second = Time.current.change(usec: 0)
|
||||
conversation.messages.find_by!(content: 'Hello').update!(created_at: same_second, updated_at: same_second)
|
||||
create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
message_type: :activity,
|
||||
content: 'Conversation was marked resolved by Alice',
|
||||
content_attributes: { activity: { type: 'conversation_status_changed', status: 'resolved' } },
|
||||
created_at: same_second,
|
||||
updated_at: same_second
|
||||
)
|
||||
create(:message, conversation: conversation, message_type: :activity, content: 'Assigned to agent', created_at: same_second,
|
||||
updated_at: same_second)
|
||||
create(:message, conversation: conversation, content: 'Fresh question', message_type: :incoming, created_at: same_second,
|
||||
updated_at: same_second)
|
||||
|
||||
expected_messages = [
|
||||
{ content: 'Hello', role: 'user' }
|
||||
{ content: 'Hello', role: 'user' },
|
||||
{
|
||||
content: Captain::Conversation::MessageHistoryBuilderService::RESOLUTION_MARKER,
|
||||
role: 'assistant'
|
||||
},
|
||||
{ content: 'Fresh question', role: 'user' }
|
||||
]
|
||||
|
||||
expect(mock_agent_runner_service).to receive(:generate_response).with(
|
||||
|
||||
@@ -154,7 +154,8 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
|
||||
account_id: resolvable_pending_conversation.account_id,
|
||||
inbox_id: resolvable_pending_conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: expected_content
|
||||
content: expected_content,
|
||||
content_attributes: { activity: { type: 'conversation_status_changed', status: 'resolved' } }
|
||||
}
|
||||
)
|
||||
end
|
||||
@@ -252,7 +253,8 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
|
||||
account_id: resolvable_pending_conversation.account_id,
|
||||
inbox_id: resolvable_pending_conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: expected_content
|
||||
content: expected_content,
|
||||
content_attributes: { activity: { type: 'conversation_status_changed', status: 'open' } }
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
@@ -129,7 +129,7 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
before do
|
||||
custom_tool.update!(
|
||||
auth_type: 'api_key',
|
||||
auth_config: { 'key' => 'api_key_123', 'location' => 'header', 'name' => 'X-API-Key' },
|
||||
auth_config: { 'key' => 'api_key_123', 'name' => 'X-API-Key' },
|
||||
endpoint_url: 'https://example.com/data',
|
||||
response_template: nil
|
||||
)
|
||||
@@ -145,6 +145,22 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
expect(WebMock).to have_requested(:get, 'https://example.com/data')
|
||||
.with(headers: { 'X-API-Key' => 'api_key_123' })
|
||||
end
|
||||
|
||||
it 'strips the API key header on cross-origin redirects' do
|
||||
redirect_url = 'http://example.com/data'
|
||||
redirected_headers = nil
|
||||
stub_request(:get, 'https://example.com/data').to_return(status: 302, headers: { 'Location' => redirect_url })
|
||||
stub_request(:get, redirect_url)
|
||||
.with do |request|
|
||||
redirected_headers = request.headers.transform_keys(&:downcase)
|
||||
true
|
||||
end
|
||||
.to_return(status: 200, body: '{"authenticated": false}')
|
||||
|
||||
tool.perform(tool_context)
|
||||
|
||||
expect(redirected_headers).not_to include('x-api-key')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with response template' do
|
||||
|
||||
@@ -32,6 +32,28 @@ RSpec.describe Account, type: :model do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#api_and_webhooks_enabled?' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
it 'is always enabled for self-hosted enterprise accounts' do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
|
||||
account.disable_features!('api_and_webhooks')
|
||||
|
||||
expect(account.api_and_webhooks_enabled?).to be true
|
||||
end
|
||||
|
||||
it 'uses the account feature flag on Chatwoot Cloud' do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
account.disable_features!('api_and_webhooks')
|
||||
|
||||
expect(account.api_and_webhooks_enabled?).to be false
|
||||
|
||||
account.enable_features!('api_and_webhooks')
|
||||
|
||||
expect(account.api_and_webhooks_enabled?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'sla_policies' do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:sla_policy) { create(:sla_policy, account: account) }
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Assistant do
|
||||
describe '#agent_tools' do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
it 'includes enabled custom tools from the assistant account' do
|
||||
custom_tool = create(:captain_custom_tool, account: account)
|
||||
|
||||
tools = assistant.send(:agent_tools)
|
||||
|
||||
expect(tools.map(&:name)).to include(custom_tool.slug)
|
||||
expect(tools.find { |tool| tool.name == custom_tool.slug }).to be_a(Captain::Tools::HttpTool)
|
||||
end
|
||||
|
||||
it 'excludes disabled custom tools' do
|
||||
custom_tool = create(:captain_custom_tool, :disabled, account: account)
|
||||
|
||||
tools = assistant.send(:agent_tools)
|
||||
|
||||
expect(tools.map(&:name)).not_to include(custom_tool.slug)
|
||||
end
|
||||
|
||||
it 'excludes custom tools from other accounts' do
|
||||
custom_tool = create(:captain_custom_tool)
|
||||
|
||||
tools = assistant.send(:agent_tools)
|
||||
|
||||
expect(tools.map(&:name)).not_to include(custom_tool.slug)
|
||||
end
|
||||
|
||||
it 'keeps the built-in FAQ lookup and handoff tools' do
|
||||
tools = assistant.send(:agent_tools)
|
||||
|
||||
expect(tools).to include(
|
||||
an_instance_of(Captain::Tools::FaqLookupTool),
|
||||
an_instance_of(Captain::Tools::HandoffTool)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -201,7 +201,7 @@ RSpec.describe Captain::CustomTool, type: :model do
|
||||
|
||||
expect(tool.auth_type).to eq('api_key')
|
||||
expect(tool.auth_config['key']).to eq('test_api_key')
|
||||
expect(tool.auth_config['location']).to eq('header')
|
||||
expect(tool.auth_config['name']).to eq('X-API-Key')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -259,19 +259,12 @@ RSpec.describe Captain::CustomTool, type: :model do
|
||||
expect(tool.build_auth_headers).to eq({ 'Authorization' => 'Bearer test_bearer_token_123' })
|
||||
end
|
||||
|
||||
it 'returns API key header when location is header' do
|
||||
it 'returns API key header' do
|
||||
tool = create(:captain_custom_tool, :with_api_key, account: account)
|
||||
|
||||
expect(tool.build_auth_headers).to eq({ 'X-API-Key' => 'test_api_key' })
|
||||
end
|
||||
|
||||
it 'returns empty hash for API key when location is not header' do
|
||||
tool = create(:captain_custom_tool, account: account, auth_type: 'api_key',
|
||||
auth_config: { key: 'test_key', location: 'query', name: 'api_key' })
|
||||
|
||||
expect(tool.build_auth_headers).to eq({})
|
||||
end
|
||||
|
||||
it 'returns empty hash for basic auth' do
|
||||
tool = create(:captain_custom_tool, :with_basic_auth, account: account)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ RSpec.describe Captain::AssistantMigration::DraftApplier do
|
||||
let(:faq_document_candidate) do
|
||||
{
|
||||
'question' => 'When is support available?',
|
||||
'answer' => 'Support is available Monday to Friday.'
|
||||
'answer' => "Support is available Monday to Friday.\n\nUrgent requests are handled by the on-call team."
|
||||
}
|
||||
end
|
||||
let(:draft) do
|
||||
@@ -46,11 +46,15 @@ RSpec.describe Captain::AssistantMigration::DraftApplier do
|
||||
expect(result.dig(:changes, :response_guidelines, :to)).to include(
|
||||
'For account-specific billing issues, collect the invoice number and summarize the issue before escalating.'
|
||||
)
|
||||
expect(result.dig(:changes, :faq_responses, :create)).to contain_exactly(
|
||||
faq_document_candidate.merge('status' => 'approved')
|
||||
)
|
||||
expect(assistant.reload.config).not_to have_key('assistant_migration')
|
||||
expect(assistant.responses.count).to eq(0)
|
||||
expect(assistant.scenarios.count).to eq(0)
|
||||
end
|
||||
|
||||
it 'stores scenario candidates in assistant config and flattens them into response guidelines' do
|
||||
it 'stores scenario and FAQ candidates and creates approved FAQ responses' do
|
||||
described_class.new(assistant: assistant, draft: draft, dry_run: false).perform
|
||||
|
||||
assistant.reload
|
||||
@@ -62,8 +66,55 @@ RSpec.describe Captain::AssistantMigration::DraftApplier do
|
||||
expect(assistant.response_guidelines).to include(
|
||||
'For account-specific billing issues, collect the invoice number and summarize the issue before escalating.'
|
||||
)
|
||||
expect(assistant.response_guidelines).not_to include(faq_document_candidate['answer'])
|
||||
expect(assistant.responses).to contain_exactly(
|
||||
have_attributes(
|
||||
question: faq_document_candidate['question'],
|
||||
answer: faq_document_candidate['answer'],
|
||||
status: 'approved'
|
||||
)
|
||||
)
|
||||
expect(assistant.scenarios.count).to eq(0)
|
||||
|
||||
expect do
|
||||
described_class.new(assistant: assistant, draft: draft, dry_run: false).perform
|
||||
end.not_to(change { assistant.responses.count })
|
||||
end
|
||||
|
||||
it 'leaves pending FAQ responses untouched' do
|
||||
pending_response = assistant.responses.create!(
|
||||
question: faq_document_candidate['question'],
|
||||
answer: faq_document_candidate['answer'],
|
||||
status: :pending
|
||||
)
|
||||
|
||||
described_class.new(assistant: assistant, draft: draft, dry_run: false).perform
|
||||
|
||||
expect(pending_response.reload).to be_pending
|
||||
expect(assistant.responses.approved).to contain_exactly(
|
||||
have_attributes(
|
||||
question: faq_document_candidate['question'],
|
||||
answer: faq_document_candidate['answer']
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
it 'rejects conflicting FAQ answers within the same draft' do
|
||||
conflicting_draft = draft.merge(
|
||||
faq_document_candidates: [
|
||||
faq_document_candidate,
|
||||
{
|
||||
'question' => "When is support\navailable?",
|
||||
'answer' => 'Support is available every day.'
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
expect do
|
||||
described_class.new(assistant: assistant, draft: conflicting_draft, dry_run: true).perform
|
||||
end.to raise_error(ArgumentError, 'FAQ candidate conflicts with an existing FAQ: When is support available?')
|
||||
|
||||
expect(assistant.responses.count).to eq(0)
|
||||
expect(assistant.config).not_to have_key('assistant_migration')
|
||||
end
|
||||
|
||||
it 'rejects stale drafts whose FAQ candidates use the old string format' do
|
||||
@@ -87,8 +138,12 @@ RSpec.describe Captain::AssistantMigration::DraftApplier do
|
||||
|
||||
assistant.reload
|
||||
expect(assistant.description).to eq('Support assistant for Test Product.')
|
||||
expect(assistant.response_guidelines).to include('Be concise.')
|
||||
expect(assistant.guardrails).to eq(['Do not guess.'])
|
||||
expect(assistant.response_guidelines).to include(
|
||||
'Use plain language.',
|
||||
'Be concise.',
|
||||
'For account-specific billing issues, collect the invoice number and summarize the issue before escalating.'
|
||||
)
|
||||
expect(assistant.guardrails).to contain_exactly('Do not disclose internal notes.', 'Do not guess.')
|
||||
expect(assistant.config.dig('assistant_migration', 'original_values')).to include(
|
||||
'name' => assistant.name,
|
||||
'description' => 'Existing assistant description.',
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::AssistantMigration::InstructionClassifier do
|
||||
describe Captain::AssistantMigration::InstructionClassifierSchema do
|
||||
it 'does not request classification notes' do
|
||||
expect(described_class.as_json.to_s).not_to include('classification_notes')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'classifier prompt' do
|
||||
it 'keeps the model focused on active behavior and approved FAQ candidates' do
|
||||
prompt = Captain::PromptRenderer.render('instruction_classifier')
|
||||
|
||||
expect(prompt).to include(
|
||||
'The original custom instructions remain stored unchanged',
|
||||
'A FAQ cannot implicitly preserve an action',
|
||||
'Scenario candidates remain pending metadata',
|
||||
'Convert reusable query-dependent facts into natural customer questions',
|
||||
'an error code that requires immediate',
|
||||
'actively require specialist-name verification',
|
||||
'Mandatory prohibitions are not FAQ-only',
|
||||
'never promise refunds after 30 days',
|
||||
'never recommend cooking the product',
|
||||
'Treat explicit policy boundaries',
|
||||
'outside the stated condition, window, or exception',
|
||||
'source-defined behavior or workflow that requires an unavailable capability',
|
||||
'Do not require mandatory wording',
|
||||
'every mandatory action and prohibition remains active'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe Captain::AssistantMigration::InstructionAuditorSchema do
|
||||
it 'only permits additions that fit in the generated draft' do
|
||||
schema = described_class.for(
|
||||
response_guidelines: 0,
|
||||
guardrails: 2,
|
||||
scenario_candidates: 1,
|
||||
faq_document_candidates: 3,
|
||||
needs_review: 4
|
||||
).new.to_json_schema[:schema]
|
||||
|
||||
expect(schema[:properties]).not_to have_key(:response_guidelines)
|
||||
expect(schema.dig(:properties, :guardrails, :maxItems)).to eq(2)
|
||||
expect(schema.dig(:properties, :scenario_candidates, :maxItems)).to eq(1)
|
||||
expect(schema.dig(:properties, :faq_document_candidates, :maxItems)).to eq(3)
|
||||
expect(schema.dig(:properties, :needs_review, :maxItems)).to eq(4)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'auditor prompt' do
|
||||
it 'adds missing coverage without replacing the generated draft' do
|
||||
prompt = Captain::PromptRenderer.render('instruction_auditor')
|
||||
|
||||
expect(prompt).to include(
|
||||
'This is a monotonic coverage audit',
|
||||
'Never repeat, rewrite, replace, or delete content',
|
||||
'If mandatory behavior appears only there, add the missing active guideline or guardrail',
|
||||
'available_additions gives the exact remaining capacity',
|
||||
'A needs_review item never replaces representable behavior',
|
||||
'No mandatory action or prohibition remains FAQ-only'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'audited payload' do
|
||||
it 'appends a review note for an unavailable runtime capability' do
|
||||
service = described_class.new(assistant: instance_double(Captain::Assistant))
|
||||
generated_draft = {
|
||||
response_guidelines: [],
|
||||
guardrails: [],
|
||||
scenario_candidates: [],
|
||||
faq_document_candidates: [],
|
||||
needs_review: ['Existing conflict']
|
||||
}
|
||||
|
||||
result = service.send(
|
||||
:audited_payload,
|
||||
generated_draft,
|
||||
{ needs_review: ['Order-status lookup requires an unavailable account-history tool.'] }
|
||||
)
|
||||
|
||||
expect(result[:needs_review]).to eq(
|
||||
['Existing conflict', 'Order-status lookup requires an unavailable account-history tool.']
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -248,20 +248,53 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
service.generate_and_deduplicate
|
||||
end.to change(captain_assistant.faq_suggestions, :count).by(1)
|
||||
|
||||
expect(captain_assistant.faq_suggestions.pluck(:language)).to contain_exactly('en', 'pt_BR')
|
||||
expect(captain_assistant.faq_suggestions.pluck(:language)).to contain_exactly('en', 'pt')
|
||||
expect(existing_suggestion.reload.source_count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an open suggestion uses another locale variant of the same language' do
|
||||
let(:account) { create(:account, locale: 'pt_BR') }
|
||||
let(:captain_assistant) { create(:captain_assistant, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, first_reply_created_at: Time.zone.now) }
|
||||
let(:sample_faqs) { [{ 'question' => 'Como ativo o recurso?', 'answer' => 'Ative nas configuracoes.' }] }
|
||||
let(:existing_suggestion) do
|
||||
captain_assistant.faq_suggestions.create!(
|
||||
question: 'Como habilito o recurso?',
|
||||
answer: 'Ative nas configuracoes.',
|
||||
embedding: embedding_one,
|
||||
language: 'pt',
|
||||
source_count: 1
|
||||
)
|
||||
end
|
||||
let(:equivalence_response) { instance_double(RubyLLM::Message, content: { same_faq: true }.to_json) }
|
||||
|
||||
before do
|
||||
existing_suggestion
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
|
||||
allow(mock_chat).to receive(:ask) do |input|
|
||||
input.start_with?('{') ? equivalence_response : mock_response
|
||||
end
|
||||
end
|
||||
|
||||
it 'attaches the observation to the existing base-language suggestion' do
|
||||
expect do
|
||||
service.generate_and_deduplicate
|
||||
end.to change(existing_suggestion.observations, :count).by(1)
|
||||
|
||||
expect(existing_suggestion.reload.source_count).to eq(2)
|
||||
expect(captain_assistant.faq_suggestions.count).to eq(1)
|
||||
expect(existing_suggestion.observations.last.language).to eq('pt')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a similar approved FAQ uses the account language' do
|
||||
let(:sample_faqs) { [{ 'question' => 'Como ativo o recurso?', 'answer' => 'Ative nas configuracoes.' }] }
|
||||
let!(:existing_response) do
|
||||
|
||||
before do
|
||||
create(:captain_assistant_response, assistant: captain_assistant, account: captain_assistant.account,
|
||||
question: 'How do I enable the feature?', answer: 'Turn it on in settings.',
|
||||
embedding: embedding_one)
|
||||
end
|
||||
|
||||
before do
|
||||
conversation.update!(additional_attributes: { conversation_language: 'pt-BR' })
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
|
||||
end
|
||||
@@ -271,7 +304,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
service.generate_and_deduplicate
|
||||
end.to change(captain_assistant.faq_suggestions, :count).by(1)
|
||||
expect(Captain::FaqObservation.discarded.count).to be_zero
|
||||
expect(captain_assistant.faq_suggestions.last.language).to eq('pt_BR')
|
||||
expect(captain_assistant.faq_suggestions.last.language).to eq('pt')
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ FactoryBot.define do
|
||||
|
||||
trait :with_api_key do
|
||||
auth_type { 'api_key' }
|
||||
auth_config { { key: 'test_api_key', location: 'header', name: 'X-API-Key' } }
|
||||
auth_config { { key: 'test_api_key', name: 'X-API-Key' } }
|
||||
end
|
||||
|
||||
trait :with_templates do
|
||||
|
||||
@@ -249,6 +249,34 @@ RSpec.describe SafeFetch do
|
||||
expect { described_class.fetch(redirect_url) { nil } }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
it 'strips caller-provided sensitive headers on private network cross-origin redirects' do
|
||||
redirect_url = 'http://example.com/redirect.png'
|
||||
private_url = 'http://private.example.com/image.png'
|
||||
redirected_headers = nil
|
||||
allow(Resolv).to receive(:getaddresses).with('private.example.com').and_return(['10.0.0.5'])
|
||||
stub_request(:get, redirect_url).to_return(status: 302, headers: { 'Location' => private_url })
|
||||
stub_request(:get, private_url)
|
||||
.with do |request|
|
||||
redirected_headers = request.headers.transform_keys(&:downcase)
|
||||
true
|
||||
end
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: File.new(Rails.root.join('spec/assets/avatar.png')),
|
||||
headers: { 'Content-Type' => 'image/png' }
|
||||
)
|
||||
|
||||
with_modified_env('SAFE_FETCH_ALLOW_PRIVATE_NETWORK' => 'true') do
|
||||
described_class.fetch(
|
||||
redirect_url,
|
||||
headers: { 'X-API-Key' => 'secret-key' },
|
||||
sensitive_headers: ['X-API-Key']
|
||||
) { nil }
|
||||
end
|
||||
|
||||
expect(redirected_headers).not_to include('x-api-key')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with content-type allowlist' do
|
||||
@@ -400,6 +428,47 @@ RSpec.describe SafeFetch do
|
||||
expect(redirected_headers).not_to include('authorization', 'cookie')
|
||||
end
|
||||
|
||||
it 'strips caller-provided sensitive headers on cross-origin redirects' do
|
||||
redirect_url = 'https://example.com/image.png'
|
||||
redirected_headers = nil
|
||||
headers = { 'X-API-Key' => 'secret-key' }
|
||||
|
||||
stub_request(:get, url).to_return(status: 302, headers: { 'Location' => redirect_url })
|
||||
stub_request(:get, redirect_url)
|
||||
.with do |request|
|
||||
redirected_headers = request.headers.transform_keys(&:downcase)
|
||||
true
|
||||
end
|
||||
.to_return(status: 200, body: '', headers: {})
|
||||
|
||||
described_class.fetch(
|
||||
url,
|
||||
headers: headers,
|
||||
sensitive_headers: ['X-API-Key'],
|
||||
validate_content_type: false
|
||||
) { nil }
|
||||
|
||||
expect(redirected_headers).not_to include('x-api-key')
|
||||
end
|
||||
|
||||
it 'preserves caller-provided sensitive headers on same-origin redirects' do
|
||||
redirect_url = 'http://example.com/redirected.png'
|
||||
|
||||
stub_request(:get, url).to_return(status: 302, headers: { 'Location' => '/redirected.png' })
|
||||
stub_request(:get, redirect_url)
|
||||
.with(headers: { 'X-API-Key' => 'secret-key' })
|
||||
.to_return(status: 200, body: '', headers: {})
|
||||
|
||||
described_class.fetch(
|
||||
url,
|
||||
headers: { 'X-API-Key' => 'secret-key' },
|
||||
sensitive_headers: ['X-API-Key'],
|
||||
validate_content_type: false
|
||||
) { nil }
|
||||
|
||||
expect(WebMock).to have_requested(:get, redirect_url).with(headers: { 'X-API-Key' => 'secret-key' })
|
||||
end
|
||||
|
||||
it 'raises UnsupportedMethodError for unsupported HTTP methods' do
|
||||
expect { described_class.fetch(url, method: :options) { nil } }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SafeFetch::UnsupportedMethodError')
|
||||
|
||||
@@ -44,6 +44,50 @@ describe WebhookListener do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API and webhook access is disabled for the account' do
|
||||
before do
|
||||
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
|
||||
allow(message).to receive(:inbox).and_return(inbox)
|
||||
allow(inbox).to receive(:account).and_return(account)
|
||||
end
|
||||
|
||||
it 'does not trigger account webhooks' do
|
||||
create(:webhook, inbox: inbox, account: account)
|
||||
expect(WebhookJob).not_to receive(:perform_later)
|
||||
listener.message_created(message_created_event)
|
||||
end
|
||||
|
||||
it 'still triggers API inbox webhooks' do
|
||||
channel_api = create(:channel_api, account: account)
|
||||
api_inbox = channel_api.inbox
|
||||
api_conversation = create(:conversation, account: account, inbox: api_inbox, assignee: user)
|
||||
api_message = create(:message, message_type: 'outgoing', account: account, inbox: api_inbox, conversation: api_conversation)
|
||||
api_event = Events::Base.new(event_name, Time.zone.now, message: api_message)
|
||||
allow(api_message).to receive(:inbox).and_return(api_inbox)
|
||||
allow(api_inbox).to receive(:account).and_return(account)
|
||||
expect(WebhookJob).to receive(:perform_later).with(
|
||||
channel_api.webhook_url, api_message.webhook_data.merge(event: 'message_created'),
|
||||
:api_inbox_webhook, secret: channel_api.secret, delivery_id: instance_of(String)
|
||||
).once
|
||||
listener.message_created(api_event)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when api_and_webhooks feature is disabled on self-hosted' do
|
||||
it 'still triggers account webhooks' do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
|
||||
account.disable_features!('api_and_webhooks')
|
||||
webhook = create(:webhook, inbox: inbox, account: account)
|
||||
|
||||
expect(WebhookJob).to receive(:perform_later).with(
|
||||
webhook.url, message.webhook_data.merge(event: 'message_created'), :account_webhook,
|
||||
secret: webhook.secret, delivery_id: instance_of(String)
|
||||
).once
|
||||
|
||||
listener.message_created(message_created_event)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inbox is an API Channel' do
|
||||
it 'triggers webhook if webhook_url is present' do
|
||||
channel_api = create(:channel_api, account: account)
|
||||
|
||||
@@ -50,6 +50,15 @@ RSpec.describe Account do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#api_and_webhooks_enabled?' do
|
||||
it 'is enabled for self-hosted accounts regardless of the stored feature flag' do
|
||||
account = create(:account)
|
||||
account.disable_features!('api_and_webhooks')
|
||||
|
||||
expect(account.api_and_webhooks_enabled?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'captain defaults for new accounts' do
|
||||
it 'does not store Captain model overrides or enable premium Captain features' do
|
||||
InstallationConfig.find_or_initialize_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS').update!(
|
||||
@@ -109,7 +118,7 @@ RSpec.describe Account do
|
||||
it 'configures the account feature flag extension column' do
|
||||
expect(described_class.flag_columns).to include('feature_flags', 'feature_flags_ext_1')
|
||||
expect(described_class.flag_mapping['feature_flags_ext_1']).to eq(feature_whatsapp_manual_transfer: 1, feature_data_import: 1 << 1,
|
||||
feature_api_and_webhooks: 1 << 2)
|
||||
feature_api_and_webhooks: 1 << 2, feature_whatsapp_reconfigure: 1 << 3)
|
||||
expect(described_class.flag_mapping['feature_flags_ext_1'][:feature_whatsapp_manual_transfer]).to eq(1)
|
||||
expect(described_class.flag_mapping['feature_flags_ext_1'][:feature_data_import]).to eq(2)
|
||||
end
|
||||
|
||||
@@ -264,7 +264,8 @@ RSpec.describe Conversation do
|
||||
expect(Conversations::ActivityMessageJob)
|
||||
.to(have_been_enqueued.at_least(:once)
|
||||
.with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity,
|
||||
content: "Conversation was marked resolved by #{old_assignee.name}" }))
|
||||
content: "Conversation was marked resolved by #{old_assignee.name}",
|
||||
content_attributes: { activity: { type: 'conversation_status_changed', status: 'resolved' } } }))
|
||||
expect(Conversations::ActivityMessageJob)
|
||||
.to(have_been_enqueued.at_least(:once)
|
||||
.with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity,
|
||||
@@ -287,7 +288,8 @@ RSpec.describe Conversation do
|
||||
expect { conversation2.update(status: :resolved) }
|
||||
.to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||
.with(conversation2, { account_id: conversation2.account_id, inbox_id: conversation2.inbox_id, message_type: :activity,
|
||||
content: system_resolved_message })
|
||||
content: system_resolved_message,
|
||||
content_attributes: { activity: { type: 'conversation_status_changed', status: 'resolved' } } })
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ describe Whatsapp::EmbeddedSignupService do
|
||||
account: account,
|
||||
inbox_id: inbox_id,
|
||||
phone_number_id: params[:phone_number_id],
|
||||
business_id: params[:business_id]
|
||||
waba_id: params[:waba_id]
|
||||
).and_return(reauth_service)
|
||||
allow(reauth_service).to receive(:perform).with(access_token, phone_info).and_return(channel)
|
||||
|
||||
@@ -212,7 +212,7 @@ describe Whatsapp::EmbeddedSignupService do
|
||||
account: account,
|
||||
inbox_id: inbox.id,
|
||||
phone_number_id: params[:phone_number_id],
|
||||
business_id: params[:business_id]
|
||||
waba_id: params[:waba_id]
|
||||
).and_return(reauth_service)
|
||||
|
||||
allow(reauth_service).to receive(:perform) do
|
||||
|
||||
@@ -51,18 +51,41 @@ RSpec.describe Whatsapp::WebhookTeardownService do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is whatsapp_cloud but not embedded_signup' do
|
||||
context 'when channel is whatsapp_cloud with manual setup' do
|
||||
before do
|
||||
allow(channel).to receive(:setup_webhooks).and_return(true)
|
||||
|
||||
channel.update!(
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: { 'source' => 'manual' }
|
||||
provider_config: {
|
||||
'source' => 'manual',
|
||||
'phone_number_id' => 'manual_phone_id',
|
||||
'business_account_id' => 'manual_waba_id',
|
||||
'api_key' => 'manual_api_key'
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not attempt to unsubscribe webhook' do
|
||||
expect(Whatsapp::FacebookApiClient).not_to receive(:new)
|
||||
it 'clears the phone number callback override' do
|
||||
api_client = instance_double(Whatsapp::FacebookApiClient)
|
||||
allow(Whatsapp::FacebookApiClient).to receive(:new).with('manual_api_key').and_return(api_client)
|
||||
allow(api_client).to receive(:clear_phone_number_callback_override).with('manual_phone_id')
|
||||
|
||||
service.perform
|
||||
|
||||
expect(api_client).to have_received(:clear_phone_number_callback_override).with('manual_phone_id')
|
||||
end
|
||||
|
||||
# The manual token belongs to the customer's own Meta app, so its WABA subscription is not ours to remove.
|
||||
it 'does not unsubscribe the app from the WABA' do
|
||||
api_client = instance_double(Whatsapp::FacebookApiClient)
|
||||
allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client)
|
||||
allow(api_client).to receive(:clear_phone_number_callback_override)
|
||||
allow(api_client).to receive(:unsubscribe_app_from_waba)
|
||||
|
||||
service.perform
|
||||
|
||||
expect(api_client).not_to have_received(:unsubscribe_app_from_waba)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user