Files
chatwoot/spec/controllers/api/v1/accounts/webhook_controller_spec.rb
522e3c4d3f 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>
2026-07-16 13:43:49 +04:00

158 lines
6.4 KiB
Ruby

require 'rails_helper'
RSpec.describe 'Webhooks API', type: :request do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:webhook) { create(:webhook, account: account, inbox: inbox, url: 'https://hello.com', name: 'My Webhook') }
let(:administrator) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
describe 'GET /api/v1/accounts/<account_id>/webhooks' do
context 'when it is an authenticated agent' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/webhooks",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated admin user' do
it 'gets all webhook' do
get "/api/v1/accounts/#{account.id}/webhooks",
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
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
context 'when it is an authenticated agent' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/webhooks",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated admin user' do
it 'creates webhook' do
post "/api/v1/accounts/#{account.id}/webhooks",
params: { account_id: account.id, inbox_id: inbox.id, url: 'https://hello.com' },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']['webhook']['url']).to eql 'https://hello.com'
end
it 'creates webhook with name' do
post "/api/v1/accounts/#{account.id}/webhooks",
params: { account_id: account.id, inbox_id: inbox.id, url: 'https://hello.com', name: 'My Webhook' },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']['webhook']['name']).to eql 'My Webhook'
end
it 'throws error when invalid url provided' do
post "/api/v1/accounts/#{account.id}/webhooks",
params: { account_id: account.id, inbox_id: inbox.id, url: 'javascript:alert(1)' },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['message']).to eql 'Url is invalid'
end
it 'throws error if subscription events are invalid' do
post "/api/v1/accounts/#{account.id}/webhooks",
params: { url: 'https://hello.com', subscriptions: ['conversation_random_event'] },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['message']).to eql 'Subscriptions Invalid events'
end
it 'throws error if subscription events are empty' do
post "/api/v1/accounts/#{account.id}/webhooks",
params: { url: 'https://hello.com', subscriptions: [] },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['message']).to eql 'Subscriptions Invalid events'
end
it 'use default if subscription events are nil' do
post "/api/v1/accounts/#{account.id}/webhooks",
params: { url: 'https://hello.com', subscriptions: nil },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(
response.parsed_body['payload']['webhook']['subscriptions']
).to eql %w[conversation_status_changed conversation_updated conversation_created contact_created contact_updated
message_created message_updated webwidget_triggered]
end
end
end
describe 'PUT /api/v1/accounts/<account_id>/webhooks/:id' do
context 'when it is an authenticated agent' do
it 'returns unauthorized' do
put "/api/v1/accounts/#{account.id}/webhooks/#{webhook.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated admin user' do
it 'updates webhook' do
put "/api/v1/accounts/#{account.id}/webhooks/#{webhook.id}",
params: { url: 'https://hello.com', name: 'Another Webhook' },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']['webhook']['url']).to eql 'https://hello.com'
expect(response.parsed_body['payload']['webhook']['name']).to eql 'Another Webhook'
end
end
end
describe 'DELETE /api/v1/accounts/<account_id>/webhooks/:id' do
context 'when it is an authenticated agent' do
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/webhooks/#{webhook.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated admin user' do
it 'deletes webhook' do
delete "/api/v1/accounts/#{account.id}/webhooks/#{webhook.id}",
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(account.webhooks.count).to be 0
end
end
end
end