Merge branch 'feature/cw-7495-llm' into feature/cw-7495-api

This commit is contained in:
Aakash Bakhle
2026-07-16 21:32:58 +05:30
committed by GitHub
102 changed files with 2913 additions and 401 deletions
@@ -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
+22
View File
@@ -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
+1 -1
View File
@@ -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
+69
View File
@@ -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
View File
@@ -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)
+10 -1
View File
@@ -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
+4 -2
View File
@@ -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