From 420be64c451060365dac86134483e532c32fc385 Mon Sep 17 00:00:00 2001 From: Pranav Date: Thu, 24 Jul 2025 13:09:06 +0400 Subject: [PATCH] chore: Automate SSL with Cloudflare (#12021) This PR adds support for automatic SSL issuance using Cloudflare when a custom domain is updated. - Introduced a cloudflare configuration. If present, the system will attempt to issue an SSL certificate via Cloudflare whenever a custom domain is added or changed. - SSL verification is handled using an HTTP challenge. - The job will store the HTTP challenge response provided by Cloudflare and serve it under the /.well-known/cf path automatically. How to test: - Create a Cloudflare zone for your domain and copy the Zone ID. - Generate a Cloudflare API token with the required SSL certificate permissions. - Set the Fallback Origin under SSL -> Custom HostName to the Chatwoot installation. - Add or update a custom domain and verify that the SSL certificate is automatically issued. --------- Co-authored-by: Sojan Jose --- app/controllers/microsoft_controller.rb | 4 +- app/models/portal.rb | 3 + config/installation_config.yml | 14 +++ config/routes.rb | 1 + ...50722083820_add_ssl_settings_to_portals.rb | 5 + db/schema.rb | 1 + .../controllers/custom_domains_controller.rb | 22 ++++ .../super_admin/app_configs_controller.rb | 2 +- .../enterprise/cloudflare_verification_job.rb | 22 ++++ .../app/models/enterprise/concerns/portal.rb | 14 +++ .../base_cloudflare_zone_service.rb | 20 ++++ .../check_custom_hostname_service.rb | 34 ++++++ .../create_custom_hostname_service.rb | 40 +++++++ .../cloudflare_verification_job_spec.rb | 47 ++++++++ .../models/enterprise/concerns/portal_spec.rb | 59 ++++++++++ .../check_custom_hostname_service_spec.rb | 111 ++++++++++++++++++ .../create_custom_hostname_service_spec.rb | 111 ++++++++++++++++++ 17 files changed, 507 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20250722083820_add_ssl_settings_to_portals.rb create mode 100644 enterprise/app/controllers/custom_domains_controller.rb create mode 100644 enterprise/app/jobs/enterprise/cloudflare_verification_job.rb create mode 100644 enterprise/app/models/enterprise/concerns/portal.rb create mode 100644 enterprise/app/services/cloudflare/base_cloudflare_zone_service.rb create mode 100644 enterprise/app/services/cloudflare/check_custom_hostname_service.rb create mode 100644 enterprise/app/services/cloudflare/create_custom_hostname_service.rb create mode 100644 spec/enterprise/jobs/enterprise/cloudflare_verification_job_spec.rb create mode 100644 spec/enterprise/models/enterprise/concerns/portal_spec.rb create mode 100644 spec/enterprise/services/cloudflare/check_custom_hostname_service_spec.rb create mode 100644 spec/enterprise/services/cloudflare/create_custom_hostname_service_spec.rb diff --git a/app/controllers/microsoft_controller.rb b/app/controllers/microsoft_controller.rb index e6a12dafa..3071eac31 100644 --- a/app/controllers/microsoft_controller.rb +++ b/app/controllers/microsoft_controller.rb @@ -2,7 +2,7 @@ class MicrosoftController < ApplicationController after_action :set_version_header def identity_association - microsoft_indentity + microsoft_identity end private @@ -11,7 +11,7 @@ class MicrosoftController < ApplicationController response.headers['Content-Length'] = { associatedApplications: [{ applicationId: @identity_json }] }.to_json.length end - def microsoft_indentity + def microsoft_identity @identity_json = GlobalConfigService.load('AZURE_APP_ID', nil) end end diff --git a/app/models/portal.rb b/app/models/portal.rb index cb87929f5..a62ba8e4d 100644 --- a/app/models/portal.rb +++ b/app/models/portal.rb @@ -12,6 +12,7 @@ # name :string not null # page_title :string # slug :string not null +# ssl_settings :jsonb not null # created_at :datetime not null # updated_at :datetime not null # account_id :integer not null @@ -69,3 +70,5 @@ class Portal < ApplicationRecord errors.add(:cofig, "in portal on #{denied_keys.join(',')} is not supported.") if denied_keys.any? end end + +Portal.include_mod_with('Concerns::Portal') diff --git a/config/installation_config.yml b/config/installation_config.yml index 2e8d94a96..fcf4ade2f 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -416,3 +416,17 @@ locked: false description: 'The redirect URI configured in your Google OAuth app' ## ------ End of Configs added for Google OAuth ------ ## + +## ------ Configs added for Cloudflare ------ ## +- name: CLOUDFLARE_API_KEY + display_title: 'Cloudflare API Key' + value: + locked: false + description: 'API key for Cloudflare account authentication' + type: secret +- name: CLOUDFLARE_ZONE_ID + display_title: 'Cloudflare Zone ID' + value: + locked: false + description: 'Zone ID for the Cloudflare domain' +## ------ End of Configs added for Cloudflare ------ ## diff --git a/config/routes.rb b/config/routes.rb index 0627c2c6d..c12aa670b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -517,6 +517,7 @@ Rails.application.routes.draw do get '.well-known/assetlinks.json' => 'android_app#assetlinks' get '.well-known/apple-app-site-association' => 'apple_app#site_association' get '.well-known/microsoft-identity-association.json' => 'microsoft#identity_association' + get '.well-known/cf-custom-hostname-challenge/:id', to: 'custom_domains#verify' # ---------------------------------------------------------------------- # Internal Monitoring Routes diff --git a/db/migrate/20250722083820_add_ssl_settings_to_portals.rb b/db/migrate/20250722083820_add_ssl_settings_to_portals.rb new file mode 100644 index 000000000..dc59bf89b --- /dev/null +++ b/db/migrate/20250722083820_add_ssl_settings_to_portals.rb @@ -0,0 +1,5 @@ +class AddSslSettingsToPortals < ActiveRecord::Migration[7.1] + def change + add_column :portals, :ssl_settings, :jsonb, default: {}, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 2ec37fec9..eb7409b8b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -943,6 +943,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_22_152516) do t.jsonb "config", default: {"allowed_locales" => ["en"]} t.boolean "archived", default: false t.bigint "channel_web_widget_id" + t.jsonb "ssl_settings", default: {}, null: false t.index ["channel_web_widget_id"], name: "index_portals_on_channel_web_widget_id" t.index ["custom_domain"], name: "index_portals_on_custom_domain", unique: true t.index ["slug"], name: "index_portals_on_slug", unique: true diff --git a/enterprise/app/controllers/custom_domains_controller.rb b/enterprise/app/controllers/custom_domains_controller.rb new file mode 100644 index 000000000..bdba869a8 --- /dev/null +++ b/enterprise/app/controllers/custom_domains_controller.rb @@ -0,0 +1,22 @@ +class CustomDomainsController < ApplicationController + def verify + challenge_id = permitted_params[:id] + + domain = request.host + portal = Portal.find_by(custom_domain: domain) + + return render plain: 'Domain not found', status: :not_found unless portal + + ssl_settings = portal.ssl_settings || {} + + return render plain: 'Challenge ID not found', status: :not_found unless ssl_settings['cf_verification_id'] == challenge_id + + render plain: ssl_settings['cf_verification_body'], status: :ok + end + + private + + def permitted_params + params.permit(:id) + end +end diff --git a/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb b/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb index 67ce3a6a9..41c8411b1 100644 --- a/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb +++ b/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb @@ -34,6 +34,6 @@ module Enterprise::SuperAdmin::AppConfigsController def internal_config_options %w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY ANALYTICS_TOKEN CLEARBIT_API_KEY DASHBOARD_SCRIPTS INACTIVE_WHATSAPP_NUMBERS BLOCKED_EMAIL_DOMAINS CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL - OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF] + OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF CLOUDFLARE_API_KEY CLOUDFLARE_ZONE_ID] end end diff --git a/enterprise/app/jobs/enterprise/cloudflare_verification_job.rb b/enterprise/app/jobs/enterprise/cloudflare_verification_job.rb new file mode 100644 index 000000000..9b4fd64bd --- /dev/null +++ b/enterprise/app/jobs/enterprise/cloudflare_verification_job.rb @@ -0,0 +1,22 @@ +class Enterprise::CloudflareVerificationJob < ApplicationJob + queue_as :default + + def perform(portal_id) + portal = Portal.find(portal_id) + return unless portal && portal.custom_domain.present? + + result = check_hostname_status(portal) + + create_hostname(portal) if result[:errors].present? + end + + private + + def create_hostname(portal) + Cloudflare::CreateCustomHostnameService.new(portal: portal).perform + end + + def check_hostname_status(portal) + Cloudflare::CheckCustomHostnameService.new(portal: portal).perform + end +end diff --git a/enterprise/app/models/enterprise/concerns/portal.rb b/enterprise/app/models/enterprise/concerns/portal.rb new file mode 100644 index 000000000..9ca76b7b9 --- /dev/null +++ b/enterprise/app/models/enterprise/concerns/portal.rb @@ -0,0 +1,14 @@ +module Enterprise::Concerns::Portal + extend ActiveSupport::Concern + + included do + after_save :enqueue_cloudflare_verification, if: :saved_change_to_custom_domain? + end + + def enqueue_cloudflare_verification + return if custom_domain.blank? + return unless ChatwootApp.chatwoot_cloud? + + Enterprise::CloudflareVerificationJob.perform_later(id) + end +end diff --git a/enterprise/app/services/cloudflare/base_cloudflare_zone_service.rb b/enterprise/app/services/cloudflare/base_cloudflare_zone_service.rb new file mode 100644 index 000000000..7fad50790 --- /dev/null +++ b/enterprise/app/services/cloudflare/base_cloudflare_zone_service.rb @@ -0,0 +1,20 @@ +class Cloudflare::BaseCloudflareZoneService + BASE_URI = 'https://api.cloudflare.com/client/v4'.freeze + + private + + def headers + { + 'Authorization' => "Bearer #{api_token}", + 'Content-Type' => 'application/json' + } + end + + def api_token + InstallationConfig.find_by(name: 'CLOUDFLARE_API_KEY')&.value + end + + def zone_id + InstallationConfig.find_by(name: 'CLOUDFLARE_ZONE_ID')&.value + end +end diff --git a/enterprise/app/services/cloudflare/check_custom_hostname_service.rb b/enterprise/app/services/cloudflare/check_custom_hostname_service.rb new file mode 100644 index 000000000..588a9c4a7 --- /dev/null +++ b/enterprise/app/services/cloudflare/check_custom_hostname_service.rb @@ -0,0 +1,34 @@ +class Cloudflare::CheckCustomHostnameService < Cloudflare::BaseCloudflareZoneService + pattr_initialize [:portal!] + + def perform + return { errors: ['Cloudflare API token or zone ID not found'] } if api_token.blank? || zone_id.blank? + return { errors: ['No custom domain found'] } if @portal.custom_domain.blank? + + response = HTTParty.get( + "#{BASE_URI}/zones/#{zone_id}/custom_hostnames?hostname=#{@portal.custom_domain}", headers: headers + ) + + return { errors: response.parsed_response['errors'] } unless response.success? + + data = response.parsed_response['result'] + + if data.present? + update_portal_ssl_settings(data.first) + return { data: data } + end + + { errors: ['Hostname is missing in Cloudflare'] } + end + + private + + def update_portal_ssl_settings(data) + verification_record = data['ownership_verification_http'] + ssl_settings = { + 'cf_verification_id': verification_record['http_url'].split('/').last, + 'cf_verification_body': verification_record['http_body'] + } + @portal.update(ssl_settings: ssl_settings) + end +end diff --git a/enterprise/app/services/cloudflare/create_custom_hostname_service.rb b/enterprise/app/services/cloudflare/create_custom_hostname_service.rb new file mode 100644 index 000000000..f1546caed --- /dev/null +++ b/enterprise/app/services/cloudflare/create_custom_hostname_service.rb @@ -0,0 +1,40 @@ +class Cloudflare::CreateCustomHostnameService < Cloudflare::BaseCloudflareZoneService + pattr_initialize [:portal!] + + def perform + return { errors: ['Cloudflare API token or zone ID not found'] } if api_token.blank? || zone_id.blank? + return { errors: ['No hostname found'] } if @portal.custom_domain.blank? + + response = create_hostname + + return { errors: response.parsed_response['errors'] } unless response.success? + + data = response.parsed_response['result'] + + if data.present? + update_portal_ssl_settings(data) + return { data: data } + end + + { errors: ['Could not create hostname'] } + end + + private + + def create_hostname + HTTParty.post( + "#{BASE_URI}/zones/#{zone_id}/custom_hostnames", + headers: headers, + body: { hostname: @portal.custom_domain }.to_json + ) + end + + def update_portal_ssl_settings(data) + verification_record = data['ownership_verification_http'] + ssl_settings = { + 'cf_verification_id': verification_record['http_url'].split('/').last, + 'cf_verification_body': verification_record['http_body'] + } + @portal.update(ssl_settings: ssl_settings) + end +end diff --git a/spec/enterprise/jobs/enterprise/cloudflare_verification_job_spec.rb b/spec/enterprise/jobs/enterprise/cloudflare_verification_job_spec.rb new file mode 100644 index 000000000..862479034 --- /dev/null +++ b/spec/enterprise/jobs/enterprise/cloudflare_verification_job_spec.rb @@ -0,0 +1,47 @@ +require 'rails_helper' + +RSpec.describe Enterprise::CloudflareVerificationJob do + let(:portal) { create(:portal, custom_domain: 'test.example.com') } + + describe '#perform' do + context 'when portal is not found' do + it 'returns early' do + expect(Portal).to receive(:find).with(0).and_return(nil) + expect(Cloudflare::CheckCustomHostnameService).not_to receive(:new) + expect(Cloudflare::CreateCustomHostnameService).not_to receive(:new) + + described_class.perform_now(0) + end + end + + context 'when portal has no custom domain' do + it 'returns early' do + portal_without_domain = create(:portal, custom_domain: nil) + expect(Cloudflare::CheckCustomHostnameService).not_to receive(:new) + expect(Cloudflare::CreateCustomHostnameService).not_to receive(:new) + + described_class.perform_now(portal_without_domain.id) + end + end + + context 'when portal exists with custom domain' do + it 'checks hostname status' do + check_service = instance_double(Cloudflare::CheckCustomHostnameService, perform: { data: 'success' }) + expect(Cloudflare::CheckCustomHostnameService).to receive(:new).with(portal: portal).and_return(check_service) + expect(Cloudflare::CreateCustomHostnameService).not_to receive(:new) + + described_class.perform_now(portal.id) + end + + it 'creates hostname when check returns errors' do + check_service = instance_double(Cloudflare::CheckCustomHostnameService, perform: { errors: ['Hostname is missing'] }) + create_service = instance_double(Cloudflare::CreateCustomHostnameService, perform: { data: 'success' }) + + expect(Cloudflare::CheckCustomHostnameService).to receive(:new).with(portal: portal).and_return(check_service) + expect(Cloudflare::CreateCustomHostnameService).to receive(:new).with(portal: portal).and_return(create_service) + + described_class.perform_now(portal.id) + end + end + end +end diff --git a/spec/enterprise/models/enterprise/concerns/portal_spec.rb b/spec/enterprise/models/enterprise/concerns/portal_spec.rb new file mode 100644 index 000000000..1dae65e86 --- /dev/null +++ b/spec/enterprise/models/enterprise/concerns/portal_spec.rb @@ -0,0 +1,59 @@ +require 'rails_helper' + +RSpec.describe Enterprise::Concerns::Portal do + describe '#enqueue_cloudflare_verification' do + let(:portal) { create(:portal, custom_domain: nil) } + + context 'when custom_domain is changed' do + context 'when on chatwoot cloud' do + before do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) + end + + it 'enqueues cloudflare verification job' do + expect do + portal.update(custom_domain: 'test.example.com') + end.to have_enqueued_job(Enterprise::CloudflareVerificationJob).with(portal.id) + end + end + + context 'when not on chatwoot cloud' do + before do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false) + end + + it 'does not enqueue cloudflare verification job' do + expect do + portal.update(custom_domain: 'test.example.com') + end.not_to have_enqueued_job(Enterprise::CloudflareVerificationJob) + end + end + end + + context 'when custom_domain is not changed' do + before do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) + portal.update(custom_domain: 'test.example.com') + end + + it 'does not enqueue cloudflare verification job' do + expect do + portal.update(name: 'New Name') + end.not_to have_enqueued_job(Enterprise::CloudflareVerificationJob) + end + end + + context 'when custom_domain is set to blank' do + before do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) + portal.update(custom_domain: 'test.example.com') + end + + it 'does not enqueue cloudflare verification job' do + expect do + portal.update(custom_domain: '') + end.not_to have_enqueued_job(Enterprise::CloudflareVerificationJob) + end + end + end +end diff --git a/spec/enterprise/services/cloudflare/check_custom_hostname_service_spec.rb b/spec/enterprise/services/cloudflare/check_custom_hostname_service_spec.rb new file mode 100644 index 000000000..d7ed80b90 --- /dev/null +++ b/spec/enterprise/services/cloudflare/check_custom_hostname_service_spec.rb @@ -0,0 +1,111 @@ +require 'rails_helper' + +RSpec.describe Cloudflare::CheckCustomHostnameService do + let(:portal) { create(:portal, custom_domain: 'test.example.com') } + let(:installation_config_api_key) { create(:installation_config, name: 'CLOUDFLARE_API_KEY', value: 'test-api-key') } + let(:installation_config_zone_id) { create(:installation_config, name: 'CLOUDFLARE_ZONE_ID', value: 'test-zone-id') } + + describe '#perform' do + context 'when API token or zone ID is not found' do + it 'returns error when API token is missing' do + installation_config_zone_id + service = described_class.new(portal: portal) + + result = service.perform + + expect(result).to eq(errors: ['Cloudflare API token or zone ID not found']) + end + + it 'returns error when zone ID is missing' do + installation_config_api_key + service = described_class.new(portal: portal) + + result = service.perform + + expect(result).to eq(errors: ['Cloudflare API token or zone ID not found']) + end + end + + context 'when no hostname ID is found' do + it 'returns error' do + installation_config_api_key + installation_config_zone_id + portal.update(custom_domain: nil) + service = described_class.new(portal: portal) + + result = service.perform + + expect(result).to eq(errors: ['No custom domain found']) + end + end + + context 'when API request is made' do + before do + installation_config_api_key + installation_config_zone_id + end + + context 'when API request fails' do + it 'returns error response' do + service = described_class.new(portal: portal) + error_response = { + 'errors' => [{ 'message' => 'API error' }] + } + + stub_request(:get, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames?hostname=test.example.com') + .to_return(status: 422, body: error_response.to_json, headers: { 'Content-Type' => 'application/json' }) + + result = service.perform + + expect(result[:errors]).to eq(error_response['errors']) + end + end + + context 'when API request succeeds but no data is returned' do + it 'returns hostname missing error' do + service = described_class.new(portal: portal) + success_response = { + 'result' => [] + } + + stub_request(:get, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames?hostname=test.example.com') + .to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' }) + + result = service.perform + + expect(result).to eq(errors: ['Hostname is missing in Cloudflare']) + end + end + + context 'when API request succeeds and data is returned' do + it 'updates portal SSL settings and returns success' do + service = described_class.new(portal: portal) + success_response = { + 'result' => [ + { + 'ownership_verification_http' => { + 'http_url' => 'http://example.com/.well-known/cf-verification/verification-id', + 'http_body' => 'verification-body' + } + } + ] + } + + stub_request(:get, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames?hostname=test.example.com') + .to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' }) + + expect(portal).to receive(:update).with( + ssl_settings: { + 'cf_verification_id': 'verification-id', + 'cf_verification_body': 'verification-body' + } + ) + + result = service.perform + + expect(result).to eq(data: success_response['result']) + end + end + end + end +end diff --git a/spec/enterprise/services/cloudflare/create_custom_hostname_service_spec.rb b/spec/enterprise/services/cloudflare/create_custom_hostname_service_spec.rb new file mode 100644 index 000000000..a3ddc8273 --- /dev/null +++ b/spec/enterprise/services/cloudflare/create_custom_hostname_service_spec.rb @@ -0,0 +1,111 @@ +require 'rails_helper' + +RSpec.describe Cloudflare::CreateCustomHostnameService do + let(:portal) { create(:portal, custom_domain: 'test.example.com') } + let(:installation_config_api_key) { create(:installation_config, name: 'CLOUDFLARE_API_KEY', value: 'test-api-key') } + let(:installation_config_zone_id) { create(:installation_config, name: 'CLOUDFLARE_ZONE_ID', value: 'test-zone-id') } + + describe '#perform' do + context 'when API token or zone ID is not found' do + it 'returns error when API token is missing' do + installation_config_zone_id + service = described_class.new(portal: portal) + + result = service.perform + + expect(result).to eq(errors: ['Cloudflare API token or zone ID not found']) + end + + it 'returns error when zone ID is missing' do + installation_config_api_key + service = described_class.new(portal: portal) + + result = service.perform + + expect(result).to eq(errors: ['Cloudflare API token or zone ID not found']) + end + end + + context 'when no hostname is found' do + it 'returns error' do + installation_config_api_key + installation_config_zone_id + portal.update(custom_domain: nil) + service = described_class.new(portal: portal) + + result = service.perform + + expect(result).to eq(errors: ['No hostname found']) + end + end + + context 'when API request is made' do + before do + installation_config_api_key + installation_config_zone_id + end + + context 'when API request fails' do + it 'returns error response' do + service = described_class.new(portal: portal) + error_response = { + 'errors' => [{ 'message' => 'API error' }] + } + + stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames') + .with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' }, + body: { hostname: 'test.example.com' }.to_json) + .to_return(status: 422, body: error_response.to_json, headers: { 'Content-Type' => 'application/json' }) + + result = service.perform + + expect(result[:errors]).to eq(error_response['errors']) + end + end + + context 'when API request succeeds but no data is returned' do + it 'returns hostname creation error' do + service = described_class.new(portal: portal) + success_response = { + 'result' => nil + } + + stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames') + .with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' }, + body: { hostname: 'test.example.com' }.to_json) + .to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' }) + + result = service.perform + + expect(result).to eq(errors: ['Could not create hostname']) + end + end + + context 'when API request succeeds and data is returned' do + it 'updates portal SSL settings and returns success' do + service = described_class.new(portal: portal) + success_response = { + 'result' => { + 'ownership_verification_http' => { + 'http_url' => 'http://example.com/.well-known/cf-verification/verification-id', + 'http_body' => 'verification-body' + } + } + } + + stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames') + .with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' }, + body: { hostname: 'test.example.com' }.to_json) + .to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' }) + + expect(portal).to receive(:update).with(ssl_settings: { 'cf_verification_id': 'verification-id', + 'cf_verification_body': 'verification-body' }) + + result = service.perform + + expect(result).to eq(data: success_response['result']) + end + end + end + end +end