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>
309 lines
12 KiB
Ruby
309 lines
12 KiB
Ruby
require 'rails_helper'
|
|
require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb'
|
|
|
|
RSpec.describe Integrations::Hook do
|
|
it_behaves_like 'reauthorizable'
|
|
|
|
context 'with validations' do
|
|
it { is_expected.to validate_presence_of(:app_id) }
|
|
it { is_expected.to validate_presence_of(:account_id) }
|
|
end
|
|
|
|
describe 'associations' do
|
|
it { is_expected.to belong_to(:account) }
|
|
end
|
|
|
|
describe 'when trying to create multiple hooks for an app' do
|
|
let(:account) { create(:account) }
|
|
|
|
context 'when app allows multiple hooks' do
|
|
it 'allows to create succesfully' do
|
|
create(:integrations_hook, account: account, app_id: 'webhook')
|
|
expect(build(:integrations_hook, account: account, app_id: 'webhook').valid?).to be true
|
|
end
|
|
end
|
|
|
|
context 'when app doesnot allow multiple hooks' do
|
|
it 'throws invalid error' do
|
|
create(:integrations_hook, account: account, app_id: 'slack')
|
|
expect(build(:integrations_hook, account: account, app_id: 'slack').valid?).to be false
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'scopes' do
|
|
let(:account) { create(:account) }
|
|
let(:inbox) { create(:inbox, account: account) }
|
|
let!(:account_hook) { create(:integrations_hook, account: account, app_id: 'webhook') }
|
|
let!(:inbox_hook) do
|
|
create(:integrations_hook,
|
|
account: account,
|
|
app_id: 'dialogflow',
|
|
inbox: inbox,
|
|
settings: {
|
|
project_id: 'test-project',
|
|
credentials: { type: 'service_account' }
|
|
})
|
|
end
|
|
|
|
it 'returns account hooks' do
|
|
expect(described_class.account_hooks.pluck(:id)).to include(account_hook.id)
|
|
expect(described_class.account_hooks.pluck(:id)).not_to include(inbox_hook.id)
|
|
end
|
|
|
|
it 'returns inbox hooks' do
|
|
expect(described_class.inbox_hooks.pluck(:id)).to include(inbox_hook.id)
|
|
expect(described_class.inbox_hooks.pluck(:id)).not_to include(account_hook.id)
|
|
end
|
|
end
|
|
|
|
describe '#crm_integration?' do
|
|
let(:account) { create(:account) }
|
|
|
|
before do
|
|
account.enable_features('crm_integration')
|
|
end
|
|
|
|
it 'returns true for leadsquared integration' do
|
|
hook = create(:integrations_hook,
|
|
account: account,
|
|
app_id: 'leadsquared',
|
|
settings: {
|
|
access_key: 'test',
|
|
secret_key: 'test',
|
|
endpoint_url: 'https://api.leadsquared.com'
|
|
})
|
|
expect(hook.send(:crm_integration?)).to be true
|
|
end
|
|
|
|
it 'returns false for non-crm integrations' do
|
|
hook = create(:integrations_hook, account: account, app_id: 'slack')
|
|
expect(hook.send(:crm_integration?)).to be false
|
|
end
|
|
end
|
|
|
|
describe '#trigger_setup_if_crm' do
|
|
let(:account) { create(:account) }
|
|
|
|
before do
|
|
account.enable_features('crm_integration')
|
|
allow(Crm::SetupJob).to receive(:perform_later)
|
|
end
|
|
|
|
context 'when integration is a CRM' do
|
|
it 'enqueues setup job' do
|
|
create(:integrations_hook,
|
|
account: account,
|
|
app_id: 'leadsquared',
|
|
settings: {
|
|
access_key: 'test',
|
|
secret_key: 'test',
|
|
endpoint_url: 'https://api.leadsquared.com'
|
|
})
|
|
expect(Crm::SetupJob).to have_received(:perform_later)
|
|
end
|
|
end
|
|
|
|
context 'when integration is not a CRM' do
|
|
it 'does not enqueue setup job' do
|
|
create(:integrations_hook, account: account, app_id: 'slack')
|
|
expect(Crm::SetupJob).not_to have_received(:perform_later)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'openai api key validation' do
|
|
let(:account) { create(:account) }
|
|
|
|
it 'prevents saving an openai hook with an invalid key' do
|
|
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(false)
|
|
|
|
hook = build(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'sk-bad' })
|
|
|
|
expect(hook).not_to be_valid
|
|
expect(hook.errors[:base]).to include(I18n.t('errors.openai.invalid_api_key'))
|
|
end
|
|
|
|
it 'prevents saving an openai hook with a blank key' do
|
|
hook = build(:integrations_hook, :openai, account: account, settings: { 'api_key' => '' })
|
|
|
|
expect(hook).not_to be_valid
|
|
end
|
|
|
|
it 'allows saving an openai hook with a valid key' do
|
|
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
|
|
|
|
hook = build(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'sk-good' })
|
|
|
|
expect(hook).to be_valid
|
|
end
|
|
|
|
it 'skips validation when an enabled openai hook is saved without changing the api key' do
|
|
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
|
|
hook = create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'sk-good', 'label_suggestion' => false })
|
|
|
|
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(false)
|
|
hook.settings['label_suggestion'] = true
|
|
|
|
expect(hook.save).to be true
|
|
end
|
|
|
|
it 'validates when a disabled openai hook is re-enabled' do
|
|
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
|
|
hook = create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'sk-bad' })
|
|
hook.update!(status: :disabled)
|
|
|
|
allow(Integrations::Openai::KeyValidator).to receive(:valid?).with('sk-bad').and_return(false)
|
|
|
|
expect(hook.update(status: :enabled)).to be false
|
|
expect(hook.errors[:base]).to include(I18n.t('errors.openai.invalid_api_key'))
|
|
end
|
|
|
|
it 'skips validation for disabled hooks' do
|
|
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
|
|
hook = create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'sk-good' })
|
|
|
|
# Even with validator returning false, disable succeeds because disabled hooks skip validation
|
|
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(false)
|
|
hook.disable
|
|
expect(hook.reload).to be_disabled
|
|
end
|
|
|
|
it 'does not validate keys for non-openai hooks' do
|
|
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(false)
|
|
|
|
hook = build(:integrations_hook, account: account, app_id: 'slack')
|
|
|
|
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
|