feat: enforce api_and_webhooks feature for token API and account webhooks (#14973)

This gates API-token access and outgoing account webhooks behind the
`api_and_webhooks` account feature introduced in #14972. On Chatwoot
Cloud, Hacker accounts lose token-authenticated account API access and
account webhook delivery, while paid accounts retain them through the
billing-plan feature reconcile. Community and self-hosted installations
continue to work without any upgrade-time interruption.

## What changed

- Added `Account#api_and_webhooks_enabled?` as the single backend kill
switch. Core returns enabled; the Enterprise override consults the
account flag on Chatwoot Cloud and remains enabled off-Cloud.
- Account-scoped v1 and v2 requests authenticated with a user or
agent-bot API token now return `403 Forbidden` when the feature is
disabled. Invalid tokens still return 401, and dashboard session
requests are unaffected.
- Profile responses return an empty access token when none of the user's
accounts has access. The stored token is preserved, and the profile UI
disables its token controls with paid-plan copy on Cloud.
- Account webhook delivery stops when the feature is disabled. Webhook
CRUD remains available to session-authenticated dashboard requests,
API-inbox webhooks continue to be delivered, and the Cloud dashboard
shows a webhook paywall instead of the webhook list.
- Removed the database backfill migration. Existing paid Cloud accounts
should be enabled with the one-off script below before enforcement is
deployed.

## Existing paid-account rollout

Run this as an ad-hoc Rails runner script on Chatwoot Cloud. It
intentionally targets only the Startups, Business, and Enterprise plans
and does not add `api_and_webhooks` to `manually_managed_features`, so
future billing reconciles remain authoritative.

```rb
paid_plan_names = %w[Startups Business Enterprise]
accounts = Account.where("custom_attributes ->> 'plan_name' IN (?)", paid_plan_names)

total = accounts.count
enabled = 0
skipped = 0

puts "Enabling api_and_webhooks for #{total} paid account(s)..."

accounts.find_each(batch_size: 500).with_index(1) do |account, processed|
  if account.feature_enabled?('api_and_webhooks')
    skipped += 1
  else
    account.enable_features!('api_and_webhooks')
    enabled += 1
  end

  puts "Processed #{processed}/#{total}..." if (processed % 1000).zero?
end

puts "Done! Enabled: #{enabled}, Skipped: #{skipped}, Total: #{total}"
```

For example, save the snippet outside the repository as
`enable_api_and_webhooks.rb`, then run:

```sh
bundle exec rails runner /path/to/enable_api_and_webhooks.rb
```

## How to test

- On Cloud, use a Hacker account and confirm token-authenticated
requests to account-scoped v1 and v2 endpoints return 403, while the
same dashboard actions continue to work through session authentication.
- Confirm profile access-token controls are disabled with paid-plan copy
when all accounts are ineligible, and remain available when at least one
account has the feature.
- Confirm the Webhooks settings page shows the billing paywall for a
Cloud account without the feature; admins get the billing action and
agents get the existing ask-an-admin message.
- Confirm outgoing account webhooks stop for an ineligible Cloud account
while API-inbox webhooks still deliver.
- Confirm community and self-hosted installations retain API and webhook
behavior after upgrading, even when an existing account does not have
the stored feature bit.


### Screenshots

## Cloud

<img width="2590" height="642" alt="CleanShot 2026-07-15 at 15 13 14@2x"
src="https://github.com/user-attachments/assets/431a7bd8-1742-4e7a-b312-d3ad92015f9b"
/>

<img width="2152" height="994" alt="CleanShot 2026-07-15 at 15 14 37@2x"
src="https://github.com/user-attachments/assets/475dda48-d1c5-4be5-a3c3-7a96b9713724"
/>

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Shivam Mishra
2026-07-16 13:43:49 +04:00
committed by GitHub
co-authored by Muhsin Keloth
parent 0a25a0ef66
commit 522e3c4d3f
23 changed files with 441 additions and 12 deletions
@@ -2,5 +2,14 @@ class Api::V1::Accounts::BaseController < Api::BaseController
include SwitchLocale
include EnsureCurrentAccountHelper
before_action :current_account
before_action :validate_token_api_access, if: :authenticate_by_access_token?
around_action :switch_locale_using_account_locale
private
def validate_token_api_access
return if Current.account.api_and_webhooks_enabled?
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
end
end
@@ -8,6 +8,7 @@ class Api::V1::AccountsController < Api::BaseController
before_action :ensure_account_name, only: [:create]
before_action :validate_captcha, only: [:create]
before_action :fetch_account, except: [:create]
before_action :validate_token_api_access, if: :authenticate_by_access_token?, except: [:create]
before_action :check_authorization, except: [:create]
rescue_from CustomExceptions::Account::InvalidEmail,
@@ -105,6 +106,12 @@ class Api::V1::AccountsController < Api::BaseController
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
end
def validate_token_api_access
return if @account.api_and_webhooks_enabled?
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
end
def account_params
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
end
@@ -14,6 +14,7 @@ const props = defineProps({
icon: { type: [String, Object, Function], default: '' },
trailingIcon: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
@@ -61,6 +62,7 @@ const handleClick = () => {
:icon="icon"
:trailing-icon="trailingIcon"
:is-loading="isLoading"
:disabled="disabled"
@click="handleClick"
@blur="resetConfirmMode"
>
+1
View File
@@ -12,6 +12,7 @@ export const FEATURE_FLAGS = {
CRM: 'crm',
CUSTOM_ATTRIBUTES: 'custom_attributes',
DATA_IMPORT: 'data_import',
API_AND_WEBHOOKS: 'api_and_webhooks',
INBOX_MANAGEMENT: 'inbox_management',
INTEGRATIONS: 'integrations',
LABELS: 'labels',
@@ -31,6 +31,13 @@
"WEBHOOK": {
"SUBSCRIBED_EVENTS": "Subscribed Events",
"LEARN_MORE": "Learn more about webhooks",
"PAYWALL": {
"TITLE": "Webhooks are available on paid plans",
"AVAILABLE_ON": "Use webhooks to receive real-time events from your Chatwoot account.",
"UPGRADE_PROMPT": "Upgrade to the Startups, Business, or Enterprise plan to use webhooks.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "Change or cancel your plan anytime."
},
"SECRET": {
"LABEL": "Secret",
"COPY": "Copy secret to clipboard",
@@ -100,6 +100,7 @@
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration",
"PAID_PLAN_NOTE": "API access tokens are available on paid plans.",
"COPY": "Copy",
"RESET": "Reset",
"CONFIRM_RESET": "Are you sure?",
@@ -5,9 +5,11 @@ import { useBranding } from 'shared/composables/useBranding';
import { picoSearch } from '@scmmishra/pico-search';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { BaseTable } from 'dashboard/components-next/table';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import NewWebhook from './NewWebHook.vue';
import EditWebhook from './EditWebHook.vue';
import WebhookRow from './WebhookRow.vue';
import WebhookPaywall from './WebhookPaywall.vue';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
import SettingsLayout from '../../SettingsLayout.vue';
@@ -20,6 +22,7 @@ export default {
NewWebhook,
EditWebhook,
WebhookRow,
WebhookPaywall,
},
setup() {
const { replaceInstallationName } = useBranding();
@@ -39,7 +42,19 @@ export default {
...mapGetters({
records: 'webhooks/getWebhooks',
uiFlags: 'webhooks/getUIFlags',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
apiAndWebhooksEnabled() {
return (
!this.isOnChatwootCloud ||
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.API_AND_WEBHOOKS
)
);
},
integration() {
return this.$store.getters['integrations/getIntegration']('webhook');
},
@@ -57,9 +72,16 @@ export default {
];
},
},
watch: {
apiAndWebhooksEnabled: {
immediate: true,
handler(enabled) {
if (enabled) this.$store.dispatch('webhooks/get');
},
},
},
mounted() {
this.$store.dispatch('integrations/get', 'webhook');
this.$store.dispatch('webhooks/get');
},
methods: {
openAddPopup() {
@@ -105,10 +127,10 @@ export default {
<template>
<SettingsLayout
:is-loading="uiFlags.fetchingList"
:is-loading="apiAndWebhooksEnabled && uiFlags.fetchingList"
:loading-message="$t('INTEGRATION_SETTINGS.WEBHOOK.LOADING')"
:no-records-message="$t('INTEGRATION_SETTINGS.WEBHOOK.LIST.404')"
:no-records-found="!records.length"
:no-records-found="apiAndWebhooksEnabled && !records.length"
>
<template #header>
<BaseSettingsHeader
@@ -118,19 +140,21 @@ export default {
:description="replaceInstallationName(integration.description)"
:link-text="$t('INTEGRATION_SETTINGS.WEBHOOK.LEARN_MORE')"
:search-placeholder="
$t('INTEGRATION_SETTINGS.WEBHOOK.SEARCH_PLACEHOLDER')
apiAndWebhooksEnabled
? $t('INTEGRATION_SETTINGS.WEBHOOK.SEARCH_PLACEHOLDER')
: ''
"
feature-name="webhook"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
>
<template v-if="records?.length" #count>
<template v-if="apiAndWebhooksEnabled && records?.length" #count>
<span class="text-body-main text-n-slate-11">
{{
$t('INTEGRATION_SETTINGS.WEBHOOK.COUNT', { n: records.length })
}}
</span>
</template>
<template #actions>
<template v-if="apiAndWebhooksEnabled" #actions>
<NextButton
blue
:label="$t('INTEGRATION_SETTINGS.WEBHOOK.HEADER_BTN_TXT')"
@@ -141,7 +165,9 @@ export default {
</BaseSettingsHeader>
</template>
<template #body>
<WebhookPaywall v-if="!apiAndWebhooksEnabled" />
<BaseTable
v-else
:headers="tableHeaders"
:items="filteredRecords"
:no-data-message="
@@ -160,11 +186,19 @@ export default {
</template>
</BaseTable>
</template>
<woot-modal v-model:show="showAddPopup" :on-close="hideAddPopup">
<woot-modal
v-if="apiAndWebhooksEnabled"
v-model:show="showAddPopup"
:on-close="hideAddPopup"
>
<NewWebhook v-if="showAddPopup" :on-close="hideAddPopup" />
</woot-modal>
<woot-modal v-model:show="showEditPopup" :on-close="hideEditPopup">
<woot-modal
v-if="apiAndWebhooksEnabled"
v-model:show="showEditPopup"
:on-close="hideEditPopup"
>
<EditWebhook
v-if="showEditPopup"
:id="selectedWebHook.id"
@@ -173,6 +207,7 @@ export default {
/>
</woot-modal>
<woot-delete-modal
v-if="apiAndWebhooksEnabled"
v-model:show="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
:on-confirm="confirmDeletion"
@@ -0,0 +1,27 @@
<script setup>
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
const router = useRouter();
const accountId = useMapGetter('getCurrentAccountId');
const openBilling = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: accountId.value },
});
};
</script>
<template>
<div class="grid place-content-center w-full h-full max-h-[28rem] mx-auto">
<BasePaywallModal
class="mx-auto"
feature-prefix="INTEGRATION_SETTINGS.WEBHOOK"
i18n-key="PAYWALL"
is-on-chatwoot-cloud
@upgrade="openBilling"
/>
</div>
</template>
@@ -6,6 +6,7 @@ import ConfirmButton from 'dashboard/components-next/button/ConfirmButton.vue';
const props = defineProps({
value: { type: String, default: '' },
showResetButton: { type: Boolean, default: true },
disabled: { type: Boolean, default: false },
});
const emit = defineEmits(['onCopy', 'onReset']);
@@ -41,12 +42,14 @@ const onReset = () => {
}"
:type="inputType"
:model-value="value"
:disabled="disabled"
readonly
>
<template #masked>
<button
class="absolute top-0 bottom-0 ltr:right-0.5 rtl:left-0.5"
type="button"
:disabled="disabled"
@click="toggleMasked"
>
<fluent-icon :icon="maskIcon" :size="16" />
@@ -61,6 +64,7 @@ const onReset = () => {
type="button"
icon="i-lucide-copy"
class="rounded-xl"
:disabled="disabled"
@click="onClick"
/>
<ConfirmButton
@@ -73,6 +77,7 @@ const onReset = () => {
variant="outline"
icon="i-lucide-key-round"
class="rounded-xl"
:disabled="disabled"
@click="onReset"
/>
</div>
@@ -101,7 +101,24 @@ export default {
currentUser: 'getCurrentUser',
currentUserId: 'getCurrentUserID',
globalConfig: 'globalConfig/get',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
apiAndWebhooksEnabled() {
if (!this.isOnChatwootCloud) return true;
return this.currentUser.accounts.some(
account => account.api_and_webhooks
);
},
accessTokenDescription() {
if (!this.apiAndWebhooksEnabled) {
return this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.PAID_PLAN_NOTE');
}
return this.replaceInstallationName(
this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE')
);
},
isMfaEnabled() {
return parseBoolean(window.chatwootConfig?.isMfaEnabled);
},
@@ -191,10 +208,14 @@ export default {
useAlert(this.$t('PROFILE_SETTINGS.FORM.SEND_MESSAGE.UPDATE_SUCCESS'));
},
async onCopyToken(value) {
if (!this.apiAndWebhooksEnabled) return;
await copyTextToClipboard(value);
useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
},
async resetAccessToken() {
if (!this.apiAndWebhooksEnabled) return;
const success = await this.$store.dispatch('resetAccessToken');
if (success) {
useAlert(this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET_SUCCESS'));
@@ -339,12 +360,11 @@ export default {
<SectionLayout
with-border
:title="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.TITLE')"
:description="
replaceInstallationName($t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE'))
"
:description="accessTokenDescription"
>
<AccessToken
:value="currentUser.access_token"
:disabled="!apiAndWebhooksEnabled"
@on-copy="onCopyToken"
@on-reset="resetAccessToken"
/>
+2
View File
@@ -108,6 +108,8 @@ class WebhookListener < BaseListener
end
def deliver_account_webhooks(payload, account)
return unless account.api_and_webhooks_enabled?
account.webhooks.account_type.each do |webhook|
next unless webhook.subscriptions.include?(payload[:event])
+4
View File
@@ -154,6 +154,10 @@ class Account < ApplicationRecord
}
end
def api_and_webhooks_enabled?
true
end
def locale_english_name
# the locale can also be something like pt_BR, en_US, fr_FR, etc.
# the format is `<locale_code>_<country_code>`
+2 -1
View File
@@ -1,4 +1,4 @@
json.access_token resource.access_token.token
json.access_token resource.accounts.any?(&:api_and_webhooks_enabled?) ? resource.access_token.token : ''
json.account_id resource.active_account_user&.account_id
json.available_name resource.available_name
json.avatar_url resource.avatar_url
@@ -31,6 +31,7 @@ json.accounts do
# availability derived from presence
json.availability_status account_user.availability_status
json.auto_offline account_user.auto_offline
json.api_and_webhooks account_user.account.feature_enabled?('api_and_webhooks')
json.partial! 'api/v1/models/account_user', account_user: account_user if ChatwootApp.enterprise?
end
end
@@ -1,6 +1,7 @@
class Enterprise::Api::V1::AccountsController < Api::BaseController
include BillingHelper
before_action :fetch_account
before_action :validate_token_api_access, if: :authenticate_by_access_token?
before_action :check_authorization
before_action :check_cloud_env, only: [:limits, :toggle_deletion, :topup_options]
@@ -89,6 +90,12 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
private
def validate_token_api_access
return if @account.api_and_webhooks_enabled?
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
end
def check_cloud_env
render json: { error: 'Not found' }, status: :not_found unless ChatwootApp.chatwoot_cloud?
end
@@ -73,6 +73,12 @@ module Enterprise::Account
saml_settings&.saml_enabled? || false
end
def api_and_webhooks_enabled?
return true unless ChatwootApp.chatwoot_cloud?
feature_enabled?('api_and_webhooks')
end
def billing_currency
# Feature off => everyone is billed in USD (legacy behaviour).
return Enterprise::Billing::Currencies::DEFAULT unless Enterprise::Billing::Currencies.enabled?
@@ -23,6 +23,52 @@ RSpec.describe 'API Base', type: :request do
end
end
context 'when API and webhook access is disabled for the account' do
let!(:admin) { create(:user, :administrator, account: account) }
let!(:conversation) { create(:conversation, account: account) }
before do
allow(Account).to receive(:find).and_call_original
allow(Account).to receive(:find).with(account.id.to_s).and_return(account)
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
end
it 'returns forbidden for token authenticated requests' do
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
headers: { api_access_token: admin.access_token.token },
as: :json
expect(response).to have_http_status(:forbidden)
expect(response.parsed_body['error']).to eq('API access is not enabled for this account')
end
it 'allows session authenticated requests' do
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
end
end
context 'when a self-hosted account has the feature flag disabled' do
let!(:admin) { create(:user, :administrator, account: account) }
let!(:conversation) { create(:conversation, account: account) }
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
account.disable_features!('api_and_webhooks')
end
it 'allows token authenticated requests' do
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
headers: { api_access_token: admin.access_token.token },
as: :json
expect(response).to have_http_status(:success)
end
end
context 'when it is an invalid api_access_token' do
it 'returns unauthorized' do
get '/api/v1/profile',
@@ -94,6 +140,21 @@ RSpec.describe 'API Base', type: :request do
end
end
context 'when API and webhook access is disabled for the account' do
it 'returns forbidden for accessible bot endpoints' do
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
allow(Account).to receive(:find).and_call_original
allow(Account).to receive(:find).with(account.id.to_s).and_return(account)
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
headers: { api_access_token: agent_bot.access_token.token },
as: :json
expect(response).to have_http_status(:forbidden)
end
end
context 'when the account is suspended' do
it 'returns 401 unauthorized' do
account.update!(status: :suspended)
@@ -26,6 +26,17 @@ RSpec.describe 'Webhooks API', type: :request do
expect(response.parsed_body['payload']['webhooks'].count).to eql account.webhooks.count
end
end
context 'when api_and_webhooks feature is disabled' do
it 'allows session authenticated admins to manage webhooks' do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
account.disable_features!('api_and_webhooks')
get "/api/v1/accounts/#{account.id}/webhooks",
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
end
end
end
describe 'POST /api/v1/accounts/<account_id>/webhooks' do
@@ -199,6 +199,22 @@ RSpec.describe 'Accounts API', type: :request do
expect(response.body).to include(account.locale)
end
end
context 'when API and webhook access is disabled for the account' do
it 'returns forbidden for API token authentication' do
account_scope = double
allow(account_scope).to receive(:find).with(account.id.to_s).and_return(account)
allow_any_instance_of(User).to receive(:accounts).and_return(account_scope) # rubocop:disable RSpec/AnyInstance
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
get "/api/v1/accounts/#{account.id}",
headers: { api_access_token: admin.access_token.token },
as: :json
expect(response).to have_http_status(:forbidden)
expect(response.parsed_body['error']).to eq('API access is not enabled for this account')
end
end
end
describe 'GET /api/v1/accounts/{account.id}/cache_keys' do
@@ -225,6 +241,21 @@ RSpec.describe 'Accounts API', type: :request do
expect(response.headers['Cache-Control']).to include('private')
expect(response.headers['Cache-Control']).to include('stale-while-revalidate=300')
end
context 'when API and webhook access is disabled for the account' do
it 'returns forbidden for API token authentication' do
account_scope = double
allow(account_scope).to receive(:find).with(account.id.to_s).and_return(account)
allow_any_instance_of(User).to receive(:accounts).and_return(account_scope) # rubocop:disable RSpec/AnyInstance
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
get "/api/v1/accounts/#{account.id}/cache_keys",
headers: { api_access_token: admin.access_token.token },
as: :json
expect(response).to have_http_status(:forbidden)
end
end
end
describe 'PATCH /api/v1/accounts/{account.id}' do
@@ -324,6 +355,24 @@ RSpec.describe 'Accounts API', type: :request do
expect(json_response['message']).to eq('Name is too long (maximum is 255 characters)')
end
end
context 'when API and webhook access is disabled for the account' do
it 'returns forbidden without modifying the account for API token authentication' do
account_scope = double
allow(account_scope).to receive(:find).with(account.id.to_s).and_return(account)
allow_any_instance_of(User).to receive(:accounts).and_return(account_scope) # rubocop:disable RSpec/AnyInstance
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
expect do
patch "/api/v1/accounts/#{account.id}",
params: { name: 'Updated through API' },
headers: { api_access_token: admin.access_token.token },
as: :json
end.not_to(change { account.reload.name })
expect(response).to have_http_status(:forbidden)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/update_active_at' do
@@ -349,5 +398,22 @@ RSpec.describe 'Accounts API', type: :request do
expect(agent.account_users.first.active_at).not_to be_nil
end
end
context 'when API and webhook access is disabled for the account' do
it 'returns forbidden without updating active_at for API token authentication' do
account_scope = double
allow(account_scope).to receive(:find).with(account.id.to_s).and_return(account)
allow_any_instance_of(User).to receive(:accounts).and_return(account_scope) # rubocop:disable RSpec/AnyInstance
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
account_user = agent.account_users.first
post "/api/v1/accounts/#{account.id}/update_active_at",
headers: { api_access_token: agent.access_token.token },
as: :json
expect(response).to have_http_status(:forbidden)
expect(account_user.reload.active_at).to be_nil
end
end
end
end
@@ -29,6 +29,49 @@ RSpec.describe 'Profile API', type: :request do
expect(json_response['custom_attributes']['test']).to eq('test')
expect(json_response['message_signature']).to be_nil
end
it 'returns an empty access token when all accounts have API and webhook access disabled' do
account.disable_features!('api_and_webhooks')
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
allow_any_instance_of(User).to receive(:accounts).and_return([account]) # rubocop:disable RSpec/AnyInstance
get '/api/v1/profile',
headers: agent.create_new_auth_token,
as: :json
json_response = response.parsed_body
expect(json_response['access_token']).to eq('')
expect(json_response['accounts'].first['api_and_webhooks']).to be false
end
it 'returns the access token when any account has API and webhook access enabled' do
account.disable_features!('api_and_webhooks')
enabled_account = create(:account)
enabled_account.enable_features!('api_and_webhooks')
create(:account_user, account: enabled_account, user: agent)
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
allow(enabled_account).to receive(:api_and_webhooks_enabled?).and_return(true)
allow_any_instance_of(User).to receive(:accounts).and_return([account, enabled_account]) # rubocop:disable RSpec/AnyInstance
get '/api/v1/profile',
headers: agent.create_new_auth_token,
as: :json
json_response = response.parsed_body
expect(json_response['access_token']).to eq(agent.access_token.token)
expect(json_response['accounts'].find { |item| item['id'] == enabled_account.id }['api_and_webhooks']).to be true
end
it 'returns the access token for self-hosted accounts even when the stored feature flag is disabled' do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
account.disable_features!('api_and_webhooks')
get '/api/v1/profile',
headers: agent.create_new_auth_token,
as: :json
expect(response.parsed_body['access_token']).to eq(agent.access_token.token)
end
end
end
@@ -338,6 +381,21 @@ RSpec.describe 'Profile API', type: :request do
json_response = response.parsed_body
expect(json_response['access_token']).to eq(agent.access_token.token)
end
it 'regenerates the stored token but returns an empty token when no account has API and webhook access enabled' do
account.disable_features!('api_and_webhooks')
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
allow_any_instance_of(User).to receive(:accounts).and_return([account]) # rubocop:disable RSpec/AnyInstance
old_token = agent.access_token.token
post '/api/v1/profile/reset_access_token',
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(agent.reload.access_token.token).not_to eq(old_token)
expect(response.parsed_body['access_token']).to eq('')
end
end
end
end
@@ -5,6 +5,30 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
let!(:admin) { create(:user, account: account, role: :administrator) }
let!(:agent) { create(:user, account: account, role: :agent) }
describe 'API token access' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
account.disable_features!('api_and_webhooks')
end
it 'returns forbidden when API and webhook access is disabled for the account' do
get "/enterprise/api/v1/accounts/#{account.id}/limits",
headers: { api_access_token: admin.access_token.token },
as: :json
expect(response).to have_http_status(:forbidden)
expect(response.parsed_body['error']).to eq('API access is not enabled for this account')
end
it 'allows session-authenticated requests' do
get "/enterprise/api/v1/accounts/#{account.id}/limits",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
end
end
describe 'POST /enterprise/api/v1/accounts/{account.id}/subscription' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
+22
View File
@@ -32,6 +32,28 @@ RSpec.describe Account, type: :model do
end
end
describe '#api_and_webhooks_enabled?' do
let(:account) { create(:account) }
it 'is always enabled for self-hosted enterprise accounts' do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
account.disable_features!('api_and_webhooks')
expect(account.api_and_webhooks_enabled?).to be true
end
it 'uses the account feature flag on Chatwoot Cloud' do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
account.disable_features!('api_and_webhooks')
expect(account.api_and_webhooks_enabled?).to be false
account.enable_features!('api_and_webhooks')
expect(account.api_and_webhooks_enabled?).to be true
end
end
describe 'sla_policies' do
let!(:account) { create(:account) }
let!(:sla_policy) { create(:sla_policy, account: account) }
+44
View File
@@ -44,6 +44,50 @@ describe WebhookListener do
end
end
context 'when API and webhook access is disabled for the account' do
before do
allow(account).to receive(:api_and_webhooks_enabled?).and_return(false)
allow(message).to receive(:inbox).and_return(inbox)
allow(inbox).to receive(:account).and_return(account)
end
it 'does not trigger account webhooks' do
create(:webhook, inbox: inbox, account: account)
expect(WebhookJob).not_to receive(:perform_later)
listener.message_created(message_created_event)
end
it 'still triggers API inbox webhooks' do
channel_api = create(:channel_api, account: account)
api_inbox = channel_api.inbox
api_conversation = create(:conversation, account: account, inbox: api_inbox, assignee: user)
api_message = create(:message, message_type: 'outgoing', account: account, inbox: api_inbox, conversation: api_conversation)
api_event = Events::Base.new(event_name, Time.zone.now, message: api_message)
allow(api_message).to receive(:inbox).and_return(api_inbox)
allow(api_inbox).to receive(:account).and_return(account)
expect(WebhookJob).to receive(:perform_later).with(
channel_api.webhook_url, api_message.webhook_data.merge(event: 'message_created'),
:api_inbox_webhook, secret: channel_api.secret, delivery_id: instance_of(String)
).once
listener.message_created(api_event)
end
end
context 'when api_and_webhooks feature is disabled on self-hosted' do
it 'still triggers account webhooks' do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
account.disable_features!('api_and_webhooks')
webhook = create(:webhook, inbox: inbox, account: account)
expect(WebhookJob).to receive(:perform_later).with(
webhook.url, message.webhook_data.merge(event: 'message_created'), :account_webhook,
secret: webhook.secret, delivery_id: instance_of(String)
).once
listener.message_created(message_created_event)
end
end
context 'when inbox is an API Channel' do
it 'triggers webhook if webhook_url is present' do
channel_api = create(:channel_api, account: account)
+9
View File
@@ -50,6 +50,15 @@ RSpec.describe Account do
end
end
describe '#api_and_webhooks_enabled?' do
it 'is enabled for self-hosted accounts regardless of the stored feature flag' do
account = create(:account)
account.disable_features!('api_and_webhooks')
expect(account.api_and_webhooks_enabled?).to be true
end
end
describe 'captain defaults for new accounts' do
it 'does not store Captain model overrides or enable premium Captain features' do
InstallationConfig.find_or_initialize_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS').update!(