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>
420 lines
16 KiB
Ruby
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
|