diff --git a/app/controllers/api/v1/accounts/integrations/dyte_controller.rb b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb index 845caab5e..7bda1c802 100644 --- a/app/controllers/api/v1/accounts/integrations/dyte_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb @@ -15,7 +15,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC end render_response( - dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user) + dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user, @message) ) end diff --git a/app/controllers/api/v1/widget/integrations/dyte_controller.rb b/app/controllers/api/v1/widget/integrations/dyte_controller.rb index 0661b4a3c..fde425b26 100644 --- a/app/controllers/api/v1/widget/integrations/dyte_controller.rb +++ b/app/controllers/api/v1/widget/integrations/dyte_controller.rb @@ -10,7 +10,8 @@ class Api::V1::Widget::Integrations::DyteController < Api::V1::Widget::BaseContr response = dyte_processor_service.add_participant_to_meeting( @message.content_attributes['data']['meeting_id'], - @conversation.contact + @conversation.contact, + @message ) render_response(response) end diff --git a/app/javascript/dashboard/components/widgets/VideoCallButton.vue b/app/javascript/dashboard/components/widgets/VideoCallButton.vue index 30984ab4b..0080c1498 100644 --- a/app/javascript/dashboard/components/widgets/VideoCallButton.vue +++ b/app/javascript/dashboard/components/widgets/VideoCallButton.vue @@ -31,12 +31,22 @@ export default { } }, methods: { + createErrorMessage(error) { + const responseError = error?.response?.data?.error; + if (typeof responseError === 'string') return responseError; + + return ( + responseError?.error?.message || + responseError?.message || + this.$t('INTEGRATION_SETTINGS.DYTE.CREATE_ERROR') + ); + }, async onClick() { this.isLoading = true; try { await DyteAPI.createAMeeting(this.conversationId); } catch (error) { - useAlert(this.$t('INTEGRATION_SETTINGS.DYTE.CREATE_ERROR')); + useAlert(this.createErrorMessage(error)); } finally { this.isLoading = false; } diff --git a/app/javascript/shared/helpers/IntegrationHelper.js b/app/javascript/shared/helpers/IntegrationHelper.js index 8d8be822b..d1dc23d01 100644 --- a/app/javascript/shared/helpers/IntegrationHelper.js +++ b/app/javascript/shared/helpers/IntegrationHelper.js @@ -1,5 +1,11 @@ -const DYTE_MEETING_LINK = 'https://app.dyte.io/v2/meeting'; +const DYTE_MEETING_LINK = 'https://examples.realtime.cloudflare.com/meeting/'; export const buildDyteURL = dyteAuthToken => { - return `${DYTE_MEETING_LINK}?authToken=${dyteAuthToken}&showSetupScreen=true&disableVideoBackground=true`; + const params = new URLSearchParams({ + authToken: dyteAuthToken, + showSetupScreen: true, + disableVideoBackground: true, + }); + + return `${DYTE_MEETING_LINK}?${params.toString()}`; }; diff --git a/app/models/integrations/hook.rb b/app/models/integrations/hook.rb index a3396951f..36515eb66 100644 --- a/app/models/integrations/hook.rb +++ b/app/models/integrations/hook.rb @@ -30,6 +30,7 @@ class Integrations::Hook < ApplicationRecord validate :validate_settings_json_schema validate :ensure_feature_enabled validate :validate_openai_api_key, if: :validate_openai_api_key? + validate :validate_cloudflare_realtimekit_credentials, if: :validate_cloudflare_realtimekit_credentials? validates :app_id, uniqueness: { scope: [:account_id], unless: -> { app.present? && app.params[:allow_multiple_hooks].present? } } # TODO: This seems to be only used for slack at the moment @@ -61,6 +62,10 @@ class Integrations::Hook < ApplicationRecord app_id == 'openai' end + def dyte? + app_id == 'dyte' + end + def notion? app_id == 'notion' end @@ -96,6 +101,7 @@ class Integrations::Hook < ApplicationRecord def validate_settings_json_schema return if app.blank? || app.params[:settings_json_schema].blank? + return if legacy_dyte_settings_unchanged? errors.add(:settings, ': Invalid settings data') unless JSONSchemer.schema(app.params[:settings_json_schema]).valid?(settings) end @@ -106,18 +112,57 @@ class Integrations::Hook < ApplicationRecord openai? && enabled? && (new_record? || openai_api_key_changed? || will_save_change_to_status?) end + def validate_cloudflare_realtimekit_credentials? + dyte? && enabled? && !legacy_dyte_settings_unchanged? && + (new_record? || cloudflare_realtimekit_credentials_changed? || will_save_change_to_status?) + end + def openai_api_key_changed? settings_api_key(settings) != settings_api_key(settings_in_database) end + def cloudflare_realtimekit_credentials_changed? + settings_cloudflare_realtimekit_credentials(settings) != settings_cloudflare_realtimekit_credentials(settings_in_database) + end + + def legacy_dyte_settings_unchanged? + dyte? && persisted? && !will_save_change_to_settings? && legacy_dyte_settings?(settings_in_database) + end + + def legacy_dyte_settings?(value) + return false if value.blank? + + %w[organization_id api_key].any? { |key| settings_value(value, key).present? } && + %w[account_id app_id api_token].none? { |key| settings_value(value, key).present? } + end + def validate_openai_api_key return if Integrations::Openai::KeyValidator.valid?(settings_api_key(settings)) errors.add(:base, I18n.t('errors.openai.invalid_api_key')) end + def validate_cloudflare_realtimekit_credentials + result = Integrations::Cloudflare::RealtimeKitCredentialsValidator.validate(*settings_cloudflare_realtimekit_credentials(settings)) + return if result.success? + + errors.add(:base, I18n.t("errors.cloudflare.realtimekit.#{result.error}")) + end + def settings_api_key(value) - value&.dig('api_key') || value&.dig(:api_key) + settings_value(value, 'api_key') + end + + def settings_cloudflare_realtimekit_credentials(value) + [ + settings_value(value, 'account_id'), + settings_value(value, 'app_id'), + settings_value(value, 'api_token') + ] + end + + def settings_value(value, key) + value&.dig(key) || value&.dig(key.to_sym) end def trigger_setup_if_crm diff --git a/config/integration/apps.yml b/config/integration/apps.yml index 9ef01ed30..5550e8ecb 100644 --- a/config/integration/apps.yml +++ b/config/integration/apps.yml @@ -215,28 +215,35 @@ dyte: 'type': 'object', 'properties': { - 'api_key': { 'type': 'string' }, - 'organization_id': { 'type': 'string' }, + 'account_id': { 'type': 'string' }, + 'app_id': { 'type': 'string' }, + 'api_token': { 'type': 'string' }, }, - 'required': ['api_key', 'organization_id'], + 'required': ['account_id', 'app_id', 'api_token'], 'additionalProperties': false, } settings_form_schema: [ { - 'label': 'Organization ID', + 'label': 'Cloudflare Account ID', 'type': 'text', - 'name': 'organization_id', + 'name': 'account_id', 'validation': 'required', }, { - 'label': 'API Key', + 'label': 'RealtimeKit App ID', 'type': 'text', - 'name': 'api_key', + 'name': 'app_id', + 'validation': 'required', + }, + { + 'label': 'Cloudflare API Token', + 'type': 'text', + 'name': 'api_token', 'validation': 'required', }, ] - visible_properties: ['organization_id'] + visible_properties: ['account_id', 'app_id'] shopify: id: shopify diff --git a/config/locales/en.yml b/config/locales/en.yml index 826894466..c3672ef7b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -113,6 +113,15 @@ en: unique: should be unique in the category and portal dyte: invalid_message_type: 'Invalid message type. Action not permitted' + realtimekit_credentials_required: 'Cloudflare RealtimeKit credentials are required. Delete the existing Dyte integration and create it again with Cloudflare RealtimeKit credentials.' + cloudflare: + realtimekit: + invalid_credentials: 'Cloudflare RealtimeKit credentials are invalid. Please check the Account ID, RealtimeKit App ID, and API token permissions.' + missing_credentials: 'Cloudflare Account ID, RealtimeKit App ID, and API token are required.' + invalid_api_token: 'Cloudflare API token is invalid or inactive. Please create a new token with Realtime Admin permissions.' + invalid_account_or_permissions: 'Cloudflare Account ID is invalid, or the API token does not have Realtime Admin access to this account.' + app_not_found: 'RealtimeKit App ID was not found in this Cloudflare account. Please check the app you selected.' + verification_failed: 'Could not verify Cloudflare RealtimeKit credentials right now. Please try again.' slack: invalid_channel_id: 'Invalid slack channel. Please try again' whatsapp: @@ -350,9 +359,9 @@ en: name: 'Dashboard Apps' description: 'Dashboard Apps allow you to create and embed applications that display user information, orders, or payment history, providing more context to your customer support agents.' dyte: - name: 'Dyte' + name: 'Cloudflare RealtimeKit' short_description: 'Start video/voice calls with customers directly from Chatwoot.' - description: 'Dyte is a product that integrates audio and video functionalities into your application. With this integration, your agents can start video/voice calls with your customers directly from Chatwoot.' + description: 'Cloudflare RealtimeKit lets your agents start video/voice calls with your customers directly from Chatwoot.' meeting_name: '%{agent_name} has started a meeting' slack: name: 'Slack' diff --git a/lib/dyte.rb b/lib/dyte.rb index 96750da08..7e448d18a 100644 --- a/lib/dyte.rb +++ b/lib/dyte.rb @@ -1,13 +1,15 @@ class Dyte - BASE_URL = 'https://api.dyte.io/v2'.freeze + BASE_URL = 'https://api.cloudflare.com/client/v4'.freeze API_KEY_HEADER = 'Authorization'.freeze - PRESET_NAME = 'group_call_host'.freeze + PRESET_NAME = 'group-call-host'.freeze + LEGACY_PRESET_NAME = 'group_call_host'.freeze - def initialize(organization_id, api_key) - @api_key = Base64.strict_encode64("#{organization_id}:#{api_key}") - @organization_id = organization_id + def initialize(account_id = nil, app_id = nil, api_token = nil) + @account_id = account_id + @app_id = app_id + @api_token = api_token - raise ArgumentError, 'Missing Credentials' if @api_key.blank? || @organization_id.blank? + raise ArgumentError, 'Missing Credentials' if @account_id.blank? || @app_id.blank? || @api_token.blank? end def create_a_meeting(title) @@ -29,24 +31,65 @@ class Dyte 'preset_name': PRESET_NAME } path = "meetings/#{meeting_id}/participants" - response = post(path, payload) + response = process_response(post(path, payload)) + return response unless preset_not_found?(response) + + payload[:preset_name] = LEGACY_PRESET_NAME + process_response(post(path, payload)) + end + + def refresh_participant_token(meeting_id, participant_id) + raise ArgumentError, 'Missing information' if meeting_id.blank? || participant_id.blank? + + path = "meetings/#{meeting_id}/participants/#{participant_id}/token" + response = post(path) + process_response(response) + end + + def fetch_participants(meeting_id) + raise ArgumentError, 'Missing information' if meeting_id.blank? + + response = get("meetings/#{meeting_id}/participants") process_response(response) end private def process_response(response) - return response.parsed_response['data'].with_indifferent_access if response.success? + return { error: response.parsed_response, error_code: response.code } unless response.success? - { error: response.parsed_response, error_code: response.code } + data = parsed_data(response) + return data.with_indifferent_access if data.is_a?(Hash) + return data.map(&:with_indifferent_access) if data.is_a?(Array) + + { error: :unexpected_response, error_code: response.code } end - def post(path, payload) + def parsed_data(response) + response.parsed_response['data'] + end + + def preset_not_found?(response) + error = response[:error] + message = error.dig('error', 'message') if error.is_a?(Hash) && error['error'].is_a?(Hash) + message ||= error['message'] if error.is_a?(Hash) + message ||= error.to_s + message.include?('No preset found') + end + + def post(path, payload = nil) HTTParty.post( - "#{BASE_URL}/#{path}", { - headers: { API_KEY_HEADER => "Basic #{@api_key}", 'Content-Type' => 'application/json' }, - body: payload.to_json - } + "#{BASE_URL}/accounts/#{@account_id}/realtime/kit/#{@app_id}/#{path}", { + headers: { API_KEY_HEADER => "Bearer #{@api_token}", 'Content-Type' => 'application/json' }, + body: payload&.to_json + }.compact + ) + end + + def get(path) + HTTParty.get( + "#{BASE_URL}/accounts/#{@account_id}/realtime/kit/#{@app_id}/#{path}", + headers: { API_KEY_HEADER => "Bearer #{@api_token}", 'Content-Type' => 'application/json' } ) end end diff --git a/lib/integrations/cloudflare/realtime_kit_credentials_validator.rb b/lib/integrations/cloudflare/realtime_kit_credentials_validator.rb new file mode 100644 index 000000000..da4266ab1 --- /dev/null +++ b/lib/integrations/cloudflare/realtime_kit_credentials_validator.rb @@ -0,0 +1,104 @@ +module Integrations::Cloudflare::RealtimeKitCredentialsValidator + Result = Data.define(:success?, :error) + + BASE_URL = 'https://api.cloudflare.com/client/v4'.freeze + TIMEOUT_SECONDS = 5 + APPS_PAGE_SIZE = 50 + + def self.valid?(account_id, app_id, api_token) + validate(account_id, app_id, api_token).success? + end + + def self.validate(account_id, app_id, api_token) + return failure(:missing_credentials) if account_id.blank? || app_id.blank? || api_token.blank? + + token_result = validate_token(api_token) + return token_result unless token_result.success? + + validate_realtimekit_app(account_id, app_id, api_token) + rescue Faraday::Error => e + Rails.logger.warn("[cloudflare-realtimekit-credentials-validator] #{e.class}: #{e.message}") + failure(:verification_failed) + end + + def self.validate_token(api_token) + response = connection.get("#{BASE_URL}/user/tokens/verify") do |req| + req.headers['Authorization'] = "Bearer #{api_token}" + end + + return failure(:verification_failed) if transient_error?(response) + + body = parse_response(response) + return success if response.status == 200 && body['success'] == true && body.dig('result', 'status') == 'active' + + failure(:invalid_api_token) + end + private_class_method :validate_token + + def self.validate_realtimekit_app(account_id, app_id, api_token) + page_no = 1 + + loop do + response = fetch_realtimekit_apps(account_id, api_token, page_no) + return failure(:verification_failed) if transient_error?(response) + return failure(:invalid_account_or_permissions) unless response.status == 200 + + body = parse_response(response) + apps = body['data'] || [] + return success if apps.any? { |app| app['id'] == app_id } + break unless next_apps_page?(body, page_no, apps) + + page_no += 1 + end + + failure(:app_not_found) + end + private_class_method :validate_realtimekit_app + + def self.fetch_realtimekit_apps(account_id, api_token, page_no) + connection.get("#{BASE_URL}/accounts/#{account_id}/realtime/kit/apps") do |req| + req.headers['Authorization'] = "Bearer #{api_token}" + req.params['page_no'] = page_no + req.params['per_page'] = APPS_PAGE_SIZE + end + end + private_class_method :fetch_realtimekit_apps + + def self.next_apps_page?(body, page_no, apps) + total_count = body.dig('paging', 'total_count') || body.dig('result_info', 'total_count') + return page_no * APPS_PAGE_SIZE < total_count.to_i if total_count.present? + + apps.size == APPS_PAGE_SIZE + end + private_class_method :next_apps_page? + + def self.connection + Faraday.new do |f| + f.options.timeout = TIMEOUT_SECONDS + f.options.open_timeout = TIMEOUT_SECONDS + end + end + private_class_method :connection + + def self.parse_response(response) + JSON.parse(response.body) + rescue JSON::ParserError + {} + end + private_class_method :parse_response + + def self.transient_error?(response) + response.status >= 500 + end + private_class_method :transient_error? + + def self.success + Result.new(true, nil) + end + private_class_method :success + + def self.failure(error) + Result.new(false, error) + end + private_class_method :failure +end diff --git a/lib/integrations/dyte/processor_service.rb b/lib/integrations/dyte/processor_service.rb index dbe429776..f74332b32 100644 --- a/lib/integrations/dyte/processor_service.rb +++ b/lib/integrations/dyte/processor_service.rb @@ -2,6 +2,8 @@ class Integrations::Dyte::ProcessorService pattr_initialize [:account!, :conversation!] def create_a_meeting(agent) + return missing_realtimekit_credentials_response if realtimekit_credentials_missing? + title = I18n.t('integration_apps.dyte.meeting_name', agent_name: agent.available_name) response = dyte_client.create_a_meeting(title) @@ -12,12 +14,31 @@ class Integrations::Dyte::ProcessorService message.push_event_data end - def add_participant_to_meeting(meeting_id, user) - dyte_client.add_participant_to_meeting(meeting_id, user.id, user.name, avatar_url(user)) + def add_participant_to_meeting(meeting_id, user, message = nil) + return missing_realtimekit_credentials_response if realtimekit_credentials_missing? + + client_id = realtimekit_client_id(user) + participant_id = realtimekit_participant_id(message, client_id) + response = participant_token_response(meeting_id, participant_id) + return response if response[:error].blank? + + response = dyte_client.add_participant_to_meeting(meeting_id, client_id, user.name, avatar_url(user)) + return store_participant_id_and_return(message, client_id, response) if response[:error].blank? + + existing_participant_token_response(meeting_id, client_id, message) || response end private + def realtimekit_client_id(user) + "#{user.class.name}:#{user.id}" + end + + def store_participant_id_and_return(message, client_id, response) + update_realtimekit_participant_id(message, client_id, response['id']) if response['id'].present? + response + end + def create_a_dyte_integration_message(meeting, title, agent) @conversation.messages.create!( { @@ -48,7 +69,65 @@ class Integrations::Dyte::ProcessorService end def dyte_client - credentials = dyte_hook.settings - @dyte_client ||= Dyte.new(credentials['organization_id'], credentials['api_key']) + @dyte_client ||= Dyte.new(*realtimekit_credentials) + end + + def participant_token_response(meeting_id, participant_id) + return { error: :participant_id_missing } if participant_id.blank? + + dyte_client.refresh_participant_token(meeting_id, participant_id) + end + + def existing_participant_token_response(meeting_id, client_id, message) + participant_id = existing_realtimekit_participant_id(meeting_id, client_id) + return if participant_id.blank? + + response = dyte_client.refresh_participant_token(meeting_id, participant_id) + update_realtimekit_participant_id(message, client_id, participant_id) if response[:error].blank? + response + end + + def existing_realtimekit_participant_id(meeting_id, client_id) + participants = dyte_client.fetch_participants(meeting_id) + return if participants.blank? || participants.is_a?(Hash) + + participants.find { |participant| participant['custom_participant_id'].to_s == client_id.to_s }&.dig('id') + end + + def realtimekit_participant_id(message, client_id) + integration_message_data(message).dig(:participants, client_id.to_s) + end + + def update_realtimekit_participant_id(message, client_id, participant_id) + return if message.blank? + + attributes = message.content_attributes.with_indifferent_access + data = (attributes[:data] || {}).with_indifferent_access + participants = (data[:participants] || {}).with_indifferent_access + participants[client_id.to_s] = participant_id + data[:participants] = participants + attributes[:data] = data + message.update_columns(content_attributes: attributes.deep_stringify_keys, updated_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + rescue StandardError => e + Rails.logger.warn("[dyte] Failed to store RealtimeKit participant ID for message #{message.id}: #{e.class}: #{e.message}") + end + + def integration_message_data(message) + return {} if message.blank? + + (message.content_attributes.with_indifferent_access[:data] || {}).with_indifferent_access + end + + def realtimekit_credentials + credentials = dyte_hook.settings.with_indifferent_access + [credentials[:account_id], credentials[:app_id], credentials[:api_token]] + end + + def realtimekit_credentials_missing? + realtimekit_credentials.any?(&:blank?) + end + + def missing_realtimekit_credentials_response + { error: I18n.t('errors.dyte.realtimekit_credentials_required') } end end diff --git a/public/dashboard/images/integrations/dyte-dark.png b/public/dashboard/images/integrations/dyte-dark.png index 42162b58a..f45987cb2 100644 Binary files a/public/dashboard/images/integrations/dyte-dark.png and b/public/dashboard/images/integrations/dyte-dark.png differ diff --git a/public/dashboard/images/integrations/dyte.png b/public/dashboard/images/integrations/dyte.png index 42162b58a..f45987cb2 100644 Binary files a/public/dashboard/images/integrations/dyte.png and b/public/dashboard/images/integrations/dyte.png differ diff --git a/spec/controllers/api/v1/accounts/integrations/dyte_controller_spec.rb b/spec/controllers/api/v1/accounts/integrations/dyte_controller_spec.rb index 3182402f3..4f401d48f 100644 --- a/spec/controllers/api/v1/accounts/integrations/dyte_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/integrations/dyte_controller_spec.rb @@ -15,6 +15,8 @@ RSpec.describe 'Dyte Integration API', type: :request do let(:unauthorized_agent) { create(:user, account: account, role: :agent) } before do + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .and_return(Integrations::Cloudflare::RealtimeKitCredentialsValidator::Result.new(true, nil)) create(:integrations_hook, :dyte, account: account) create(:inbox_member, user: agent, inbox: conversation.inbox) end @@ -39,7 +41,7 @@ RSpec.describe 'Dyte Integration API', type: :request do context 'when it is an agent with inbox access and the Dyte API is a success' do before do - stub_request(:post, 'https://api.dyte.io/v2/meetings') + stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings') .to_return( status: 200, body: { success: true, data: { id: 'meeting_id' } }.to_json, @@ -62,7 +64,7 @@ RSpec.describe 'Dyte Integration API', type: :request do context 'when it is an agent with inbox access and the Dyte API is errored' do before do - stub_request(:post, 'https://api.dyte.io/v2/meetings') + stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings') .to_return( status: 422, body: { success: false, data: { message: 'Title is required' } }.to_json, @@ -112,15 +114,15 @@ RSpec.describe 'Dyte Integration API', type: :request do context 'when it is an agent with inbox access and message_type is integrations' do before do - stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants') + stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants') .to_return( status: 200, - body: { success: true, data: { id: 'random_uuid', auth_token: 'json-web-token' } }.to_json, + body: { success: true, data: { id: 'random_uuid', token: 'json-web-token' } }.to_json, headers: headers ) end - it 'returns auth_token' do + it 'returns token' do post add_participant_to_meeting_api_v1_account_integrations_dyte_url(account), params: { message_id: integration_message.id }, headers: agent.create_new_auth_token, @@ -129,7 +131,7 @@ RSpec.describe 'Dyte Integration API', type: :request do response_body = response.parsed_body expect(response_body).to eq( { - 'id' => 'random_uuid', 'auth_token' => 'json-web-token' + 'id' => 'random_uuid', 'token' => 'json-web-token' } ) end diff --git a/spec/controllers/api/v1/accounts/integrations/hooks_controller_spec.rb b/spec/controllers/api/v1/accounts/integrations/hooks_controller_spec.rb index 5ca2633fc..c49f37611 100644 --- a/spec/controllers/api/v1/accounts/integrations/hooks_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/integrations/hooks_controller_spec.rb @@ -38,6 +38,19 @@ RSpec.describe 'Integration Hooks API', type: :request do data = response.parsed_body expect(data['app_id']).to eq params[:app_id] end + + it 'validates Cloudflare RealtimeKit credentials before creating the hook' do + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .and_return(Integrations::Cloudflare::RealtimeKitCredentialsValidator::Result.new(false, :invalid_api_token)) + + post api_v1_account_integrations_hooks_url(account_id: account.id), + params: { app_id: 'dyte', settings: { account_id: 'bad', app_id: 'bad', api_token: 'bad' } }, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['message']).to include(I18n.t('errors.cloudflare.realtimekit.invalid_api_token')) + end end end diff --git a/spec/controllers/api/v1/widget/integrations/dyte_controller_spec.rb b/spec/controllers/api/v1/widget/integrations/dyte_controller_spec.rb index 01585cee3..c5a4e1bdc 100644 --- a/spec/controllers/api/v1/widget/integrations/dyte_controller_spec.rb +++ b/spec/controllers/api/v1/widget/integrations/dyte_controller_spec.rb @@ -16,6 +16,8 @@ RSpec.describe '/api/v1/widget/integrations/dyte', type: :request do end before do + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .and_return(Integrations::Cloudflare::RealtimeKitCredentialsValidator::Result.new(true, nil)) create(:integrations_hook, :dyte, account: account) end @@ -46,15 +48,15 @@ RSpec.describe '/api/v1/widget/integrations/dyte', type: :request do context 'when message is an integration message' do before do - stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants') + stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants') .to_return( status: 200, - body: { success: true, data: { id: 'random_uuid', auth_token: 'json-web-token' } }.to_json, + body: { success: true, data: { id: 'random_uuid', token: 'json-web-token' } }.to_json, headers: { 'Content-Type' => 'application/json' } ) end - it 'returns auth_token' do + it 'returns token' do post add_participant_to_meeting_api_v1_widget_integrations_dyte_url, headers: { 'X-Auth-Token' => token }, params: { website_token: web_widget.website_token, message_id: integration_message.id }, @@ -64,7 +66,7 @@ RSpec.describe '/api/v1/widget/integrations/dyte', type: :request do response_body = response.parsed_body expect(response_body).to eq( { - 'id' => 'random_uuid', 'auth_token' => 'json-web-token' + 'id' => 'random_uuid', 'token' => 'json-web-token' } ) end diff --git a/spec/factories/integrations/hooks.rb b/spec/factories/integrations/hooks.rb index e02bc2409..02fcc51e9 100644 --- a/spec/factories/integrations/hooks.rb +++ b/spec/factories/integrations/hooks.rb @@ -14,7 +14,7 @@ FactoryBot.define do trait :dyte do app_id { 'dyte' } - settings { { api_key: 'api_key', organization_id: 'org_id' } } + settings { { account_id: 'account_id', app_id: 'app_id', api_token: 'api_token' } } end trait :google_translate do diff --git a/spec/lib/dyte_spec.rb b/spec/lib/dyte_spec.rb index 0963bfffe..2dc16ba49 100644 --- a/spec/lib/dyte_spec.rb +++ b/spec/lib/dyte_spec.rb @@ -1,17 +1,17 @@ require 'rails_helper' describe Dyte do - let(:dyte_client) { described_class.new('org_id', 'api_key') } + let(:dyte_client) { described_class.new('account_id', 'app_id', 'api_token') } let(:headers) { { 'Content-Type' => 'application/json' } } - it 'raises an exception if api_key or organization ID is absent' do + it 'raises an exception if account ID, app ID, or API token is absent' do expect { described_class.new }.to raise_error(StandardError) end context 'when create_a_meeting is called' do context 'when API response is success' do before do - stub_request(:post, 'https://api.dyte.io/v2/meetings') + stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings') .to_return( status: 200, body: { success: true, data: { id: 'meeting_id' } }.to_json, @@ -27,7 +27,7 @@ describe Dyte do context 'when API response is invalid' do before do - stub_request(:post, 'https://api.dyte.io/v2/meetings') + stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings') .to_return(status: 422, body: { message: 'Title is required' }.to_json, headers: headers) end @@ -36,9 +36,23 @@ describe Dyte do expect(response).to eq({ error: { 'message' => 'Title is required' }, error_code: 422 }) end end + + context 'when API response succeeds without data' do + before do + stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings') + .to_return(status: 200, body: { success: true, data: nil }.to_json, headers: headers) + end + + it 'returns an explicit unexpected response error' do + response = dyte_client.create_a_meeting('title_of_the_meeting') + expect(response).to eq({ error: :unexpected_response, error_code: 200 }) + end + end end context 'when add_participant_to_meeting is called' do + let(:participants_url) { 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants' } + context 'when API parameters are missing' do it 'raises an exception' do expect { dyte_client.add_participant_to_meeting }.to raise_error(StandardError) @@ -47,23 +61,26 @@ describe Dyte do context 'when API response is success' do before do - stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants') + stub_request(:post, participants_url) .to_return( status: 200, - body: { success: true, data: { id: 'random_uuid', auth_token: 'json-web-token' } }.to_json, + body: { success: true, data: { id: 'random_uuid', token: 'json-web-token' } }.to_json, headers: headers ) end it 'returns api response' do response = dyte_client.add_participant_to_meeting('m_id', 'c_id', 'name', 'https://avatar.url') - expect(response).to eq({ 'id' => 'random_uuid', 'auth_token' => 'json-web-token' }) + expect(response).to eq({ 'id' => 'random_uuid', 'token' => 'json-web-token' }) + expect(WebMock).to( + have_requested(:post, participants_url).with { |request| JSON.parse(request.body)['preset_name'] == 'group-call-host' } + ) end end context 'when API response is invalid' do before do - stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants') + stub_request(:post, participants_url) .to_return(status: 422, body: { message: 'Meeting ID is invalid' }.to_json, headers: headers) end @@ -72,5 +89,83 @@ describe Dyte do expect(response).to eq({ error: { 'message' => 'Meeting ID is invalid' }, error_code: 422 }) end end + + context 'when the default preset is not found' do + before do + stub_request(:post, participants_url) + .with { |request| JSON.parse(request.body)['preset_name'] == 'group-call-host' } + .to_return( + status: 404, + body: { success: false, error: { code: 404, message: 'ResourceNotFound: No preset found with name group-call-host' } }.to_json, + headers: headers + ) + + stub_request(:post, participants_url) + .with { |request| JSON.parse(request.body)['preset_name'] == 'group_call_host' } + .to_return( + status: 200, + body: { success: true, data: { id: 'random_uuid', token: 'json-web-token' } }.to_json, + headers: headers + ) + end + + it 'retries with the legacy Dyte preset name' do + response = dyte_client.add_participant_to_meeting('m_id', 'c_id', 'name', 'https://avatar.url') + + expect(response).to eq({ 'id' => 'random_uuid', 'token' => 'json-web-token' }) + end + end + end + + context 'when refresh_participant_token is called' do + let(:participant_token_url) do + 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants/participant_id/token' + end + + context 'when API response is success' do + before do + stub_request(:post, participant_token_url) + .to_return(status: 200, body: { success: true, data: { token: 'refreshed-json-web-token' } }.to_json, headers: headers) + end + + it 'returns a refreshed participant token' do + response = dyte_client.refresh_participant_token('m_id', 'participant_id') + + expect(response).to eq({ 'token' => 'refreshed-json-web-token' }) + end + end + + context 'when API parameters are missing' do + it 'raises an exception' do + expect { dyte_client.refresh_participant_token('m_id', nil) }.to raise_error(StandardError) + end + end + end + + context 'when fetch_participants is called' do + let(:participants_url) { 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants' } + + context 'when API response is success' do + before do + stub_request(:get, participants_url) + .to_return( + status: 200, + body: { success: true, data: [{ id: 'participant_id', custom_participant_id: 'c_id' }] }.to_json, + headers: headers + ) + end + + it 'returns participants' do + response = dyte_client.fetch_participants('m_id') + + expect(response).to eq([{ 'id' => 'participant_id', 'custom_participant_id' => 'c_id' }]) + end + end + + context 'when API parameters are missing' do + it 'raises an exception' do + expect { dyte_client.fetch_participants(nil) }.to raise_error(StandardError) + end + end end end diff --git a/spec/lib/integrations/cloudflare/realtime_kit_credentials_validator_spec.rb b/spec/lib/integrations/cloudflare/realtime_kit_credentials_validator_spec.rb new file mode 100644 index 000000000..3eeee4665 --- /dev/null +++ b/spec/lib/integrations/cloudflare/realtime_kit_credentials_validator_spec.rb @@ -0,0 +1,96 @@ +require 'rails_helper' + +RSpec.describe Integrations::Cloudflare::RealtimeKitCredentialsValidator do + let(:account_id) { 'account_id' } + let(:app_id) { 'app_id' } + let(:api_token) { 'api_token' } + let(:token_verify_url) { 'https://api.cloudflare.com/client/v4/user/tokens/verify' } + let(:apps_url) { "https://api.cloudflare.com/client/v4/accounts/#{account_id}/realtime/kit/apps" } + let(:apps_page_size) { described_class::APPS_PAGE_SIZE } + + it 'accepts an active token with access to the requested RealtimeKit app' do + stub_token_verify(status: 'active') + stub_apps_list([{ id: app_id }]) + + expect(described_class.valid?(account_id, app_id, api_token)).to be true + expect(described_class.validate(account_id, app_id, api_token).success?).to be true + end + + it 'rejects inactive tokens' do + stub_token_verify(status: 'disabled') + + expect(described_class.valid?(account_id, app_id, api_token)).to be false + expect(described_class.validate(account_id, app_id, api_token).error).to eq(:invalid_api_token) + end + + it 'rejects tokens without access to the Cloudflare account' do + stub_token_verify(status: 'active') + stub_apps_request.to_return(status: 403, body: { success: false }.to_json) + + expect(described_class.valid?(account_id, app_id, api_token)).to be false + expect(described_class.validate(account_id, app_id, api_token).error).to eq(:invalid_account_or_permissions) + end + + it 'rejects a RealtimeKit App ID that is not present in the account' do + stub_token_verify(status: 'active') + stub_apps_list([{ id: 'another_app_id' }]) + + expect(described_class.valid?(account_id, app_id, api_token)).to be false + expect(described_class.validate(account_id, app_id, api_token).error).to eq(:app_not_found) + end + + it 'accepts a RealtimeKit App ID from a later apps page' do + stub_const("#{described_class}::APPS_PAGE_SIZE", 1) + stub_token_verify(status: 'active') + stub_apps_list([{ id: 'another_app_id' }], page_no: 1, total_count: 2) + stub_apps_list([{ id: app_id }], page_no: 2, total_count: 2) + + expect(described_class.validate(account_id, app_id, api_token).success?).to be true + end + + it 'rejects blank credentials without making a network call' do + expect(described_class.valid?(nil, app_id, api_token)).to be false + expect(described_class.valid?(account_id, nil, api_token)).to be false + expect(described_class.valid?(account_id, app_id, nil)).to be false + expect(described_class.validate(nil, app_id, api_token).error).to eq(:missing_credentials) + end + + it 'rejects transient Cloudflare failures instead of saving unverified credentials' do + stub_request(:get, token_verify_url).to_return(status: 500) + stub_apps_list([{ id: app_id }]) + expect(described_class.validate(account_id, app_id, api_token).error).to eq(:verification_failed) + + stub_token_verify(status: 'active') + stub_apps_request.to_return(status: 500) + expect(described_class.validate(account_id, app_id, api_token).error).to eq(:verification_failed) + end + + it 'rejects credentials when Cloudflare cannot be reached' do + stub_request(:get, token_verify_url).to_raise(Faraday::TimeoutError) + + expect(described_class.validate(account_id, app_id, api_token).error).to eq(:verification_failed) + end + + def stub_token_verify(status:) + stub_request(:get, token_verify_url) + .with(headers: { 'Authorization' => "Bearer #{api_token}" }) + .to_return(status: 200, body: { success: true, result: { status: status } }.to_json) + end + + def stub_apps_list(apps, page_no: 1, total_count: apps.size) + stub_apps_request(page_no: page_no) + .to_return(status: 200, body: apps_response_body(apps, total_count: total_count).to_json) + end + + def stub_apps_request(page_no: 1) + stub_request(:get, apps_url) + .with( + headers: { 'Authorization' => "Bearer #{api_token}" }, + query: { page_no: page_no.to_s, per_page: apps_page_size.to_s } + ) + end + + def apps_response_body(apps, total_count: apps.size) + { success: true, data: apps.map(&:stringify_keys), paging: { total_count: total_count } } + end +end diff --git a/spec/lib/integrations/dyte/processor_service_spec.rb b/spec/lib/integrations/dyte/processor_service_spec.rb index e914ce4cf..5294c4c0d 100644 --- a/spec/lib/integrations/dyte/processor_service_spec.rb +++ b/spec/lib/integrations/dyte/processor_service_spec.rb @@ -7,15 +7,26 @@ describe Integrations::Dyte::ProcessorService do let(:conversation) { create(:conversation, account: account, status: :pending) } let(:processor) { described_class.new(account: account, conversation: conversation) } let(:agent) { create(:user, account: account, role: :agent) } + let(:dyte_settings) { { account_id: 'account_id', app_id: 'app_id', api_token: 'api_token' } } + let(:integration_message) do + create(:message, content_type: 'integrations', + content_attributes: { type: 'dyte', data: { meeting_id: 'm_id' } }, + conversation: conversation) + end before do - create(:integrations_hook, :dyte, account: account) + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .and_return(Integrations::Cloudflare::RealtimeKitCredentialsValidator::Result.new(true, nil)) + + hook = build(:integrations_hook, :dyte, account: account, settings: dyte_settings) + hook.save!(validate: false) if dyte_settings[:organization_id].present? + hook.save! unless hook.persisted? end describe '#create_a_meeting' do context 'when the API response is success' do before do - stub_request(:post, 'https://api.dyte.io/v2/meetings') + stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings') .to_return( status: 200, body: { success: true, data: { id: 'meeting_id' } }.to_json, @@ -32,7 +43,7 @@ describe Integrations::Dyte::ProcessorService do context 'when the API response is errored' do before do - stub_request(:post, 'https://api.dyte.io/v2/meetings') + stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings') .to_return( status: 422, body: { success: false, data: { message: 'Title is required' } }.to_json, @@ -46,15 +57,28 @@ describe Integrations::Dyte::ProcessorService do expect(conversation.reload.messages.count).to eq(0) end end + + context 'when the stored hook still has legacy Dyte credentials' do + let(:dyte_settings) { { organization_id: 'org_id', api_key: 'dyte_api_key' } } + + it 'returns a normal error response without creating a RealtimeKit client' do + expect(Dyte).not_to receive(:new) + + response = processor.create_a_meeting(agent) + + expect(response).to eq({ error: I18n.t('errors.dyte.realtimekit_credentials_required') }) + expect(conversation.reload.messages.count).to eq(0) + end + end end describe '#add_participant_to_meeting' do context 'when the API response is success' do before do - stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants') + stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants') .to_return( status: 200, - body: { success: true, data: { id: 'random_uuid', auth_token: 'json-web-token' } }.to_json, + body: { success: true, data: { id: 'random_uuid', token: 'json-web-token' } }.to_json, headers: headers ) end @@ -63,6 +87,117 @@ describe Integrations::Dyte::ProcessorService do response = processor.add_participant_to_meeting('m_id', agent) expect(response).not_to be_nil end + + it 'stores the RealtimeKit participant ID on the integration message' do + response = processor.add_participant_to_meeting('m_id', agent, integration_message) + + expect(response).not_to be_nil + expect(integration_message.reload.content_attributes.dig('data', 'participants', "User:#{agent.id}")).to eq('random_uuid') + end + + it 'sends a namespaced participant ID to RealtimeKit' do + processor.add_participant_to_meeting('m_id', agent, integration_message) + + expect(WebMock).to( + have_requested(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants') + .with { |request| JSON.parse(request.body)['custom_participant_id'] == "User:#{agent.id}" } + ) + end + end + + context 'when the participant ID is already stored on the integration message' do + let(:integration_message) do + create(:message, content_type: 'integrations', + content_attributes: { type: 'dyte', data: { meeting_id: 'm_id', participants: { "User:#{agent.id}" => 'participant_id' } } }, + conversation: conversation) + end + + before do + stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants/participant_id/token') + .to_return( + status: 200, + body: { success: true, data: { token: 'refreshed-json-web-token' } }.to_json, + headers: headers + ) + end + + it 'returns a refreshed participant token without creating the participant again' do + response = processor.add_participant_to_meeting('m_id', agent, integration_message) + + expect(response).to eq({ 'token' => 'refreshed-json-web-token' }) + expect(WebMock).not_to have_requested( + :post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants' + ) + end + end + + context 'when the participant exists in RealtimeKit but is not stored on the integration message' do + before do + stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants') + .to_return( + status: 422, + body: { success: false, error: 'Participant already exists' }.to_json, + headers: headers + ) + stub_request(:get, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants') + .to_return( + status: 200, + body: { success: true, data: [{ id: 'participant_id', custom_participant_id: "User:#{agent.id}" }] }.to_json, + headers: headers + ) + stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants/participant_id/token') + .to_return( + status: 200, + body: { success: true, data: { token: 'refreshed-json-web-token' } }.to_json, + headers: headers + ) + end + + it 'finds the existing participant and stores the RealtimeKit participant ID' do + response = processor.add_participant_to_meeting('m_id', agent, integration_message) + + expect(response).to eq({ 'token' => 'refreshed-json-web-token' }) + expect(integration_message.reload.content_attributes.dig('data', 'participants', "User:#{agent.id}")).to eq('participant_id') + end + end + + context 'when a contact and agent have the same database ID' do + let(:contact) { create(:contact, account: account) } + + before do + allow(contact).to receive(:id).and_return(agent.id) + stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants') + .to_return( + status: 200, + body: { success: true, data: { id: 'contact_participant_id', token: 'json-web-token' } }.to_json, + headers: headers + ) + end + + it 'stores the contact participant separately from the agent participant' do + integration_message.update!( + content_attributes: { type: 'dyte', data: { meeting_id: 'm_id', participants: { "User:#{agent.id}" => 'agent_participant_id' } } } + ) + + response = processor.add_participant_to_meeting('m_id', contact, integration_message) + + expect(response).to eq({ 'id' => 'contact_participant_id', 'token' => 'json-web-token' }) + participants = integration_message.reload.content_attributes.dig('data', 'participants') + expect(participants["User:#{agent.id}"]).to eq('agent_participant_id') + expect(participants["Contact:#{contact.id}"]).to eq('contact_participant_id') + end + end + + context 'when the stored hook still has legacy Dyte credentials' do + let(:dyte_settings) { { organization_id: 'org_id', api_key: 'dyte_api_key' } } + + it 'returns a normal error response without creating a RealtimeKit client' do + expect(Dyte).not_to receive(:new) + + response = processor.add_participant_to_meeting('m_id', agent) + + expect(response).to eq({ error: I18n.t('errors.dyte.realtimekit_credentials_required') }) + end end end end diff --git a/spec/models/integrations/hook_spec.rb b/spec/models/integrations/hook_spec.rb index 369ea8ca8..aa09dee68 100644 --- a/spec/models/integrations/hook_spec.rb +++ b/spec/models/integrations/hook_spec.rb @@ -177,4 +177,132 @@ RSpec.describe Integrations::Hook do expect(hook).to be_valid end end + + describe 'cloudflare realtimekit credential validation' do + let(:account) { create(:account) } + let(:settings) { { 'account_id' => 'account_id', 'app_id' => 'app_id', 'api_token' => 'api_token' } } + + it 'prevents saving a RealtimeKit hook with an invalid API token' do + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .and_return(cloudflare_validator_result(false, :invalid_api_token)) + + hook = build(:integrations_hook, :dyte, account: account, settings: settings) + + expect(hook).not_to be_valid + expect(hook.errors[:base]).to include(I18n.t('errors.cloudflare.realtimekit.invalid_api_token')) + end + + it 'prevents saving a RealtimeKit hook with an invalid account or missing token permissions' do + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .and_return(cloudflare_validator_result(false, :invalid_account_or_permissions)) + + hook = build(:integrations_hook, :dyte, account: account, settings: settings) + + expect(hook).not_to be_valid + expect(hook.errors[:base]).to include(I18n.t('errors.cloudflare.realtimekit.invalid_account_or_permissions')) + end + + it 'prevents saving a RealtimeKit hook when the app is not found' do + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .and_return(cloudflare_validator_result(false, :app_not_found)) + + hook = build(:integrations_hook, :dyte, account: account, settings: settings) + + expect(hook).not_to be_valid + expect(hook.errors[:base]).to include(I18n.t('errors.cloudflare.realtimekit.app_not_found')) + end + + it 'allows saving a RealtimeKit hook with valid credentials' do + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .and_return(cloudflare_validator_result(true)) + + hook = build(:integrations_hook, :dyte, account: account, settings: settings) + + expect(hook).to be_valid + end + + it 'skips validation when an enabled RealtimeKit hook is saved without changing credentials' do + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .and_return(cloudflare_validator_result(true)) + hook = create(:integrations_hook, :dyte, account: account, settings: settings) + + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .and_return(cloudflare_validator_result(false, :invalid_api_token)) + hook.settings['account_id'] = 'account_id' + + expect(hook.save).to be true + end + + it 'validates when a disabled RealtimeKit hook is re-enabled' do + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .and_return(cloudflare_validator_result(true)) + hook = create(:integrations_hook, :dyte, account: account, settings: settings) + hook.update!(status: :disabled) + + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .with('account_id', 'app_id', 'api_token') + .and_return(cloudflare_validator_result(false, :invalid_api_token)) + + expect(hook.update(status: :enabled)).to be false + expect(hook.errors[:base]).to include(I18n.t('errors.cloudflare.realtimekit.invalid_api_token')) + end + + it 'skips validation for disabled RealtimeKit hooks' do + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .and_return(cloudflare_validator_result(true)) + hook = create(:integrations_hook, :dyte, account: account, settings: settings) + + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .and_return(cloudflare_validator_result(false, :invalid_api_token)) + hook.disable + + expect(hook.reload).to be_disabled + end + + it 'allows disabling a persisted legacy Dyte hook without RealtimeKit credentials' do + hook = build(:integrations_hook, :dyte, account: account, settings: { 'organization_id' => 'org_id', 'api_key' => 'dyte_api_key' }) + hook.save!(validate: false) + + allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate) + .and_return(cloudflare_validator_result(false, :invalid_api_token)) + + expect(hook.disable).to be true + expect(hook.reload).to be_disabled + end + + it 'allows re-enabling a persisted legacy Dyte hook without RealtimeKit credential validation' do + hook = build(:integrations_hook, :dyte, account: account, settings: { 'organization_id' => 'org_id', 'api_key' => 'dyte_api_key' }) + hook.save!(validate: false) + hook.disable + + expect(Integrations::Cloudflare::RealtimeKitCredentialsValidator).not_to receive(:validate) + + expect(hook.update(status: :enabled)).to be true + expect(hook.reload).to be_enabled + end + + it 'validates settings when a legacy Dyte hook settings payload is changed' do + hook = build(:integrations_hook, :dyte, account: account, settings: { 'organization_id' => 'org_id', 'api_key' => 'dyte_api_key' }) + hook.save!(validate: false) + + hook.settings = { 'account_id' => 'account_id' } + + expect(hook).not_to be_valid + expect(hook.errors[:settings]).to include(': Invalid settings data') + end + + it 'rejects new legacy Dyte hooks' do + hook = build(:integrations_hook, :dyte, + account: account, + status: :disabled, + settings: { 'organization_id' => 'org_id', 'api_key' => 'dyte_api_key' }) + + expect(hook).not_to be_valid + expect(hook.errors[:settings]).to include(': Invalid settings data') + end + end + + def cloudflare_validator_result(success, error = nil) + Integrations::Cloudflare::RealtimeKitCredentialsValidator::Result.new(success, error) + end end