Files
chatwoot/spec/controllers/api/base_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

183 lines
6.8 KiB
Ruby

require 'rails_helper'
RSpec.describe 'API Base', type: :request do
let!(:account) { create(:account) }
let!(:user) { create(:user, account: account) }
describe 'request with api_access_token for user' do
context 'when accessing an account scoped resource' do
let!(:admin) { create(:user, :administrator, account: account) }
let!(:conversation) { create(:conversation, account: account) }
it 'sets Current attributes for the request and then returns the response' do
# This test verifies that Current.user, Current.account, and Current.account_user
# are properly set during request processing. We verify this indirectly:
# - A successful response proves Current.account_user was set (required for authorization)
# - The correct conversation data proves Current.account was set (scopes the query)
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)
expect(response.parsed_body['id']).to eq(conversation.display_id)
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',
headers: { api_access_token: 'invalid' },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is a valid api_access_token' do
it 'returns current user information' do
get '/api/v1/profile',
headers: { api_access_token: user.access_token.token },
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['id']).to eq(user.id)
expect(json_response['email']).to eq(user.email)
end
end
end
describe 'request with api_access_token for a super admin' do
before do
user.update!(type: 'SuperAdmin')
end
context 'when its a valid api_access_token' do
it 'returns current user information' do
get '/api/v1/profile',
headers: { api_access_token: user.access_token.token },
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['id']).to eq(user.id)
expect(json_response['email']).to eq(user.email)
end
end
end
describe 'request with api_access_token for bot' do
let!(:agent_bot) { create(:agent_bot) }
let!(:inbox) { create(:inbox, account: account) }
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user, status: 'pending') }
context 'when it is an unauthorized url' do
it 'returns unauthorized' do
get '/api/v1/profile',
headers: { api_access_token: agent_bot.access_token.token },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is a accessible url' do
it 'returns success' do
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
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(:success)
expect(conversation.reload.status).to eq('open')
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)
post "/api/v1/accounts/#{account.id}/canned_responses",
headers: { api_access_token: user.access_token.token },
as: :json
expect(response).to have_http_status(:unauthorized)
end
# this exception occured in a client instance (DoubleRender error)
it 'will not throw exception if user does not have access to suspended account' do
user_with_out_access = create(:user)
account.update!(status: :suspended)
post "/api/v1/accounts/#{account.id}/canned_responses",
headers: { api_access_token: user_with_out_access.access_token.token },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
end