From 59eac9a7c5b4ccc521876d03ffaf72c95e65746c Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 29 Jul 2026 13:37:59 +0400 Subject: [PATCH] feat(whatsapp): add cloud template management token (#15218) Chatwoot Cloud customers can now provide a dedicated WhatsApp business management token when their Embedded Signup credential cannot access message templates. Once validated, the token is stored securely and used only for template synchronization. Existing inboxes continue using their configured WhatsApp API key when no business management token is present. Sending, receiving, webhooks, phone-number health, and other WhatsApp operations remain unchanged. ### Things to know - This option is available only on Chatwoot Cloud. - Saving the token verifies that `whatsapp_business_management` is granted through Meta's permissions endpoint; template synchronization still verifies access to the configured WhatsApp Business Account. - The token is encrypted using the existing external-credentials encryption mechanism. - Self-hosted installations continue using the existing API key flow. ### How to test 1. On Chatwoot Cloud, open a WhatsApp Cloud inbox and go to **Configuration**. 2. Enter a token with `whatsapp_business_management` access and save it. 3. Confirm the token is accepted and the value is not exposed again in the UI or API. 4. Select **Sync Templates** and confirm templates are fetched with the saved business management token. 5. Remove the token and confirm template synchronization falls back to the inbox API key. 6. Confirm the business management token controls are not shown on a self-hosted installation. ### What changed - Added an encrypted `business_management_token` credential to WhatsApp channels. - Added Cloud-only endpoints and UI controls to validate the required permission, save, and remove the token. - Added template-sync credential selection with API-key fallback. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> --- .../concerns/whatsapp_health_management.rb | 15 ++ app/javascript/dashboard/api/inboxes.js | 9 ++ .../dashboard/api/specs/inboxes.spec.js | 9 ++ .../dashboard/i18n/locale/en/inboxMgmt.json | 10 ++ .../inbox/settingsPage/ConfigurationPage.vue | 10 ++ .../WhatsappBusinessManagementToken.vue | 147 ++++++++++++++++++ app/models/channel/whatsapp.rb | 12 ++ app/policies/inbox_policy.rb | 4 + .../business_management_token_service.rb | 27 ++++ ...ess_management_token_validation_service.rb | 55 +++++++ .../providers/whatsapp_cloud_service.rb | 20 +-- .../whatsapp/reauthorization_service.rb | 1 + app/views/api/v1/models/_inbox.json.jbuilder | 5 + config/routes.rb | 1 + ...ss_management_token_to_channel_whatsapp.rb | 5 + db/schema.rb | 3 +- .../v1/accounts/inboxes_controller_spec.rb | 22 +++ ...whatsapp_business_management_token_spec.rb | 48 ++++++ ...rd_external_credentials_encryption_spec.rb | 18 +++ spec/models/channel/whatsapp_spec.rb | 62 ++++++++ .../business_management_token_service_spec.rb | 58 +++++++ ...anagement_token_validation_service_spec.rb | 105 +++++++++++++ .../providers/whatsapp_cloud_service_spec.rb | 114 ++++++++++++-- .../whatsapp/reauthorization_service_spec.rb | 52 +++++++ 24 files changed, 786 insertions(+), 26 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/WhatsappBusinessManagementToken.vue create mode 100644 app/services/whatsapp/business_management_token_service.rb create mode 100644 app/services/whatsapp/business_management_token_validation_service.rb create mode 100644 db/migrate/20260728000001_add_business_management_token_to_channel_whatsapp.rb create mode 100644 spec/controllers/api/v1/accounts/whatsapp_business_management_token_spec.rb create mode 100644 spec/services/whatsapp/business_management_token_service_spec.rb create mode 100644 spec/services/whatsapp/business_management_token_validation_service_spec.rb create mode 100644 spec/services/whatsapp/reauthorization_service_spec.rb 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