diff --git a/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb b/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb index 32e672779..fa5bd3b8a 100644 --- a/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb +++ b/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb @@ -44,8 +44,23 @@ module Api::V1::Accounts::Concerns::WhatsappHealthManagement render json: { error: e.message }, status: :unprocessable_entity end + def whatsapp_business_management_token + Whatsapp::BusinessManagementTokenService.new(whatsapp_channel).update!(params.require(:business_management_token)) + + head :no_content + rescue ArgumentError, ActiveRecord::RecordInvalid => e + render json: { error: e.message, message: e.message }, status: :unprocessable_entity + end + private + def whatsapp_channel + channel = @inbox.channel + raise ActiveRecord::RecordNotFound unless channel.is_a?(Channel::Whatsapp) + + channel + end + def validate_whatsapp_cloud_channel return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud' diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index 3c1d17fc8..21fd64f8d 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -33,6 +33,15 @@ class Inboxes extends CacheEnabledApiClient { return axios.post(`${this.url}/${inboxId}/sync_templates`); } + updateWhatsappBusinessManagementToken(inboxId, businessManagementToken) { + return axios.put( + `${this.url}/${inboxId}/whatsapp_business_management_token`, + { + business_management_token: businessManagementToken, + } + ); + } + createCSATTemplate(inboxId, template) { return axios.post(`${this.url}/${inboxId}/csat_template`, { template, diff --git a/app/javascript/dashboard/api/specs/inboxes.spec.js b/app/javascript/dashboard/api/specs/inboxes.spec.js index 64ba44aea..19aad6b3a 100644 --- a/app/javascript/dashboard/api/specs/inboxes.spec.js +++ b/app/javascript/dashboard/api/specs/inboxes.spec.js @@ -19,6 +19,7 @@ describe('#InboxesAPI', () => { const originalAxios = window.axios; const axiosMock = { post: vi.fn(() => Promise.resolve()), + put: vi.fn(() => Promise.resolve()), get: vi.fn(() => Promise.resolve()), patch: vi.fn(() => Promise.resolve()), delete: vi.fn(() => Promise.resolve()), @@ -48,5 +49,13 @@ describe('#InboxesAPI', () => { '/api/v1/inboxes/2/sync_templates' ); }); + + it('#updateWhatsappBusinessManagementToken', () => { + inboxesAPI.updateWhatsappBusinessManagementToken(2, 'business-token'); + expect(axiosMock.put).toHaveBeenCalledWith( + '/api/v1/inboxes/2/whatsapp_business_management_token', + { business_management_token: 'business-token' } + ); + }); }); }); diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 01f627d7c..20d5b5dd9 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -894,6 +894,16 @@ "WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key", "WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here", "WHATSAPP_SECTION_UPDATE_BUTTON": "Update", + "WHATSAPP_BUSINESS_MANAGEMENT_TOKEN_ADD_TITLE": "Add Business Management Token", + "WHATSAPP_BUSINESS_MANAGEMENT_TOKEN_REPLACE_TITLE": "Replace Business Management Token", + "WHATSAPP_BUSINESS_MANAGEMENT_TOKEN_CONFIGURED": "Business management token configured", + "WHATSAPP_BUSINESS_MANAGEMENT_TOKEN_UPDATE_SUBHEADER": "Provide a token with access to this WhatsApp Business Account when Chatwoot cannot retrieve its message templates. Chatwoot uses this token only for template synchronization.", + "WHATSAPP_BUSINESS_MANAGEMENT_TOKEN_UPDATE_PLACEHOLDER": "Enter the business management token", + "WHATSAPP_BUSINESS_MANAGEMENT_TOKEN_ADD_BUTTON": "Add", + "WHATSAPP_BUSINESS_MANAGEMENT_TOKEN_REPLACE_BUTTON": "Replace", + "WHATSAPP_BUSINESS_MANAGEMENT_TOKEN_UPDATE_SUCCESS": "WhatsApp business management token updated successfully.", + "WHATSAPP_BUSINESS_MANAGEMENT_TOKEN_UPDATE_ERROR": "Could not update the business management token. Verify its WhatsApp Business Management permission and account access, then try again.", + "WHATSAPP_BUSINESS_MANAGEMENT_TOKEN_GUIDE_LINK": "Learn how to generate a WhatsApp Business Management token", "WHATSAPP_EMBEDDED_SIGNUP_TITLE": "WhatsApp Embedded Signup", "WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER": "This inbox is connected through WhatsApp embedded signup.", "WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION": "You can reconfigure this inbox to update your WhatsApp Business settings.", diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue index 1e9e2f704..2a394e48a 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue @@ -15,6 +15,7 @@ import { required } from '@vuelidate/validators'; import NextButton from 'dashboard/components-next/button/Button.vue'; import TextArea from 'next/textarea/TextArea.vue'; import { sanitizeAllowedDomains } from 'dashboard/helper/URLHelper'; +import WhatsappBusinessManagementToken from './WhatsappBusinessManagementToken.vue'; export default { components: { @@ -25,6 +26,7 @@ export default { SmtpSettings, NextButton, TextArea, + WhatsappBusinessManagementToken, }, mixins: [inboxMixin], props: { @@ -469,6 +471,14 @@ export default { + +
+
+ + {{ + t( + 'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_BUSINESS_MANAGEMENT_TOKEN_CONFIGURED' + ) + }} +
+
+ + + {{ actionLabel }} + +
+ + {{ + t( + 'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_BUSINESS_MANAGEMENT_TOKEN_GUIDE_LINK' + ) + }} + +
+
+ diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb index b7557ccb2..856eb8441 100644 --- a/app/models/channel/whatsapp.rb +++ b/app/models/channel/whatsapp.rb @@ -3,6 +3,7 @@ # Table name: channel_whatsapp # # id :bigint not null, primary key +# business_management_token :text # message_templates :jsonb # message_templates_last_updated :datetime # phone_number :string not null @@ -27,6 +28,7 @@ class Channel::Whatsapp < ApplicationRecord self.table_name = 'channel_whatsapp' EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze + encrypts :business_management_token if Chatwoot.encryption_configured? # default at the moment is 360dialog lets change later. PROVIDERS = %w[default whatsapp_cloud].freeze @@ -74,6 +76,16 @@ class Channel::Whatsapp < ApplicationRecord end end + def template_access_token + return provider_config['api_key'] unless ChatwootApp.chatwoot_cloud? && provider_config['source'] == 'embedded_signup' + + business_management_token.presence || provider_config['api_key'] + end + + def serializable_hash(options = nil) + super.except('business_management_token') + end + # Enables voice: turns calling on at Meta (idempotent), then re-registers webhooks # with the in-memory calling_enabled flag so the `calls` field is subscribed. The # flag is persisted only after registration succeeds, so a webhook failure can't diff --git a/app/policies/inbox_policy.rb b/app/policies/inbox_policy.rb index 9c74c35e7..871761b0d 100644 --- a/app/policies/inbox_policy.rb +++ b/app/policies/inbox_policy.rb @@ -62,6 +62,10 @@ class InboxPolicy < ApplicationPolicy @account_user.administrator? end + def whatsapp_business_management_token? + @account_user.administrator? + end + def health? @account_user.administrator? end diff --git a/app/services/whatsapp/business_management_token_service.rb b/app/services/whatsapp/business_management_token_service.rb new file mode 100644 index 000000000..0fab45170 --- /dev/null +++ b/app/services/whatsapp/business_management_token_service.rb @@ -0,0 +1,27 @@ +class Whatsapp::BusinessManagementTokenService + def initialize(channel) + @channel = channel + end + + def update!(business_management_token) + validate_channel! + raise ArgumentError, 'Business management token is required' if business_management_token.blank? + + Whatsapp::BusinessManagementTokenValidationService.new( + business_management_token, + @channel.provider_config['business_account_id'] + ).perform + + @channel.business_management_token = business_management_token + @channel.save!(validate: false) + end + + private + + def validate_channel! + raise ArgumentError, 'Business management token is only available on Chatwoot Cloud' unless ChatwootApp.chatwoot_cloud? + return if @channel.provider == 'whatsapp_cloud' && @channel.provider_config['source'] == 'embedded_signup' + + raise ArgumentError, 'Business management token is only supported for WhatsApp Embedded Signup inboxes' + end +end diff --git a/app/services/whatsapp/business_management_token_validation_service.rb b/app/services/whatsapp/business_management_token_validation_service.rb new file mode 100644 index 000000000..fa2d2c232 --- /dev/null +++ b/app/services/whatsapp/business_management_token_validation_service.rb @@ -0,0 +1,55 @@ +class Whatsapp::BusinessManagementTokenValidationService + REQUIRED_PERMISSION = 'whatsapp_business_management'.freeze + + def initialize(business_management_token, business_account_id) + @business_management_token = business_management_token + @business_account_id = business_account_id + end + + def perform + response = HTTParty.get(permissions_url, headers: { 'Authorization' => "Bearer #{@business_management_token}" }) + + raise ArgumentError, response_error(response) unless response.success? + raise ArgumentError, "Business management token must grant the #{REQUIRED_PERMISSION} permission" unless required_permission_granted?(response) + + validate_business_account_access! + rescue Net::OpenTimeout, Net::ReadTimeout, SocketError + raise ArgumentError, 'Could not validate business management token permissions. Please try again.' + end + + private + + def permissions_url + base_path = ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com') + api_version = GlobalConfigService.load('WHATSAPP_API_VERSION', 'v22.0') + "#{base_path}/#{api_version}/me/permissions" + end + + def business_account_templates_url + base_path = ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com') + api_version = GlobalConfigService.load('WHATSAPP_API_VERSION', 'v22.0') + "#{base_path}/#{api_version}/#{@business_account_id}/message_templates?limit=1" + end + + def required_permission_granted?(response) + permissions = response.parsed_response.is_a?(Hash) ? response.parsed_response['data'] : [] + Array(permissions).select { |permission| permission.is_a?(Hash) } + .any? { |permission| permission['permission'] == REQUIRED_PERMISSION && permission['status'] == 'granted' } + end + + def validate_business_account_access! + response = HTTParty.get( + business_account_templates_url, + headers: { 'Authorization' => "Bearer #{@business_management_token}" } + ) + + raise ArgumentError, response_error(response) unless response.success? + + true + end + + def response_error(response) + error_message = response.parsed_response.is_a?(Hash) ? response.parsed_response.dig('error', 'message') : nil + error_message.presence || 'Could not validate business management token permissions' + end +end diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb index 09270a588..6e887ed8a 100644 --- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb +++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb @@ -35,29 +35,29 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi def sync_templates # ensuring that channels with wrong provider config wouldn't keep trying to sync templates whatsapp_channel.mark_message_templates_updated - templates = fetch_whatsapp_templates("#{business_account_path}/message_templates?access_token=#{whatsapp_channel.provider_config['api_key']}") - whatsapp_channel.update(message_templates: templates, message_templates_last_updated: Time.now.utc) if templates.present? + templates = fetch_whatsapp_templates + # rubocop:disable Rails/SkipsModelValidations + whatsapp_channel.update_columns(message_templates: templates, message_templates_last_updated: Time.current) if templates.present? + # rubocop:enable Rails/SkipsModelValidations end - def fetch_whatsapp_templates(url) - response = HTTParty.get(url) + def fetch_whatsapp_templates(after: nil) + options = { headers: { 'Authorization' => "Bearer #{whatsapp_channel.template_access_token}" } } + options[:query] = { after: after } if after.present? + response = HTTParty.get("#{business_account_path}/message_templates", options) unless response.success? Rails.logger.warn "[WHATSAPP] Template sync failed for account #{whatsapp_channel.account_id} " \ "inbox #{whatsapp_channel.inbox&.id}: #{response.code} #{error_message(response)}" return [] end - next_url = next_url(response) + next_cursor = response.dig('paging', 'cursors', 'after') - return response['data'] + fetch_whatsapp_templates(next_url) if next_url.present? + return response['data'] + fetch_whatsapp_templates(after: next_cursor) if next_cursor.present? response['data'] end - def next_url(response) - response['paging'] ? response['paging']['next'] : '' - end - def validate_provider_config? config = whatsapp_channel.provider_config response = HTTParty.get("#{business_account_path}/message_templates?access_token=#{config['api_key']}") diff --git a/app/services/whatsapp/reauthorization_service.rb b/app/services/whatsapp/reauthorization_service.rb index 81be85f50..76a79d6d4 100644 --- a/app/services/whatsapp/reauthorization_service.rb +++ b/app/services/whatsapp/reauthorization_service.rb @@ -29,6 +29,7 @@ class Whatsapp::ReauthorizationService current_config = channel.provider_config || {} # Legacy clients may omit phone_number_id; fall back to the value just fetched from Meta. resolved_phone_number_id = @phone_number_id.presence || phone_info[:phone_number_id] + channel.business_management_token = nil if current_config['business_account_id'] != @waba_id channel.provider_config = current_config.merge( 'api_key' => access_token, diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder index 216f7db76..4bdba42f9 100644 --- a/app/views/api/v1/models/_inbox.json.jbuilder +++ b/app/views/api/v1/models/_inbox.json.jbuilder @@ -134,6 +134,11 @@ json.bot_name resource.channel.try(:bot_name) if resource.telegram? if resource.whatsapp? json.message_templates resource.channel.try(:message_templates) json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator? + if Current.account_user&.administrator? && + ChatwootApp.chatwoot_cloud? && + (resource.channel.try(:provider_config) || {}).to_h['source'] == 'embedded_signup' + json.business_management_token_configured resource.channel.try(:business_management_token).present? + end # Only show reauthorization for embedded signup; manual flow uses API keys, not OAuth json.reauthorization_required( (resource.channel.try(:provider_config) || {}).to_h['source'] == 'embedded_signup' && diff --git a/config/routes.rb b/config/routes.rb index 538330b70..27cc4b37c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -283,6 +283,7 @@ Rails.application.routes.draw do post :set_agent_bot, on: :member delete :avatar, on: :member post :sync_templates, on: :member + put :whatsapp_business_management_token, on: :member get :health, on: :member post :register_webhook, on: :member post :reset_secret, on: :member diff --git a/db/migrate/20260728000001_add_business_management_token_to_channel_whatsapp.rb b/db/migrate/20260728000001_add_business_management_token_to_channel_whatsapp.rb new file mode 100644 index 000000000..95cd69e37 --- /dev/null +++ b/db/migrate/20260728000001_add_business_management_token_to_channel_whatsapp.rb @@ -0,0 +1,5 @@ +class AddBusinessManagementTokenToChannelWhatsapp < ActiveRecord::Migration[7.1] + def change + add_column :channel_whatsapp, :business_management_token, :text + end +end diff --git a/db/schema.rb b/db/schema.rb index acf83e12a..f8d4d13d4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_07_24_000100) do +ActiveRecord::Schema[7.1].define(version: 2026_07_28_000001) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -676,6 +676,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_24_000100) do create_table "channel_whatsapp", force: :cascade do |t| t.integer "account_id", null: false + t.text "business_management_token" t.string "phone_number", null: false t.string "provider", default: "default" t.jsonb "provider_config", default: {} diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb index b42fcbeae..294c8bf9c 100644 --- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb @@ -126,6 +126,28 @@ RSpec.describe 'Inboxes API', type: :request do expect(response.parsed_body['reauthorization_required']).to be(true) end + it 'returns only the configured state for an embedded signup WhatsApp business management token' do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) + whatsapp_channel = create( + :channel_whatsapp, + account: account, + provider: 'whatsapp_cloud', + business_management_token: 'business-token', + sync_templates: false, + validate_provider_config: false + ) + whatsapp_inbox = create(:inbox, channel: whatsapp_channel, account: account) + + get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(response.parsed_body['business_management_token_configured']).to be(true) + expect(response.parsed_body).not_to have_key('business_management_token') + expect(response.body).not_to include('business-token') + end + it 'does not flag reauthorization_required for manual whatsapp channel even when reauth required' do whatsapp_channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) diff --git a/spec/controllers/api/v1/accounts/whatsapp_business_management_token_spec.rb b/spec/controllers/api/v1/accounts/whatsapp_business_management_token_spec.rb new file mode 100644 index 000000000..8261ca02e --- /dev/null +++ b/spec/controllers/api/v1/accounts/whatsapp_business_management_token_spec.rb @@ -0,0 +1,48 @@ +require 'rails_helper' + +RSpec.describe 'WhatsApp business management token API', type: :request do + let(:account) { create(:account) } + let(:admin) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + let(:channel) do + create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) + end + let(:inbox) { channel.inbox } + let(:service) { instance_double(Whatsapp::BusinessManagementTokenService, update!: true) } + + before do + allow(Whatsapp::BusinessManagementTokenService).to receive(:new).with(channel).and_return(service) + end + + it 'allows an administrator to validate and update the token' do + put "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/whatsapp_business_management_token", + headers: admin.create_new_auth_token, + params: { business_management_token: 'business-token' }, + as: :json + + expect(service).to have_received(:update!).with('business-token') + expect(response).to have_http_status(:no_content) + end + + it 'does not allow an agent to update the token' do + put "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/whatsapp_business_management_token", + headers: agent.create_new_auth_token, + params: { business_management_token: 'business-token' }, + as: :json + + expect(service).not_to have_received(:update!) + expect(response).to have_http_status(:unauthorized) + end + + it 'returns a consistent validation error response' do + allow(service).to receive(:update!).and_raise(ArgumentError, 'Invalid token') + + put "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/whatsapp_business_management_token", + headers: admin.create_new_auth_token, + params: { business_management_token: 'business-token' }, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body).to eq('error' => 'Invalid token', 'message' => 'Invalid token') + end +end diff --git a/spec/models/application_record_external_credentials_encryption_spec.rb b/spec/models/application_record_external_credentials_encryption_spec.rb index 65c347434..a79fa97a5 100644 --- a/spec/models/application_record_external_credentials_encryption_spec.rb +++ b/spec/models/application_record_external_credentials_encryption_spec.rb @@ -38,6 +38,24 @@ RSpec.describe ApplicationRecord do attribute: :access_token, value: 'ig-secret' + it 'encrypts WhatsApp business_management_token at rest' do + skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured? + + channel = create( + :channel_whatsapp, + provider: 'whatsapp_cloud', + business_management_token: 'whatsapp-business-management-secret', + validate_provider_config: false, + sync_templates: false + ) + + raw_stored_value = channel.reload.read_attribute_before_type_cast(:business_management_token).to_s + expect(raw_stored_value).to be_present + expect(raw_stored_value).not_to include('whatsapp-business-management-secret') + expect(channel.business_management_token).to eq('whatsapp-business-management-secret') + expect(channel.encrypted_attribute?(:business_management_token)).to be(true) + end + it_behaves_like 'encrypted external credential', factory: :channel_line, attribute: :line_channel_secret, diff --git a/spec/models/channel/whatsapp_spec.rb b/spec/models/channel/whatsapp_spec.rb index 10a192004..9fcdce183 100644 --- a/spec/models/channel/whatsapp_spec.rb +++ b/spec/models/channel/whatsapp_spec.rb @@ -4,6 +4,65 @@ require 'rails_helper' require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb' RSpec.describe Channel::Whatsapp do + describe '#serializable_hash' do + it 'does not expose the business management token' do + channel = build(:channel_whatsapp, business_management_token: 'business-token') + + expect(channel.serializable_hash).not_to have_key('business_management_token') + end + end + + describe '#template_access_token' do + let(:channel) do + build( + :channel_whatsapp, + provider: 'whatsapp_cloud', + provider_config: { 'api_key' => 'api-key', 'source' => source }, + business_management_token: business_management_token + ) + end + let(:source) { 'embedded_signup' } + + context 'when running on Chatwoot Cloud' do + before { allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) } + + context 'with a business management token' do + let(:business_management_token) { 'business-token' } + + it 'uses the business management token' do + expect(channel.template_access_token).to eq('business-token') + end + end + + context 'without a business management token' do + let(:business_management_token) { nil } + + it 'uses the provider API key' do + expect(channel.template_access_token).to eq('api-key') + end + end + + context 'with a manually configured inbox' do + let(:business_management_token) { 'business-token' } + let(:source) { nil } + + it 'ignores the business management token' do + expect(channel.template_access_token).to eq('api-key') + end + end + end + + context 'when running outside Chatwoot Cloud' do + before { allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false) } + + let(:business_management_token) { 'business-token' } + + it 'ignores the business management token' do + expect(channel.template_access_token).to eq('api-key') + end + end + end + describe 'concerns' do let(:channel) { create(:channel_whatsapp) } @@ -44,6 +103,9 @@ RSpec.describe Channel::Whatsapp do }] }.to_json) stub_request(:get, 'https://graph.facebook.com/v14.0//phone_numbers?fields=id&limit=100&access_token=test_key') .to_return(status: 200, body: { data: [{ id: 'random_id' }] }.to_json, headers: { 'Content-Type' => 'application/json' }) + stub_request(:get, 'https://graph.facebook.com/v14.0//message_templates') + .with(headers: { 'Authorization' => 'Bearer test_key' }) + .to_return(status: 200, body: { data: [] }.to_json, headers: { 'Content-Type' => 'application/json' }) expect(channel.save).to be(true) end diff --git a/spec/services/whatsapp/business_management_token_service_spec.rb b/spec/services/whatsapp/business_management_token_service_spec.rb new file mode 100644 index 000000000..fa38651c8 --- /dev/null +++ b/spec/services/whatsapp/business_management_token_service_spec.rb @@ -0,0 +1,58 @@ +require 'rails_helper' + +RSpec.describe Whatsapp::BusinessManagementTokenService do + let(:channel) do + create( + :channel_whatsapp, + provider: 'whatsapp_cloud', + provider_config: { + 'api_key' => 'integration-token', + 'phone_number_id' => 'phone-id', + 'business_account_id' => 'waba-id', + 'source' => 'embedded_signup' + }, + sync_templates: false, + validate_provider_config: false + ) + end + let(:service) { described_class.new(channel) } + let(:validation_service) { instance_double(Whatsapp::BusinessManagementTokenValidationService, perform: true) } + + before do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) + allow(Whatsapp::BusinessManagementTokenValidationService).to receive(:new) + .with('business-token', channel.provider_config['business_account_id']) + .and_return(validation_service) + end + + it 'stores the business management token without replacing the API key' do + original_api_key = channel.provider_config['api_key'] + + service.update!('business-token') + + expect(channel.reload.business_management_token).to eq('business-token') + expect(channel.provider_config['api_key']).to eq(original_api_key) + end + + it 'stores the token without revalidating the existing provider API key' do + expect(channel).not_to receive(:validate_provider_config) + + service.update!('business-token') + end + + it 'rejects updates outside Chatwoot Cloud' do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false) + + expect { service.update!('business-token') } + .to raise_error(ArgumentError, 'Business management token is only available on Chatwoot Cloud') + expect(validation_service).not_to have_received(:perform) + end + + it 'rejects updates for manually configured WhatsApp Cloud inboxes' do + channel.provider_config.delete('source') + + expect { service.update!('business-token') } + .to raise_error(ArgumentError, 'Business management token is only supported for WhatsApp Embedded Signup inboxes') + expect(validation_service).not_to have_received(:perform) + end +end diff --git a/spec/services/whatsapp/business_management_token_validation_service_spec.rb b/spec/services/whatsapp/business_management_token_validation_service_spec.rb new file mode 100644 index 000000000..d428f8376 --- /dev/null +++ b/spec/services/whatsapp/business_management_token_validation_service_spec.rb @@ -0,0 +1,105 @@ +require 'rails_helper' + +RSpec.describe Whatsapp::BusinessManagementTokenValidationService do + let(:permissions_url) { 'https://graph.facebook.com/v22.0/me/permissions' } + let(:templates_url) { 'https://graph.facebook.com/v22.0/waba-id/message_templates?limit=1' } + let(:service) { described_class.new('business-token', 'waba-id') } + + around do |example| + with_modified_env WHATSAPP_CLOUD_BASE_URL: 'https://graph.facebook.com' do + example.run + end + end + + before do + allow(GlobalConfigService).to receive(:load).with('WHATSAPP_API_VERSION', 'v22.0').and_return('v22.0') + end + + it 'accepts a token with the WhatsApp business management permission' do + stub_request(:get, permissions_url) + .with(headers: { 'Authorization' => 'Bearer business-token' }) + .to_return( + status: 200, + body: { + data: [ + { permission: 'whatsapp_business_management', status: 'granted' }, + { permission: 'whatsapp_business_messaging', status: 'granted' } + ] + }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + stub_request(:get, templates_url) + .with(headers: { 'Authorization' => 'Bearer business-token' }) + .to_return(status: 200, body: { data: [] }.to_json, headers: { 'Content-Type' => 'application/json' }) + + expect(service.perform).to be(true) + end + + it 'rejects a token without the WhatsApp business management permission' do + stub_request(:get, permissions_url) + .with(headers: { 'Authorization' => 'Bearer business-token' }) + .to_return( + status: 200, + body: { + data: [ + { permission: 'whatsapp_business_management', status: 'declined' }, + { permission: 'whatsapp_business_messaging', status: 'granted' } + ] + }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { service.perform } + .to raise_error(ArgumentError, 'Business management token must grant the whatsapp_business_management permission') + end + + it 'rejects malformed permission entries as a controlled validation error' do + stub_request(:get, permissions_url) + .to_return( + status: 200, + body: { data: [nil, 'invalid'] }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { service.perform } + .to raise_error(ArgumentError, 'Business management token must grant the whatsapp_business_management permission') + end + + it 'rejects a token without access to the inbox WhatsApp Business Account' do + stub_request(:get, permissions_url) + .to_return( + status: 200, + body: { data: [{ permission: 'whatsapp_business_management', status: 'granted' }] }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + stub_request(:get, templates_url) + .with(headers: { 'Authorization' => 'Bearer business-token' }) + .to_return( + status: 403, + body: { error: { message: 'Permission denied' } }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { service.perform }.to raise_error(ArgumentError, 'Permission denied') + end + + it 'returns the provider error when the permission check fails' do + stub_request(:get, permissions_url) + .with(headers: { 'Authorization' => 'Bearer business-token' }) + .to_return( + status: 403, + body: { error: { message: 'Permission denied' } }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { service.perform } + .to raise_error(ArgumentError, 'Permission denied') + end + + it 'returns a safe error when the permission request times out' do + stub_request(:get, permissions_url).to_timeout + + expect { service.perform } + .to raise_error(ArgumentError, 'Could not validate business management token permissions. Please try again.') + end +end diff --git a/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb index c409c9a69..5d6e1ff8c 100644 --- a/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb +++ b/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb @@ -4,7 +4,16 @@ describe Whatsapp::Providers::WhatsappCloudService do subject(:service) { described_class.new(whatsapp_channel: whatsapp_channel) } let(:conversation) { create(:conversation, inbox: whatsapp_channel.inbox) } - let(:whatsapp_channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false) } + let(:business_management_token) { nil } + let(:whatsapp_channel) do + create( + :channel_whatsapp, + provider: 'whatsapp_cloud', + business_management_token: business_management_token, + validate_provider_config: false, + sync_templates: false + ) + end let(:message) do create(:message, conversation: conversation, message_type: :outgoing, content: 'test', inbox: whatsapp_channel.inbox, source_id: 'external_id') @@ -282,21 +291,95 @@ describe Whatsapp::Providers::WhatsappCloudService do describe '#sync_templates' do context 'when called' do + context 'with a business management token' do + let(:business_management_token) { 'business-token' } + + before { allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) } + + it 'uses it instead of the provider API key' do + request = stub_request( + :get, + 'https://graph.facebook.com/v14.0/123456789/message_templates' + ).with( + headers: { 'Authorization' => 'Bearer business-token' } + ).to_return(status: 200, headers: response_headers, body: { data: [] }.to_json) + + subject.sync_templates + + expect(request).to have_been_requested + end + end + + context 'without a business management token' do + before { allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) } + + it 'uses the provider API key' do + request = stub_request( + :get, + 'https://graph.facebook.com/v14.0/123456789/message_templates' + ).with( + headers: { 'Authorization' => 'Bearer test_key' } + ).to_return(status: 200, headers: response_headers, body: { data: [] }.to_json) + + subject.sync_templates + + expect(request).to have_been_requested + end + end + + context 'with a stored business management token outside Chatwoot Cloud' do + let(:business_management_token) { 'business-token' } + + before { allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false) } + + it 'uses the provider API key' do + request = stub_request( + :get, + 'https://graph.facebook.com/v14.0/123456789/message_templates' + ).with( + headers: { 'Authorization' => 'Bearer test_key' } + ).to_return(status: 200, headers: response_headers, body: { data: [] }.to_json) + + subject.sync_templates + + expect(request).to have_been_requested + end + end + it 'updated the message templates' do - stub_request(:get, 'https://graph.facebook.com/v14.0/123456789/message_templates?access_token=test_key') + request_headers = { 'Authorization' => 'Bearer test_key' } + stub_request(:get, 'https://graph.facebook.com/v14.0/123456789/message_templates') + .with(headers: request_headers) .to_return( - { status: 200, headers: response_headers, - body: { data: [ - { id: '123456789', name: 'test_template' } - ], paging: { next: 'https://graph.facebook.com/v14.0/123456789/message_templates?access_token=test_key' } }.to_json }, - { status: 200, headers: response_headers, - body: { data: [ - { id: '123456789', name: 'next_template' } - ], paging: { next: 'https://graph.facebook.com/v14.0/123456789/message_templates?access_token=test_key' } }.to_json }, - { status: 200, headers: response_headers, - body: { data: [ - { id: '123456789', name: 'last_template' } - ], paging: { prev: 'https://graph.facebook.com/v14.0/123456789/message_templates?access_token=test_key' } }.to_json } + status: 200, + headers: response_headers, + body: { + data: [{ id: '123456789', name: 'test_template' }], + paging: { + cursors: { after: 'cursor-1' }, + next: 'https://graph.facebook.com/v14.0/123456789/message_templates?after=cursor-1&access_token=test_key' + } + }.to_json + ) + stub_request(:get, 'https://graph.facebook.com/v14.0/123456789/message_templates?after=cursor-1') + .with(headers: request_headers) + .to_return( + status: 200, + headers: response_headers, + body: { + data: [{ id: '123456789', name: 'next_template' }], + paging: { + cursors: { after: 'cursor-2' }, + next: 'https://graph.facebook.com/v14.0/123456789/message_templates?after=cursor-2&access_token=test_key' + } + }.to_json + ) + stub_request(:get, 'https://graph.facebook.com/v14.0/123456789/message_templates?after=cursor-2') + .with(headers: request_headers) + .to_return( + status: 200, + headers: response_headers, + body: { data: [{ id: '123456789', name: 'last_template' }] }.to_json ) timstamp = whatsapp_channel.reload.message_templates_last_updated @@ -308,7 +391,8 @@ describe Whatsapp::Providers::WhatsappCloudService do end it 'updates message_templates_last_updated even when template request fails' do - stub_request(:get, 'https://graph.facebook.com/v14.0/123456789/message_templates?access_token=test_key') + stub_request(:get, 'https://graph.facebook.com/v14.0/123456789/message_templates') + .with(headers: { 'Authorization' => 'Bearer test_key' }) .to_return(status: 401) timstamp = whatsapp_channel.reload.message_templates_last_updated diff --git a/spec/services/whatsapp/reauthorization_service_spec.rb b/spec/services/whatsapp/reauthorization_service_spec.rb new file mode 100644 index 000000000..092a969ef --- /dev/null +++ b/spec/services/whatsapp/reauthorization_service_spec.rb @@ -0,0 +1,52 @@ +require 'rails_helper' + +RSpec.describe Whatsapp::ReauthorizationService do + let(:account) { create(:account) } + let(:channel) do + create( + :channel_whatsapp, + account: account, + provider: 'whatsapp_cloud', + provider_config: { + 'api_key' => 'old-token', + 'phone_number_id' => 'old-phone-id', + 'business_account_id' => 'old-waba-id', + 'source' => 'embedded_signup' + }, + business_management_token: 'business-token', + validate_provider_config: false, + sync_templates: false + ) + end + let(:inbox) { create(:inbox, account: account, channel: channel) } + let(:phone_info) { { phone_number: channel.phone_number, business_name: inbox.name } } + + before do + stub_request(:get, %r{\Ahttps://graph\.facebook\.com/v14\.0/.+/message_templates\?access_token=new-token\z}) + .to_return(status: 200, body: { data: [] }.to_json, headers: { 'Content-Type' => 'application/json' }) + stub_request(:get, %r{\Ahttps://graph\.facebook\.com/v14\.0/.+/phone_numbers\?.*access_token=new-token}) + .to_return(status: 200, body: { data: [{ id: 'new-phone-id' }] }.to_json, headers: { 'Content-Type' => 'application/json' }) + end + + it 'clears the business management token when the WhatsApp Business Account changes' do + described_class.new( + account: account, + inbox_id: inbox.id, + phone_number_id: 'new-phone-id', + waba_id: 'new-waba-id' + ).perform('new-token', phone_info) + + expect(channel.reload.business_management_token).to be_nil + end + + it 'retains the business management token when the WhatsApp Business Account does not change' do + described_class.new( + account: account, + inbox_id: inbox.id, + phone_number_id: 'new-phone-id', + waba_id: channel.provider_config['business_account_id'] + ).perform('new-token', phone_info) + + expect(channel.reload.business_management_token).to eq('business-token') + end +end