Files
chatwoot/spec/controllers/api/v1/accounts_controller_spec.rb
T
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

420 lines
16 KiB
Ruby

require 'rails_helper'
RSpec.describe 'Accounts API', type: :request do
describe 'POST /api/v1/accounts' do
let(:email) { Faker::Internet.email }
let(:user_full_name) { Faker::Name.name_with_middle }
context 'when posting to accounts with correct parameters' do
let(:account_builder) { double }
let(:account) { create(:account) }
let(:user) { create(:user, email: email, account: account, name: user_full_name) }
before do
allow(AccountBuilder).to receive(:new).and_return(account_builder)
end
it 'calls account builder' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
allow(account_builder).to receive(:perform).and_return([user, account])
params = { account_name: 'test', email: email, user: nil, locale: nil, user_full_name: user_full_name, password: 'Password1!' }
post api_v1_accounts_url,
params: params,
as: :json
expect(AccountBuilder).to have_received(:new).with(params.except(:password).merge(user_password: params[:password]))
expect(account_builder).to have_received(:perform)
expect(response.headers.keys).not_to include('access-token', 'token-type', 'client', 'expiry', 'uid')
expect(response.parsed_body['email']).to eq(email)
end
end
it 'calls ChatwootCaptcha' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
captcha = double
allow(account_builder).to receive(:perform).and_return([user, account])
allow(ChatwootCaptcha).to receive(:new).and_return(captcha)
allow(captcha).to receive(:valid?).and_return(true)
params = { account_name: 'test', email: email, user: nil, locale: nil, user_full_name: user_full_name, password: 'Password1!',
h_captcha_client_response: '123' }
post api_v1_accounts_url,
params: params,
as: :json
expect(ChatwootCaptcha).to have_received(:new).with('123')
expect(response.headers.keys).not_to include('access-token', 'token-type', 'client', 'expiry', 'uid')
expect(response.parsed_body['email']).to eq(email)
end
end
it 'renders error response on invalid params' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
allow(account_builder).to receive(:perform).and_return(nil)
params = { account_name: nil, email: nil, user: nil, locale: 'en', user_full_name: nil }
post api_v1_accounts_url,
params: params,
as: :json
expect(AccountBuilder).not_to have_received(:new)
expect(response).to have_http_status(:forbidden)
expect(response.body).to eq({ message: I18n.t('errors.signup.invalid_params') }.to_json)
end
end
end
context 'when an authenticated user creates a second account' do
let(:existing_user) { create(:user, password: 'Password1!') }
it 'returns the full response with account_id' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
post api_v1_accounts_url,
params: { account_name: 'Second Account', email: existing_user.email,
user_full_name: existing_user.name, password: 'Password1!' },
headers: existing_user.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body.dig('data', 'account_id')).to be_present
end
end
end
context 'when ENABLE_ACCOUNT_SIGNUP env variable is set to false' do
it 'responds 404 on requests' do
params = { account_name: 'test', email: email, user_full_name: user_full_name }
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'false' do
post api_v1_accounts_url,
params: params,
as: :json
expect(response).to have_http_status(:not_found)
end
end
end
context 'when ENABLE_ACCOUNT_SIGNUP is stored as boolean false' do
before do
GlobalConfig.clear_cache
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
InstallationConfig.create!(name: 'ENABLE_ACCOUNT_SIGNUP', value: false, locked: false)
end
after do
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
GlobalConfig.clear_cache
end
it 'responds 404 on requests' do
params = { account_name: 'test', email: email, user_full_name: user_full_name, password: 'Password1!' }
post api_v1_accounts_url,
params: params,
as: :json
expect(response).to have_http_status(:not_found)
end
end
context 'when ENABLE_ACCOUNT_SIGNUP env variable is set to api_only' do
before do
GlobalConfig.clear_cache
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
end
after do
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
GlobalConfig.clear_cache
end
it 'returns auth headers and full response for api_only signup' do
params = { account_name: 'test', email: email, user_full_name: user_full_name, password: 'Password1!' }
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'api_only' do
post api_v1_accounts_url,
params: params,
as: :json
expect(response).to have_http_status(:success)
expect(response.headers.keys).to include('access-token', 'token-type', 'client', 'expiry', 'uid')
end
end
end
context 'when CW_API_ONLY_SERVER is true' do
it 'returns auth headers and full response' do
params = { account_name: 'test', email: email, user_full_name: user_full_name, password: 'Password1!' }
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true', CW_API_ONLY_SERVER: 'true' do
post api_v1_accounts_url,
params: params,
as: :json
expect(response).to have_http_status(:success)
expect(response.headers.keys).to include('access-token', 'token-type', 'client', 'expiry', 'uid')
end
end
end
end
describe 'GET /api/v1/accounts/{account.id}' do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:user_without_access) { create(:user) }
let(:admin) { create(:user, account: account, role: :administrator) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an unauthorized user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}",
headers: user_without_access.create_new_auth_token
expect(response).to have_http_status(:not_found)
end
end
context 'when it is an authenticated user' do
it 'shows an account' do
account.update(name: 'new name')
get "/api/v1/accounts/#{account.id}",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(response.body).to include(account.name)
expect(response.body).to include(account.locale)
expect(response.body).to include(account.domain)
expect(response.body).to include(account.support_email)
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
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
it 'returns cache_keys as expected' do
account.update(auto_resolve_duration: 30)
get "/api/v1/accounts/#{account.id}/cache_keys",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['cache_keys'].keys).to match_array(%w[label inbox team])
end
it 'sets the appropriate cache headers' do
get "/api/v1/accounts/#{account.id}/cache_keys",
headers: admin.create_new_auth_token,
as: :json
expect(response.headers['Cache-Control']).to include('max-age=10')
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
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:admin) { create(:user, account: account, role: :administrator) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
patch "/api/v1/accounts/#{account.id}"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an unauthorized user' do
it 'returns unauthorized' do
patch "/api/v1/accounts/#{account.id}",
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
params = {
name: 'New Name',
locale: 'en',
domain: 'example.com',
support_email: 'care@example.com',
auto_resolve_after: 40,
auto_resolve_message: 'Auto resolved',
auto_resolve_ignore_waiting: false,
timezone: 'Asia/Kolkata',
industry: 'Technology',
company_size: '1-10'
}
it 'returns a valid schema' do
patch "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(response).to conform_schema(200)
end
it 'modifies an account' do
patch "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(account.reload.name).to eq(params[:name])
expect(account.reload.locale).to eq(params[:locale])
expect(account.reload.domain).to eq(params[:domain])
expect(account.reload.support_email).to eq(params[:support_email])
%w[auto_resolve_after auto_resolve_message auto_resolve_ignore_waiting].each do |attribute|
expect(account.reload.settings[attribute]).to eq(params[attribute.to_sym])
end
%w[timezone industry company_size].each do |attribute|
expect(account.reload.custom_attributes[attribute]).to eq(params[attribute.to_sym])
end
end
it 'updates onboarding step to invite_team if onboarding step is present in account custom attributes' do
account.update(custom_attributes: { onboarding_step: 'account_update' })
patch "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team')
end
it 'will not update onboarding step if onboarding step is not present in account custom attributes' do
patch "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(account.reload.custom_attributes['onboarding_step']).to be_nil
end
it 'Throws error 422' do
params[:name] = 'test' * 999
patch "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
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
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/update_active_at"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
it 'modifies an account' do
expect(agent.account_users.first.active_at).to be_nil
post "/api/v1/accounts/#{account.id}/update_active_at",
params: {},
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
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