Merge branch 'develop' into feat/voice-as-twilio-capability
This commit is contained in:
@@ -26,8 +26,8 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
|
||||
expect(AccountBuilder).to have_received(:new).with(params.except(:password).merge(user_password: params[:password]))
|
||||
expect(account_builder).to have_received(:perform)
|
||||
expect(response.headers.keys).to include('access-token', 'token-type', 'client', 'expiry', 'uid')
|
||||
expect(response.body).to include('en')
|
||||
expect(response.headers.keys).not_to include('access-token', 'token-type', 'client', 'expiry', 'uid')
|
||||
expect(response.parsed_body['email']).to eq(email)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -46,8 +46,8 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(ChatwootCaptcha).to have_received(:new).with('123')
|
||||
expect(response.headers.keys).to include('access-token', 'token-type', 'client', 'expiry', 'uid')
|
||||
expect(response.body).to include('en')
|
||||
expect(response.headers.keys).not_to include('access-token', 'token-type', 'client', 'expiry', 'uid')
|
||||
expect(response.parsed_body['email']).to eq(email)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -68,6 +68,23 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an authenticated user creates a second account' do
|
||||
let(:existing_user) { create(:user, password: 'Password1!') }
|
||||
|
||||
it 'returns the full response with account_id' do
|
||||
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
|
||||
post api_v1_accounts_url,
|
||||
params: { account_name: 'Second Account', email: existing_user.email,
|
||||
user_full_name: existing_user.name, password: 'Password1!' },
|
||||
headers: existing_user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body.dig('data', 'account_id')).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when ENABLE_ACCOUNT_SIGNUP env variable is set to false' do
|
||||
it 'responds 404 on requests' do
|
||||
params = { account_name: 'test', email: email, user_full_name: user_full_name }
|
||||
@@ -105,7 +122,17 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
end
|
||||
|
||||
context 'when ENABLE_ACCOUNT_SIGNUP env variable is set to api_only' do
|
||||
it 'does not respond 404 on requests' do
|
||||
before do
|
||||
GlobalConfig.clear_cache
|
||||
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
|
||||
end
|
||||
|
||||
after do
|
||||
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
|
||||
it 'returns auth headers and full response for api_only signup' do
|
||||
params = { account_name: 'test', email: email, user_full_name: user_full_name, password: 'Password1!' }
|
||||
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'api_only' do
|
||||
post api_v1_accounts_url,
|
||||
@@ -113,6 +140,21 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.headers.keys).to include('access-token', 'token-type', 'client', 'expiry', 'uid')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when CW_API_ONLY_SERVER is true' do
|
||||
it 'returns auth headers and full response' do
|
||||
params = { account_name: 'test', email: email, user_full_name: user_full_name, password: 'Password1!' }
|
||||
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true', CW_API_ONLY_SERVER: 'true' do
|
||||
post api_v1_accounts_url,
|
||||
params: params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.headers.keys).to include('access-token', 'token-type', 'client', 'expiry', 'uid')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -39,6 +39,11 @@ RSpec.describe 'Api::V1::Accounts::UploadController', type: :request do
|
||||
let(:valid_external_url) { 'http://example.com/image.jpg' }
|
||||
|
||||
before do
|
||||
allow(Resolv).to receive(:getaddresses).and_call_original
|
||||
allow(Resolv).to receive(:getaddresses).with('example.com').and_return(['93.184.216.34'])
|
||||
allow(Resolv).to receive(:getaddresses).with('error.example.com').and_return(['93.184.216.34'])
|
||||
allow(Resolv).to receive(:getaddresses).with('nonexistent.example.com').and_return(['93.184.216.34'])
|
||||
|
||||
stub_request(:get, valid_external_url)
|
||||
.to_return(status: 200, body: File.new(Rails.root.join('spec/assets/avatar.png')), headers: { 'Content-Type' => 'image/png' })
|
||||
end
|
||||
@@ -82,7 +87,7 @@ RSpec.describe 'Api::V1::Accounts::UploadController', type: :request do
|
||||
params: { external_url: 'http://nonexistent.example.com' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
expect(response.parsed_body['error']).to eq('Failed to fetch file from URL')
|
||||
end
|
||||
|
||||
it 'handles HTTP errors' do
|
||||
@@ -96,6 +101,112 @@ RSpec.describe 'Api::V1::Accounts::UploadController', type: :request do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to start_with('Failed to fetch file from URL')
|
||||
end
|
||||
|
||||
it 'rejects oversized responses with a file-size message' do
|
||||
stub_request(:get, valid_external_url)
|
||||
.to_return(status: 200,
|
||||
body: 'x' * (41 * 1024 * 1024),
|
||||
headers: { 'Content-Type' => 'image/png' })
|
||||
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: valid_external_url }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('File exceeds the maximum allowed size')
|
||||
end
|
||||
|
||||
it 'rejects unsupported content types with a file-type message' do
|
||||
stub_request(:get, valid_external_url)
|
||||
.to_return(status: 200,
|
||||
body: '<html></html>',
|
||||
headers: { 'Content-Type' => 'text/html' })
|
||||
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: valid_external_url }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('File type not supported (only images and videos are allowed)')
|
||||
end
|
||||
|
||||
context 'with SSRF attack vectors' do
|
||||
it 'blocks requests to private IP ranges (10.x.x.x)' do
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://10.0.0.1/secret' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
|
||||
it 'blocks requests to private IP ranges (172.16.x.x)' do
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://172.16.0.1/secret' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
|
||||
it 'blocks requests to private IP ranges (192.168.x.x)' do
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://192.168.1.1/secret' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
|
||||
it 'blocks requests to loopback addresses' do
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://127.0.0.1/secret' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
|
||||
it 'blocks requests to AWS metadata service (169.254.169.254)' do
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
|
||||
it 'blocks requests to localhost' do
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://localhost/secret' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
|
||||
it 'blocks requests to .local domains' do
|
||||
allow(Resolv).to receive(:getaddresses).with('server.local').and_return(['192.168.1.100'])
|
||||
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://server.local/secret' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
|
||||
it 'blocks DNS rebinding attacks (hostname resolving to private IP)' do
|
||||
allow(Resolv).to receive(:getaddresses).with('evil.attacker.com').and_return(['10.0.0.1'])
|
||||
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://evil.attacker.com/secret' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns an error when no file or URL is provided' do
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Resend Confirmations API', type: :request do
|
||||
describe 'POST /resend_confirmation' do
|
||||
let(:email) { 'unconfirmed@example.com' }
|
||||
|
||||
context 'when the user exists and is unconfirmed' do
|
||||
before { create(:user, email: email, skip_confirmation: false) }
|
||||
|
||||
it 'sends confirmation instructions and returns 200' do
|
||||
expect do
|
||||
post '/resend_confirmation', params: { email: email }, as: :json
|
||||
end.to have_enqueued_mail(Devise::Mailer, :confirmation_instructions)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the user exists and is already confirmed' do
|
||||
before { create(:user, email: email) }
|
||||
|
||||
it 'returns 200 without sending confirmation' do
|
||||
expect do
|
||||
post '/resend_confirmation', params: { email: email }, as: :json
|
||||
end.not_to have_enqueued_mail(Devise::Mailer, :confirmation_instructions)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the email does not exist' do
|
||||
it 'returns 200 without leaking email existence' do
|
||||
post '/resend_confirmation', params: { email: 'nobody@example.com' }, as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when hCaptcha is configured' do
|
||||
before do
|
||||
create(:user, email: email, skip_confirmation: false)
|
||||
allow(ChatwootCaptcha).to receive(:new).and_return(captcha)
|
||||
end
|
||||
|
||||
context 'with a valid captcha response' do
|
||||
let(:captcha) { instance_double(ChatwootCaptcha, valid?: true) }
|
||||
|
||||
it 'sends confirmation instructions' do
|
||||
expect do
|
||||
post '/resend_confirmation',
|
||||
params: { email: email, h_captcha_client_response: 'valid-token' },
|
||||
as: :json
|
||||
end.to have_enqueued_mail(Devise::Mailer, :confirmation_instructions)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an invalid captcha response' do
|
||||
let(:captcha) { instance_double(ChatwootCaptcha, valid?: false) }
|
||||
|
||||
it 'returns 200 without sending confirmation' do
|
||||
expect do
|
||||
post '/resend_confirmation',
|
||||
params: { email: email, h_captcha_client_response: 'bad-token' },
|
||||
as: :json
|
||||
end.not_to have_enqueued_mail(Devise::Mailer, :confirmation_instructions)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -7,164 +7,111 @@ end
|
||||
|
||||
RSpec.describe Enterprise::WebsiteBrandingService do
|
||||
describe '#perform' do
|
||||
subject(:service) { test_klass.new(url) }
|
||||
subject(:service) { test_klass.new(email) }
|
||||
|
||||
let(:url) { 'https://example.com' }
|
||||
let(:api_key) { 'test-firecrawl-api-key' }
|
||||
let(:scrape_endpoint) { described_class::FIRECRAWL_SCRAPE_ENDPOINT }
|
||||
let(:fallback_html) { '<html lang="en"><head><title>Fallback</title></head><body></body></html>' }
|
||||
let(:email) { 'user@example.com' }
|
||||
let(:api_key) { 'test-context-dev-api-key' }
|
||||
let(:endpoint) { described_class::CONTEXT_DEV_ENDPOINT }
|
||||
let(:fallback_html) { '<html><head><title>Fallback</title></head><body></body></html>' }
|
||||
let(:success_response_body) do
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
json: {
|
||||
business_name: 'Acme Corp',
|
||||
language: 'en',
|
||||
industry_category: 'Technology'
|
||||
},
|
||||
branding: {
|
||||
images: { logo: 'https://example.com/logo.png', favicon: 'https://example.com/favicon.png' },
|
||||
colors: { primary: '#FF5733' }
|
||||
},
|
||||
links: [
|
||||
'https://example.com/about',
|
||||
'https://facebook.com/acmecorp',
|
||||
'https://instagram.com/acme_corp',
|
||||
'https://wa.me/1234567890',
|
||||
'https://t.me/acmecorp',
|
||||
'https://tiktok.com/@acmetok'
|
||||
]
|
||||
status: 'ok',
|
||||
code: 200,
|
||||
brand: {
|
||||
domain: 'example.com',
|
||||
title: 'Acme Corp',
|
||||
description: 'Leading tech company',
|
||||
slogan: 'We build things',
|
||||
is_nsfw: false,
|
||||
colors: [{ hex: '#FF5733', name: 'Orange Red' }],
|
||||
logos: [{ url: 'https://media.brand.dev/logo.png', type: 'icon', mode: 'light',
|
||||
colors: [{ hex: '#FF5733', name: 'Orange Red' }],
|
||||
resolution: { width: 256, height: 256, aspect_ratio: 1 } }],
|
||||
socials: [
|
||||
{ type: 'facebook', url: 'https://facebook.com/acmecorp' },
|
||||
{ type: 'instagram', url: 'https://instagram.com/acme_corp' }
|
||||
],
|
||||
industries: {
|
||||
eic: [{ industry: 'Technology', subindustry: 'Software' }]
|
||||
}
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, url).to_return(status: 200, body: fallback_html, headers: { 'content-type' => 'text/html' })
|
||||
stub_request(:get, 'https://example.com').to_return(status: 200, body: fallback_html,
|
||||
headers: { 'content-type' => 'text/html' })
|
||||
end
|
||||
|
||||
context 'when firecrawl is configured and API returns success' do
|
||||
context 'when context.dev is configured and API returns success' do
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
|
||||
stub_request(:post, scrape_endpoint)
|
||||
.with(headers: { 'Authorization' => "Bearer #{api_key}", 'Content-Type' => 'application/json' })
|
||||
create(:installation_config, name: 'CONTEXT_DEV_API_KEY', value: api_key)
|
||||
stub_request(:get, endpoint)
|
||||
.with(query: { email: email }, headers: { 'Authorization' => "Bearer #{api_key}" })
|
||||
.to_return(status: 200, body: success_response_body, headers: { 'content-type' => 'application/json' })
|
||||
end
|
||||
|
||||
it 'returns business info and branding from firecrawl' do
|
||||
it 'returns basic brand info' do
|
||||
result = service.perform
|
||||
|
||||
expect(result).to eq({
|
||||
business_name: 'Acme Corp',
|
||||
language: 'en',
|
||||
industry_category: 'Technology',
|
||||
social_handles: {
|
||||
whatsapp: '1234567890',
|
||||
line: nil,
|
||||
facebook: 'acmecorp',
|
||||
instagram: 'acme_corp',
|
||||
telegram: 'acmecorp',
|
||||
tiktok: '@acmetok'
|
||||
},
|
||||
branding: {
|
||||
favicon: 'https://example.com/favicon.png',
|
||||
primary_color: '#FF5733'
|
||||
}
|
||||
})
|
||||
expect(result).to include(domain: 'example.com', title: 'Acme Corp', description: 'Leading tech company',
|
||||
slogan: 'We build things', is_nsfw: false, email: email)
|
||||
end
|
||||
|
||||
it 'returns colors, logos, socials, and industries' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:colors]).to eq([{ hex: '#FF5733', name: 'Orange Red' }])
|
||||
expect(result[:logos].first[:url]).to eq('https://media.brand.dev/logo.png')
|
||||
expect(result[:socials]).to eq([{ type: 'facebook', url: 'https://facebook.com/acmecorp' },
|
||||
{ type: 'instagram', url: 'https://instagram.com/acme_corp' }])
|
||||
expect(result[:industries]).to eq([{ industry: 'Technology', subindustry: 'Software' }])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when firecrawl API returns an error' do
|
||||
context 'when context.dev API returns an error' do
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
|
||||
stub_request(:post, scrape_endpoint)
|
||||
.to_return(status: 422, body: '{"error": "Invalid URL"}', headers: {})
|
||||
create(:installation_config, name: 'CONTEXT_DEV_API_KEY', value: api_key)
|
||||
stub_request(:get, endpoint)
|
||||
.with(query: { email: email })
|
||||
.to_return(status: 422, body: '{"error": "FREE_EMAIL_DETECTED"}')
|
||||
end
|
||||
|
||||
it 'falls back to basic scrape' do
|
||||
result = service.perform
|
||||
expect(result[:business_name]).to eq('Fallback')
|
||||
expect(result[:industry_category]).to be_nil
|
||||
it 'returns nil' do
|
||||
expect(service.perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when firecrawl raises an exception' do
|
||||
context 'when context.dev raises an exception' do
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
|
||||
stub_request(:post, scrape_endpoint).to_raise(StandardError.new('connection refused'))
|
||||
create(:installation_config, name: 'CONTEXT_DEV_API_KEY', value: api_key)
|
||||
stub_request(:get, endpoint).with(query: { email: email }).to_raise(StandardError.new('connection refused'))
|
||||
end
|
||||
|
||||
it 'falls back to basic scrape' do
|
||||
result = service.perform
|
||||
expect(result[:business_name]).to eq('Fallback')
|
||||
it 'returns nil' do
|
||||
expect(service.perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when firecrawl is not configured' do
|
||||
it 'uses basic scrape' do
|
||||
expect(HTTParty).not_to receive(:post)
|
||||
context 'when context.dev is not configured' do
|
||||
it 'falls back to base scraper' do
|
||||
result = service.perform
|
||||
expect(result[:business_name]).to eq('Fallback')
|
||||
expect(result[:title]).to eq('Fallback')
|
||||
expect(result[:industries]).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when WhatsApp link uses api.whatsapp.com format' do
|
||||
context 'when context.dev returns empty brand' do
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
|
||||
response = {
|
||||
success: true,
|
||||
data: {
|
||||
json: { business_name: 'Acme Corp' },
|
||||
links: ['https://api.whatsapp.com/send?phone=5511999999999&text=Hello']
|
||||
}
|
||||
}.to_json
|
||||
stub_request(:post, scrape_endpoint)
|
||||
.to_return(status: 200, body: response, headers: { 'content-type' => 'application/json' })
|
||||
create(:installation_config, name: 'CONTEXT_DEV_API_KEY', value: api_key)
|
||||
stub_request(:get, endpoint)
|
||||
.with(query: { email: email })
|
||||
.to_return(status: 200, body: { status: 'ok', code: 200, brand: nil }.to_json,
|
||||
headers: { 'content-type' => 'application/json' })
|
||||
end
|
||||
|
||||
it 'extracts phone number from query param' do
|
||||
result = service.perform
|
||||
expect(result[:social_handles][:whatsapp]).to eq('5511999999999')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when WhatsApp link uses wa.me format' do
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
|
||||
response = {
|
||||
success: true,
|
||||
data: {
|
||||
json: { business_name: 'Acme Corp' },
|
||||
links: ['https://wa.me/+5511999999999']
|
||||
}
|
||||
}.to_json
|
||||
stub_request(:post, scrape_endpoint)
|
||||
.to_return(status: 200, body: response, headers: { 'content-type' => 'application/json' })
|
||||
end
|
||||
|
||||
it 'extracts phone number from path' do
|
||||
result = service.perform
|
||||
expect(result[:social_handles][:whatsapp]).to eq('5511999999999')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when links contain lookalike domains' do
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
|
||||
response = {
|
||||
success: true,
|
||||
data: {
|
||||
json: { business_name: 'Acme Corp' },
|
||||
links: ['https://notfacebook.com/page', 'https://fakeinstagram.com/user']
|
||||
}
|
||||
}.to_json
|
||||
stub_request(:post, scrape_endpoint)
|
||||
.to_return(status: 200, body: response, headers: { 'content-type' => 'application/json' })
|
||||
end
|
||||
|
||||
it 'does not match lookalike domains' do
|
||||
result = service.perform
|
||||
expect(result[:social_handles][:facebook]).to be_nil
|
||||
expect(result[:social_handles][:instagram]).to be_nil
|
||||
it 'returns nil' do
|
||||
expect(service.perform).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
require 'rails_helper'
|
||||
|
||||
# `SafeFetch.fetch` is a custom method that requires a block (it yields a Result);
|
||||
# it is NOT `Hash#fetch`, so RuboCop's autocorrect to `fetch(url, nil)` would break the API.
|
||||
# rubocop:disable Style/RedundantFetchBlock
|
||||
RSpec.describe SafeFetch do
|
||||
let(:url) { 'http://example.com/image.png' }
|
||||
|
||||
before do
|
||||
allow(Resolv).to receive(:getaddresses).and_call_original
|
||||
allow(Resolv).to receive(:getaddresses).with('example.com').and_return(['93.184.216.34'])
|
||||
end
|
||||
|
||||
describe '.fetch' do
|
||||
context 'with a valid public URL serving an image' do
|
||||
before do
|
||||
stub_request(:get, url).to_return(
|
||||
status: 200,
|
||||
body: File.new(Rails.root.join('spec/assets/avatar.png')),
|
||||
headers: { 'Content-Type' => 'image/png' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'yields a Result with tempfile, filename, and content_type' do
|
||||
described_class.fetch(url) do |result|
|
||||
expect(result.tempfile).to be_a(Tempfile)
|
||||
expect(result.filename).to eq('image.png')
|
||||
expect(result.content_type).to eq('image/png')
|
||||
expect(result.tempfile.size).to be > 0
|
||||
end
|
||||
end
|
||||
|
||||
it 'closes the tempfile after the block returns' do
|
||||
captured = nil
|
||||
described_class.fetch(url) { |result| captured = result.tempfile }
|
||||
expect(captured.closed?).to be true
|
||||
end
|
||||
|
||||
it 'closes the tempfile even when the block raises' do
|
||||
captured = nil
|
||||
expect do
|
||||
described_class.fetch(url) do |result|
|
||||
captured = result.tempfile
|
||||
raise 'boom'
|
||||
end
|
||||
end.to raise_error('boom')
|
||||
expect(captured.closed?).to be true
|
||||
end
|
||||
|
||||
it 'defaults the filename to a unique "download-<timestamp>-<hex>" when the URL has no path' do
|
||||
bare_url = 'http://example.com'
|
||||
stub_request(:get, bare_url).to_return(
|
||||
status: 200,
|
||||
body: File.new(Rails.root.join('spec/assets/avatar.png')),
|
||||
headers: { 'Content-Type' => 'image/png' }
|
||||
)
|
||||
|
||||
described_class.fetch(bare_url) do |result|
|
||||
expect(result.filename).to match(/\Adownload-\d+-[a-f0-9]{8}\z/)
|
||||
end
|
||||
end
|
||||
|
||||
it 'requires a block' do
|
||||
expect { described_class.fetch(url) }.to raise_error(ArgumentError, /block required/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with URL validation' do
|
||||
it 'raises InvalidUrlError for javascript: URLs' do
|
||||
expect { described_class.fetch('javascript:alert(1)') { nil } }
|
||||
.to raise_error(SafeFetch::InvalidUrlError)
|
||||
end
|
||||
|
||||
it 'raises InvalidUrlError for mailto: URLs' do
|
||||
expect { described_class.fetch('mailto:test@example.com') { nil } }
|
||||
.to raise_error(SafeFetch::InvalidUrlError)
|
||||
end
|
||||
|
||||
it 'raises InvalidUrlError for data: URLs' do
|
||||
expect { described_class.fetch('data:text/html,<x>') { nil } }
|
||||
.to raise_error(SafeFetch::InvalidUrlError)
|
||||
end
|
||||
|
||||
it 'raises InvalidUrlError for ftp: URLs' do
|
||||
expect { described_class.fetch('ftp://example.com/file') { nil } }
|
||||
.to raise_error(SafeFetch::InvalidUrlError)
|
||||
end
|
||||
|
||||
it 'raises InvalidUrlError for malformed URLs' do
|
||||
expect { described_class.fetch('not_a_url') { nil } }
|
||||
.to raise_error(SafeFetch::InvalidUrlError)
|
||||
end
|
||||
|
||||
it 'raises InvalidUrlError when host is missing' do
|
||||
expect { described_class.fetch('http:///path') { nil } }
|
||||
.to raise_error(SafeFetch::InvalidUrlError, /missing host/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with SSRF protection (integration with ssrf_filter)' do
|
||||
it 'raises UnsafeUrlError for private IP literals (10.x.x.x)' do
|
||||
expect { described_class.fetch('http://10.0.0.1/secret') { nil } }
|
||||
.to raise_error(SafeFetch::UnsafeUrlError)
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError for loopback addresses' do
|
||||
expect { described_class.fetch('http://127.0.0.1/secret') { nil } }
|
||||
.to raise_error(SafeFetch::UnsafeUrlError)
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError for AWS metadata IP (169.254.169.254)' do
|
||||
expect { described_class.fetch('http://169.254.169.254/latest/meta-data/') { nil } }
|
||||
.to raise_error(SafeFetch::UnsafeUrlError)
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError when hostname resolves to a private IP (DNS rebinding)' do
|
||||
allow(Resolv).to receive(:getaddresses).with('evil.example.com').and_return(['10.0.0.1'])
|
||||
expect { described_class.fetch('http://evil.example.com/secret') { nil } }
|
||||
.to raise_error(SafeFetch::UnsafeUrlError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with content-type allowlist' do
|
||||
it 'rejects text/html responses' do
|
||||
stub_request(:get, url).to_return(
|
||||
status: 200,
|
||||
body: '<html></html>',
|
||||
headers: { 'Content-Type' => 'text/html' }
|
||||
)
|
||||
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(SafeFetch::UnsupportedContentTypeError)
|
||||
end
|
||||
|
||||
it 'rejects application/octet-stream responses' do
|
||||
stub_request(:get, url).to_return(
|
||||
status: 200,
|
||||
body: 'x',
|
||||
headers: { 'Content-Type' => 'application/octet-stream' }
|
||||
)
|
||||
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(SafeFetch::UnsupportedContentTypeError)
|
||||
end
|
||||
|
||||
it 'allows video/mp4 responses' do
|
||||
stub_request(:get, url).to_return(
|
||||
status: 200,
|
||||
body: File.new(Rails.root.join('spec/assets/avatar.png')),
|
||||
headers: { 'Content-Type' => 'video/mp4' }
|
||||
)
|
||||
|
||||
expect { described_class.fetch(url) { nil } }.not_to raise_error
|
||||
end
|
||||
|
||||
it 'strips charset/boundary parameters before comparing' do
|
||||
stub_request(:get, url).to_return(
|
||||
status: 200,
|
||||
body: 'x',
|
||||
headers: { 'Content-Type' => 'image/png; charset=binary' }
|
||||
)
|
||||
|
||||
expect { described_class.fetch(url) { nil } }.not_to raise_error
|
||||
end
|
||||
|
||||
it 'rejects when the content-type header is missing' do
|
||||
stub_request(:get, url).to_return(status: 200, body: 'x', headers: {})
|
||||
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(SafeFetch::UnsupportedContentTypeError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with body size cap' do
|
||||
it 'honours a custom max_bytes argument' do
|
||||
stub_request(:get, url).to_return(
|
||||
status: 200,
|
||||
body: 'xxxxx',
|
||||
headers: { 'Content-Type' => 'image/png' }
|
||||
)
|
||||
|
||||
expect { described_class.fetch(url, max_bytes: 2) { nil } }
|
||||
.to raise_error(SafeFetch::FileTooLargeError)
|
||||
end
|
||||
|
||||
it 'reads the default cap from GlobalConfigService MAXIMUM_FILE_UPLOAD_SIZE (matching Attachment#validate_file_size)' do
|
||||
allow(GlobalConfigService).to receive(:load).and_call_original
|
||||
allow(GlobalConfigService).to receive(:load).with('MAXIMUM_FILE_UPLOAD_SIZE', 40).and_return('1')
|
||||
|
||||
oversize = 'x' * (1.megabyte + 1)
|
||||
stub_request(:get, url).to_return(
|
||||
status: 200,
|
||||
body: oversize,
|
||||
headers: { 'Content-Type' => 'image/png' }
|
||||
)
|
||||
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(SafeFetch::FileTooLargeError)
|
||||
end
|
||||
|
||||
it 'falls back to 40 MB when GlobalConfigService returns a non-positive value' do
|
||||
allow(GlobalConfigService).to receive(:load).and_call_original
|
||||
allow(GlobalConfigService).to receive(:load).with('MAXIMUM_FILE_UPLOAD_SIZE', 40).and_return('-10')
|
||||
|
||||
# 1 MB body should pass under the 40 MB fallback
|
||||
stub_request(:get, url).to_return(
|
||||
status: 200,
|
||||
body: 'x' * 1.megabyte,
|
||||
headers: { 'Content-Type' => 'image/png' }
|
||||
)
|
||||
|
||||
expect { described_class.fetch(url) { nil } }.not_to raise_error
|
||||
end
|
||||
|
||||
it 'allows uploads between the old hardcoded 10 MB and the configured limit (regression check)' do
|
||||
# Default config is 40 MB; a 15 MB upload must succeed.
|
||||
# This is the exact regression scenario: with the old hardcoded 10 MB cap,
|
||||
# this would have failed even though direct file uploads of the same size succeed.
|
||||
allow(GlobalConfigService).to receive(:load).and_call_original
|
||||
allow(GlobalConfigService).to receive(:load).with('MAXIMUM_FILE_UPLOAD_SIZE', 40).and_return('40')
|
||||
|
||||
stub_request(:get, url).to_return(
|
||||
status: 200,
|
||||
body: 'x' * (15 * 1024 * 1024),
|
||||
headers: { 'Content-Type' => 'image/png' }
|
||||
)
|
||||
|
||||
expect { described_class.fetch(url) { nil } }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'with network failures' do
|
||||
it 'maps Net::ReadTimeout to FetchError' do
|
||||
stub_request(:get, url).to_raise(Net::ReadTimeout)
|
||||
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(SafeFetch::FetchError)
|
||||
end
|
||||
|
||||
it 'maps SocketError to FetchError' do
|
||||
stub_request(:get, url).to_raise(SocketError.new('connection refused'))
|
||||
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(SafeFetch::FetchError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with non-2xx upstream responses' do
|
||||
it 'raises HttpError with the status code in the message' do
|
||||
stub_request(:get, url).to_return(status: 404, body: '', headers: {})
|
||||
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(SafeFetch::HttpError, /404/)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable Style/RedundantFetchBlock
|
||||
@@ -2,6 +2,7 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe WebsiteBrandingService do
|
||||
describe '#perform' do
|
||||
let(:email) { 'user@example.com' }
|
||||
let(:url) { 'https://example.com' }
|
||||
let(:html_body) do
|
||||
<<~HTML
|
||||
@@ -9,12 +10,21 @@ RSpec.describe WebsiteBrandingService do
|
||||
<head>
|
||||
<title>Acme Corp | Home</title>
|
||||
<meta property="og:site_name" content="Acme Corp" />
|
||||
<meta property="og:image" content="https://example.com/og-image.png" />
|
||||
<meta name="theme-color" content="#FF5733" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="shortcut icon" href="/favicon-32.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<header><a href="/">Home</a></header>
|
||||
<header>
|
||||
<a href="https://facebook.com/acmecorp">Facebook</a>
|
||||
<a href="https://instagram.com/acme_corp">Instagram</a>
|
||||
</header>
|
||||
<nav>
|
||||
<a href="https://facebook.com/acmecorp">FB</a>
|
||||
<a href="https://t.me/acmecorp">TG</a>
|
||||
</nav>
|
||||
<footer>
|
||||
<a href="https://facebook.com/acmecorp">Facebook</a>
|
||||
<a href="https://instagram.com/acme_corp">Instagram</a>
|
||||
@@ -31,26 +41,19 @@ RSpec.describe WebsiteBrandingService do
|
||||
stub_request(:get, url).to_return(status: 200, body: html_body, headers: { 'content-type' => 'text/html' })
|
||||
end
|
||||
|
||||
it 'extracts business info, branding, and social handles' do
|
||||
result = described_class.new(url).perform
|
||||
it 'extracts basic brand info' do
|
||||
result = described_class.new(email).perform
|
||||
|
||||
expect(result).to eq({
|
||||
business_name: 'Acme Corp',
|
||||
language: 'en',
|
||||
industry_category: nil,
|
||||
social_handles: {
|
||||
whatsapp: '1234567890',
|
||||
line: nil,
|
||||
facebook: 'acmecorp',
|
||||
instagram: 'acme_corp',
|
||||
telegram: 'acmecorp',
|
||||
tiktok: '@acmetok'
|
||||
},
|
||||
branding: {
|
||||
favicon: 'https://example.com/favicon.ico',
|
||||
primary_color: '#FF5733'
|
||||
}
|
||||
})
|
||||
expect(result).to include(domain: 'example.com', title: 'Acme Corp', email: email,
|
||||
description: nil, slogan: nil, is_nsfw: false, industries: [])
|
||||
end
|
||||
|
||||
it 'extracts colors, logos, and socials' do
|
||||
result = described_class.new(email).perform
|
||||
|
||||
expect(result[:colors]).to eq([{ hex: '#FF5733', name: nil }])
|
||||
expect(result[:logos].first[:url]).to eq('https://example.com/favicon.ico')
|
||||
expect(result[:socials].map { |s| s[:type] }).to contain_exactly('facebook', 'instagram', 'whatsapp', 'telegram', 'tiktok')
|
||||
end
|
||||
|
||||
context 'when og:site_name is missing' do
|
||||
@@ -64,17 +67,18 @@ RSpec.describe WebsiteBrandingService do
|
||||
end
|
||||
|
||||
it 'falls back to the first segment of the title' do
|
||||
result = described_class.new(url).perform
|
||||
expect(result[:business_name]).to eq('Mon Entreprise')
|
||||
expect(result[:language]).to eq('fr')
|
||||
result = described_class.new(email).perform
|
||||
expect(result[:title]).to eq('Mon Entreprise')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the page fails to load' do
|
||||
before { stub_request(:get, url).to_return(status: 500, body: '') }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(described_class.new(url).perform).to be_nil
|
||||
it 'returns nil and sets http_status' do
|
||||
service = described_class.new(email)
|
||||
expect(service.perform).to be_nil
|
||||
expect(service.http_status).to eq(500)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -83,18 +87,7 @@ RSpec.describe WebsiteBrandingService do
|
||||
|
||||
it 'logs the error and returns nil' do
|
||||
expect(Rails.logger).to receive(:error).with(/connection refused/)
|
||||
expect(described_class.new(url).perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when URL has no scheme' do
|
||||
before do
|
||||
stub_request(:get, 'https://example.com').to_return(status: 200, body: html_body, headers: { 'content-type' => 'text/html' })
|
||||
end
|
||||
|
||||
it 'prepends https://' do
|
||||
result = described_class.new('example.com').perform
|
||||
expect(result[:business_name]).to eq('Acme Corp')
|
||||
expect(described_class.new(email).perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -109,8 +102,9 @@ RSpec.describe WebsiteBrandingService do
|
||||
end
|
||||
|
||||
it 'extracts phone from query param' do
|
||||
result = described_class.new(url).perform
|
||||
expect(result[:social_handles][:whatsapp]).to eq('5511999999999')
|
||||
result = described_class.new(email).perform
|
||||
whatsapp = result[:socials].find { |s| s[:type] == 'whatsapp' }
|
||||
expect(whatsapp[:url]).to eq('https://wa.me/5511999999999')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -128,9 +122,10 @@ RSpec.describe WebsiteBrandingService do
|
||||
end
|
||||
|
||||
it 'does not match lookalike domains' do
|
||||
result = described_class.new(url).perform
|
||||
expect(result[:social_handles][:facebook]).to be_nil
|
||||
expect(result[:social_handles][:instagram]).to be_nil
|
||||
result = described_class.new(email).perform
|
||||
types = result[:socials].map { |s| s[:type] }
|
||||
expect(types).not_to include('facebook')
|
||||
expect(types).not_to include('instagram')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -148,8 +143,8 @@ RSpec.describe WebsiteBrandingService do
|
||||
end
|
||||
|
||||
it 'resolves the relative favicon URL' do
|
||||
result = described_class.new(url).perform
|
||||
expect(result[:branding][:favicon]).to eq('https://example.com/favicon.ico')
|
||||
result = described_class.new(email).perform
|
||||
expect(result[:logos].first[:url]).to eq('https://example.com/favicon.ico')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user