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>
This commit is contained in:
Muhsin Keloth
2026-07-29 13:37:59 +04:00
committed by GitHub
parent 2e9423cbba
commit 59eac9a7c5
24 changed files with 786 additions and 26 deletions

View File

@@ -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'

View File

@@ -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,

View File

@@ -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' }
);
});
});
});

View File

@@ -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.",

View File

@@ -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 {
</div>
</SettingsFieldSection>
</template>
<WhatsappBusinessManagementToken
v-if="
isOnChatwootCloud &&
inbox.provider === 'whatsapp_cloud' &&
isEmbeddedSignupWhatsApp
"
:inbox="inbox"
/>
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_TITLE')"
:help-text="

View File

@@ -0,0 +1,147 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import InboxesAPI from 'dashboard/api/inboxes';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
inbox: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const WHATSAPP_BUSINESS_MANAGEMENT_TOKEN_GUIDE_URL = 'https://chwt.app/zM7G2yU';
const businessManagementToken = ref('');
const isUpdating = ref(false);
const tokenUpdated = ref(false);
const isTokenConfigured = computed(
() =>
tokenUpdated.value ||
Boolean(props.inbox.business_management_token_configured)
);
const isUpdateDisabled = computed(
() => !businessManagementToken.value || isUpdating.value
);
const sectionTitle = computed(() =>
isTokenConfigured.value
? t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_BUSINESS_MANAGEMENT_TOKEN_REPLACE_TITLE'
)
: t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_BUSINESS_MANAGEMENT_TOKEN_ADD_TITLE'
)
);
const actionLabel = computed(() =>
isTokenConfigured.value
? t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_BUSINESS_MANAGEMENT_TOKEN_REPLACE_BUTTON'
)
: t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_BUSINESS_MANAGEMENT_TOKEN_ADD_BUTTON'
)
);
watch(
() => props.inbox.id,
() => {
businessManagementToken.value = '';
tokenUpdated.value = false;
isUpdating.value = false;
}
);
const updateToken = async () => {
const inboxId = props.inbox.id;
isUpdating.value = true;
try {
await InboxesAPI.updateWhatsappBusinessManagementToken(
inboxId,
businessManagementToken.value
);
if (props.inbox.id !== inboxId) return;
businessManagementToken.value = '';
tokenUpdated.value = true;
useAlert(
t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_BUSINESS_MANAGEMENT_TOKEN_UPDATE_SUCCESS'
)
);
} catch (error) {
if (props.inbox.id !== inboxId) return;
useAlert(
error.response?.data?.message ||
t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_BUSINESS_MANAGEMENT_TOKEN_UPDATE_ERROR'
)
);
} finally {
if (props.inbox.id === inboxId) {
isUpdating.value = false;
}
}
};
</script>
<template>
<SettingsFieldSection
:label="sectionTitle"
:help-text="
t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_BUSINESS_MANAGEMENT_TOKEN_UPDATE_SUBHEADER'
)
"
>
<div class="flex flex-col gap-2">
<div
v-if="isTokenConfigured"
class="inline-flex w-fit items-center gap-1.5 rounded-md bg-n-alpha-2 px-2 py-1 text-label-small text-n-teal-11"
>
<span class="size-1.5 rounded-full bg-n-teal-9" />
{{
t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_BUSINESS_MANAGEMENT_TOKEN_CONFIGURED'
)
}}
</div>
<div
class="flex flex-1 justify-between items-center whatsapp-settings--content"
>
<woot-input
v-model="businessManagementToken"
type="password"
class="flex-1 mr-2 [&>input]:!mb-0"
:placeholder="
t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_BUSINESS_MANAGEMENT_TOKEN_UPDATE_PLACEHOLDER'
)
"
/>
<NextButton
:disabled="isUpdateDisabled"
:is-loading="isUpdating"
@click="updateToken"
>
{{ actionLabel }}
</NextButton>
</div>
<a
:href="WHATSAPP_BUSINESS_MANAGEMENT_TOKEN_GUIDE_URL"
target="_blank"
rel="noopener noreferrer"
class="text-label-small text-n-blue-11 hover:underline"
>
{{
t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_BUSINESS_MANAGEMENT_TOKEN_GUIDE_LINK'
)
}}
</a>
</div>
</SettingsFieldSection>
</template>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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']}")

View File

@@ -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,

View File

@@ -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' &&

View File

@@ -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

View File

@@ -0,0 +1,5 @@
class AddBusinessManagementTokenToChannelWhatsapp < ActiveRecord::Migration[7.1]
def change
add_column :channel_whatsapp, :business_management_token, :text
end
end

View File

@@ -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: {}

View File

@@ -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)

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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