feat: migrate dyte integration to cloudflare realtimekit (#14752)

Dyte is sunsetting its existing infrastructure after the Cloudflare
acquisition, so this migrates Chatwoot’s video call integration to
Cloudflare RealtimeKit.

The integration now uses Cloudflare Account ID, RealtimeKit App ID, and
a Cloudflare API token with Realtime Admin permissions. Meeting creation
and participant token generation now call Cloudflare’s RealtimeKit APIs,
while the existing Chatwoot call experience remains unchanged for agents
and customers.

This also adds setup-time credential validation, so admins get clearer
errors when the API token is invalid, the Cloudflare account or
permissions are incorrect, or the RealtimeKit App ID does not belong to
the selected account.

Fixes
https://linear.app/chatwoot/issue/PLA-176/migrate-dyte-integration-to-cloudflare-realtimekit

**How to test**

1. Go to Settings → Integrations → Cloudflare RealtimeKit.
2. Add a Cloudflare Account ID, RealtimeKit App ID, and API token with
Realtime Admin permissions.
3. Confirm the integration saves successfully with valid credentials.
4. Try invalid credentials and confirm the error identifies whether the
token, account/permissions, or app ID is wrong.
5. Start a video call from a conversation and confirm the RealtimeKit
meeting opens.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
This commit is contained in:
Muhsin Keloth
2026-06-24 11:04:08 +04:00
committed by GitHub
co-authored by Muhsin Sony Mathew
parent e8edc9ebf5
commit 74db16158d
20 changed files with 833 additions and 58 deletions
@@ -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
@@ -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
@@ -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
+1 -1
View File
@@ -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
+103 -8
View File
@@ -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
@@ -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
@@ -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
+128
View File
@@ -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