Captain custom tools configured with API key authentication now send the configured key in the requested HTTP header. Existing tools begin working without needing to be recreated or reconfigured, while credentials remain protected across redirects. ## How to reproduce 1. Create a Captain custom tool using API Key authentication. 2. Configure `X-API-Key` as the header name and save the tool. 3. Invoke the tool and inspect the incoming request. 4. Before this change, the API key header is absent; after this change, the configured endpoint receives it. ## What changed The UI persists API key authentication as `name` and `key`, but the request builder also required an unused `location: header` property. The request builder now treats API key authentication as header-based, matching the only mode exposed by the UI. Custom authentication headers are also registered as sensitive with `SafeFetch`. They are retained for the configured endpoint and same-origin redirects, but stripped when a redirect crosses origins to prevent credential leakage. Factory, request, and redirect specs cover the real UI payload and both public and private-network fetch paths.
444 lines
18 KiB
Ruby
444 lines
18 KiB
Ruby
require 'rails_helper'
|
|
|
|
RSpec.describe Captain::Tools::HttpTool, type: :model do
|
|
let(:account) { create(:account) }
|
|
let(:assistant) { create(:captain_assistant, account: account) }
|
|
let(:custom_tool) { create(:captain_custom_tool, account: account) }
|
|
let(:tool) { described_class.new(assistant, custom_tool) }
|
|
let(:tool_context) { Struct.new(:state).new({}) }
|
|
|
|
describe '#active?' do
|
|
it 'returns true when custom tool is enabled' do
|
|
custom_tool.update!(enabled: true)
|
|
|
|
expect(tool.active?).to be true
|
|
end
|
|
|
|
it 'returns false when custom tool is disabled' do
|
|
custom_tool.update!(enabled: false)
|
|
|
|
expect(tool.active?).to be false
|
|
end
|
|
end
|
|
|
|
describe '#perform' do
|
|
context 'with GET request' do
|
|
before do
|
|
custom_tool.update!(
|
|
http_method: 'GET',
|
|
endpoint_url: 'https://example.com/orders/123',
|
|
response_template: nil
|
|
)
|
|
stub_request(:get, 'https://example.com/orders/123')
|
|
.to_return(status: 200, body: '{"status": "success"}')
|
|
end
|
|
|
|
it 'executes GET request and returns response body' do
|
|
result = tool.perform(tool_context)
|
|
|
|
expect(result).to eq('{"status": "success"}')
|
|
expect(WebMock).to have_requested(:get, 'https://example.com/orders/123')
|
|
end
|
|
end
|
|
|
|
context 'with POST request' do
|
|
before do
|
|
custom_tool.update!(
|
|
http_method: 'POST',
|
|
endpoint_url: 'https://example.com/orders',
|
|
request_template: '{"order_id": "{{ order_id }}"}',
|
|
response_template: nil
|
|
)
|
|
stub_request(:post, 'https://example.com/orders')
|
|
.with(body: '{"order_id": "123"}', headers: { 'Content-Type' => 'application/json' })
|
|
.to_return(status: 200, body: '{"created": true}')
|
|
end
|
|
|
|
it 'executes POST request with rendered body' do
|
|
result = tool.perform(tool_context, order_id: '123')
|
|
|
|
expect(result).to eq('{"created": true}')
|
|
expect(WebMock).to have_requested(:post, 'https://example.com/orders')
|
|
.with(body: '{"order_id": "123"}')
|
|
end
|
|
end
|
|
|
|
context 'with template variables in URL' do
|
|
before do
|
|
custom_tool.update!(
|
|
endpoint_url: 'https://example.com/orders/{{ order_id }}',
|
|
response_template: nil
|
|
)
|
|
stub_request(:get, 'https://example.com/orders/456')
|
|
.to_return(status: 200, body: '{"order_id": "456"}')
|
|
end
|
|
|
|
it 'renders URL template with params' do
|
|
result = tool.perform(tool_context, order_id: '456')
|
|
|
|
expect(result).to eq('{"order_id": "456"}')
|
|
expect(WebMock).to have_requested(:get, 'https://example.com/orders/456')
|
|
end
|
|
end
|
|
|
|
context 'with bearer token authentication' do
|
|
before do
|
|
custom_tool.update!(
|
|
auth_type: 'bearer',
|
|
auth_config: { 'token' => 'secret_bearer_token' },
|
|
endpoint_url: 'https://example.com/data',
|
|
response_template: nil
|
|
)
|
|
stub_request(:get, 'https://example.com/data')
|
|
.with(headers: { 'Authorization' => 'Bearer secret_bearer_token' })
|
|
.to_return(status: 200, body: '{"authenticated": true}')
|
|
end
|
|
|
|
it 'adds Authorization header with bearer token' do
|
|
result = tool.perform(tool_context)
|
|
|
|
expect(result).to eq('{"authenticated": true}')
|
|
expect(WebMock).to have_requested(:get, 'https://example.com/data')
|
|
.with(headers: { 'Authorization' => 'Bearer secret_bearer_token' })
|
|
end
|
|
end
|
|
|
|
context 'with basic authentication' do
|
|
before do
|
|
custom_tool.update!(
|
|
auth_type: 'basic',
|
|
auth_config: { 'username' => 'user123', 'password' => 'pass456' },
|
|
endpoint_url: 'https://example.com/data',
|
|
response_template: nil
|
|
)
|
|
stub_request(:get, 'https://example.com/data')
|
|
.with(basic_auth: %w[user123 pass456])
|
|
.to_return(status: 200, body: '{"authenticated": true}')
|
|
end
|
|
|
|
it 'adds basic auth credentials' do
|
|
result = tool.perform(tool_context)
|
|
|
|
expect(result).to eq('{"authenticated": true}')
|
|
expect(WebMock).to have_requested(:get, 'https://example.com/data')
|
|
.with(basic_auth: %w[user123 pass456])
|
|
end
|
|
end
|
|
|
|
context 'with API key authentication' do
|
|
before do
|
|
custom_tool.update!(
|
|
auth_type: 'api_key',
|
|
auth_config: { 'key' => 'api_key_123', 'name' => 'X-API-Key' },
|
|
endpoint_url: 'https://example.com/data',
|
|
response_template: nil
|
|
)
|
|
stub_request(:get, 'https://example.com/data')
|
|
.with(headers: { 'X-API-Key' => 'api_key_123' })
|
|
.to_return(status: 200, body: '{"authenticated": true}')
|
|
end
|
|
|
|
it 'adds API key header' do
|
|
result = tool.perform(tool_context)
|
|
|
|
expect(result).to eq('{"authenticated": true}')
|
|
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
|
|
before do
|
|
custom_tool.update!(
|
|
endpoint_url: 'https://example.com/orders/123',
|
|
response_template: 'Order status: {{ response.status }}, ID: {{ response.order_id }}'
|
|
)
|
|
stub_request(:get, 'https://example.com/orders/123')
|
|
.to_return(status: 200, body: '{"status": "shipped", "order_id": "123"}')
|
|
end
|
|
|
|
it 'formats response using template' do
|
|
result = tool.perform(tool_context)
|
|
|
|
expect(result).to eq('Order status: shipped, ID: 123')
|
|
end
|
|
end
|
|
|
|
context 'when handling errors' do
|
|
it 'returns generic error message on network failure' do
|
|
custom_tool.update!(endpoint_url: 'https://example.com/data')
|
|
stub_request(:get, 'https://example.com/data').to_raise(SocketError.new('Failed to connect'))
|
|
|
|
result = tool.perform(tool_context)
|
|
|
|
expect(result).to eq('An error occurred while executing the request')
|
|
end
|
|
|
|
it 'returns generic error message on timeout' do
|
|
custom_tool.update!(endpoint_url: 'https://example.com/data')
|
|
stub_request(:get, 'https://example.com/data').to_timeout
|
|
|
|
result = tool.perform(tool_context)
|
|
|
|
expect(result).to eq('An error occurred while executing the request')
|
|
end
|
|
|
|
it 'returns generic error message on HTTP 404' do
|
|
custom_tool.update!(endpoint_url: 'https://example.com/data')
|
|
stub_request(:get, 'https://example.com/data').to_return(status: 404, body: 'Not found')
|
|
|
|
result = tool.perform(tool_context)
|
|
|
|
expect(result).to eq('An error occurred while executing the request')
|
|
end
|
|
|
|
it 'returns generic error message on HTTP 500' do
|
|
custom_tool.update!(endpoint_url: 'https://example.com/data')
|
|
stub_request(:get, 'https://example.com/data').to_return(status: 500, body: 'Server error')
|
|
|
|
result = tool.perform(tool_context)
|
|
|
|
expect(result).to eq('An error occurred while executing the request')
|
|
end
|
|
|
|
it 'logs error details' do
|
|
custom_tool.update!(endpoint_url: 'https://example.com/data')
|
|
stub_request(:get, 'https://example.com/data').to_raise(StandardError.new('Test error'))
|
|
|
|
expect(Rails.logger).to receive(:error).with(/HttpTool execution error.*Test error/)
|
|
|
|
tool.perform(tool_context)
|
|
end
|
|
end
|
|
|
|
context 'when integrating with Toolable methods' do
|
|
it 'correctly integrates URL rendering, body rendering, auth, and response formatting' do
|
|
custom_tool.update!(
|
|
http_method: 'POST',
|
|
endpoint_url: 'https://example.com/users/{{ user_id }}/orders',
|
|
request_template: '{"product": "{{ product }}", "quantity": {{ quantity }}}',
|
|
auth_type: 'bearer',
|
|
auth_config: { 'token' => 'integration_token' },
|
|
response_template: 'Created order #{{ response.order_number }} for {{ response.product }}'
|
|
)
|
|
|
|
stub_request(:post, 'https://example.com/users/42/orders')
|
|
.with(
|
|
body: '{"product": "Widget", "quantity": 5}',
|
|
headers: {
|
|
'Authorization' => 'Bearer integration_token',
|
|
'Content-Type' => 'application/json'
|
|
}
|
|
)
|
|
.to_return(status: 200, body: '{"order_number": "ORD-789", "product": "Widget"}')
|
|
|
|
result = tool.perform(tool_context, user_id: '42', product: 'Widget', quantity: 5)
|
|
|
|
expect(result).to eq('Created order #ORD-789 for Widget')
|
|
end
|
|
end
|
|
|
|
context 'with metadata headers' do
|
|
let(:conversation) { create(:conversation, account: account) }
|
|
let(:contact) { conversation.contact }
|
|
let(:tool_context_with_state) do
|
|
Struct.new(:state).new({
|
|
account_id: account.id,
|
|
assistant_id: assistant.id,
|
|
conversation: {
|
|
id: conversation.id,
|
|
display_id: conversation.display_id
|
|
},
|
|
contact_inbox: {
|
|
id: conversation.contact_inbox.id,
|
|
hmac_verified: conversation.contact_inbox.hmac_verified
|
|
},
|
|
contact: {
|
|
id: contact.id,
|
|
email: contact.email,
|
|
phone_number: contact.phone_number
|
|
}
|
|
})
|
|
end
|
|
|
|
before do
|
|
custom_tool.update!(
|
|
endpoint_url: 'https://example.com/api/data',
|
|
response_template: nil
|
|
)
|
|
end
|
|
|
|
it 'includes metadata headers in GET request' do
|
|
stub_request(:get, 'https://example.com/api/data')
|
|
.with(headers: {
|
|
'X-Chatwoot-Account-Id' => account.id.to_s,
|
|
'X-Chatwoot-Assistant-Id' => assistant.id.to_s,
|
|
'X-Chatwoot-Tool-Slug' => custom_tool.slug,
|
|
'X-Chatwoot-Conversation-Id' => conversation.id.to_s,
|
|
'X-Chatwoot-Conversation-Display-Id' => conversation.display_id.to_s,
|
|
'X-Chatwoot-Contact-Inbox-Id' => conversation.contact_inbox.id.to_s,
|
|
'X-Chatwoot-Contact-Inbox-Verified' => conversation.contact_inbox.hmac_verified.to_s,
|
|
'X-Chatwoot-Contact-Id' => contact.id.to_s,
|
|
'X-Chatwoot-Contact-Email' => contact.email
|
|
})
|
|
.to_return(status: 200, body: '{"success": true}')
|
|
|
|
tool.perform(tool_context_with_state)
|
|
|
|
expect(WebMock).to have_requested(:get, 'https://example.com/api/data')
|
|
.with(headers: {
|
|
'X-Chatwoot-Account-Id' => account.id.to_s,
|
|
'X-Chatwoot-Contact-Inbox-Verified' => conversation.contact_inbox.hmac_verified.to_s,
|
|
'X-Chatwoot-Contact-Email' => contact.email
|
|
})
|
|
end
|
|
|
|
it 'includes metadata headers in POST request' do
|
|
custom_tool.update!(http_method: 'POST', request_template: '{"data": "test"}')
|
|
|
|
stub_request(:post, 'https://example.com/api/data')
|
|
.with(
|
|
body: '{"data": "test"}',
|
|
headers: {
|
|
'Content-Type' => 'application/json',
|
|
'X-Chatwoot-Account-Id' => account.id.to_s,
|
|
'X-Chatwoot-Tool-Slug' => custom_tool.slug,
|
|
'X-Chatwoot-Contact-Inbox-Verified' => conversation.contact_inbox.hmac_verified.to_s,
|
|
'X-Chatwoot-Contact-Email' => contact.email
|
|
}
|
|
)
|
|
.to_return(status: 200, body: '{"success": true}')
|
|
|
|
tool.perform(tool_context_with_state)
|
|
|
|
expect(WebMock).to have_requested(:post, 'https://example.com/api/data')
|
|
end
|
|
|
|
it 'includes metadata headers along with authentication headers' do
|
|
custom_tool.update!(
|
|
auth_type: 'bearer',
|
|
auth_config: { 'token' => 'test_token' }
|
|
)
|
|
|
|
stub_request(:get, 'https://example.com/api/data')
|
|
.with(headers: {
|
|
'Authorization' => 'Bearer test_token',
|
|
'X-Chatwoot-Account-Id' => account.id.to_s,
|
|
'X-Chatwoot-Contact-Inbox-Verified' => conversation.contact_inbox.hmac_verified.to_s,
|
|
'X-Chatwoot-Contact-Id' => contact.id.to_s
|
|
})
|
|
.to_return(status: 200, body: '{"success": true}')
|
|
|
|
tool.perform(tool_context_with_state)
|
|
|
|
expect(WebMock).to have_requested(:get, 'https://example.com/api/data')
|
|
.with(headers: {
|
|
'Authorization' => 'Bearer test_token',
|
|
'X-Chatwoot-Contact-Id' => contact.id.to_s
|
|
})
|
|
end
|
|
|
|
it 'handles missing contact in tool context' do
|
|
tool_context_no_contact = Struct.new(:state).new({
|
|
account_id: account.id,
|
|
assistant_id: assistant.id,
|
|
conversation: {
|
|
id: conversation.id,
|
|
display_id: conversation.display_id
|
|
},
|
|
contact_inbox: {
|
|
id: conversation.contact_inbox.id,
|
|
hmac_verified: conversation.contact_inbox.hmac_verified
|
|
}
|
|
})
|
|
|
|
stub_request(:get, 'https://example.com/api/data')
|
|
.with(headers: {
|
|
'X-Chatwoot-Account-Id' => account.id.to_s,
|
|
'X-Chatwoot-Conversation-Id' => conversation.id.to_s,
|
|
'X-Chatwoot-Contact-Inbox-Verified' => conversation.contact_inbox.hmac_verified.to_s
|
|
})
|
|
.to_return(status: 200, body: '{"success": true}')
|
|
|
|
tool.perform(tool_context_no_contact)
|
|
|
|
expect(WebMock).to have_requested(:get, 'https://example.com/api/data')
|
|
end
|
|
|
|
it 'defaults contact inbox verified header to false when contact inbox is missing' do
|
|
tool_context_without_contact_inbox = Struct.new(:state).new({
|
|
account_id: account.id,
|
|
assistant_id: assistant.id,
|
|
conversation: {
|
|
id: conversation.id,
|
|
display_id: conversation.display_id
|
|
},
|
|
contact: {
|
|
id: contact.id,
|
|
email: contact.email
|
|
}
|
|
})
|
|
|
|
stub_request(:get, 'https://example.com/api/data')
|
|
.with(headers: {
|
|
'X-Chatwoot-Contact-Inbox-Verified' => 'false'
|
|
})
|
|
.to_return(status: 200, body: '{"success": true}')
|
|
|
|
tool.perform(tool_context_without_contact_inbox)
|
|
|
|
expect(WebMock).to have_requested(:get, 'https://example.com/api/data')
|
|
.with(headers: { 'X-Chatwoot-Contact-Inbox-Verified' => 'false' })
|
|
end
|
|
|
|
it 'includes contact phone when present' do
|
|
contact.update!(phone_number: '+1234567890')
|
|
tool_context_with_state.state[:contact][:phone_number] = '+1234567890'
|
|
|
|
stub_request(:get, 'https://example.com/api/data')
|
|
.with(headers: {
|
|
'X-Chatwoot-Contact-Phone' => '+1234567890'
|
|
})
|
|
.to_return(status: 200, body: '{"success": true}')
|
|
|
|
tool.perform(tool_context_with_state)
|
|
|
|
expect(WebMock).to have_requested(:get, 'https://example.com/api/data')
|
|
.with(headers: { 'X-Chatwoot-Contact-Phone' => '+1234567890' })
|
|
end
|
|
|
|
it 'includes unverified contact inbox status explicitly as false' do
|
|
conversation.contact_inbox.update!(hmac_verified: false)
|
|
tool_context_with_state.state[:contact_inbox][:hmac_verified] = false
|
|
|
|
stub_request(:get, 'https://example.com/api/data')
|
|
.with(headers: {
|
|
'X-Chatwoot-Contact-Inbox-Verified' => 'false'
|
|
})
|
|
.to_return(status: 200, body: '{"success": true}')
|
|
|
|
tool.perform(tool_context_with_state)
|
|
|
|
expect(WebMock).to have_requested(:get, 'https://example.com/api/data')
|
|
.with(headers: { 'X-Chatwoot-Contact-Inbox-Verified' => 'false' })
|
|
end
|
|
end
|
|
end
|
|
end
|