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>
402 lines
14 KiB
Ruby
402 lines
14 KiB
Ruby
require 'rails_helper'
|
|
|
|
RSpec.describe 'Profile API', type: :request do
|
|
let(:account) { create(:account) }
|
|
|
|
describe 'GET /api/v1/profile' do
|
|
context 'when it is an unauthenticated user' do
|
|
it 'returns unauthorized' do
|
|
get '/api/v1/profile'
|
|
|
|
expect(response).to have_http_status(:unauthorized)
|
|
end
|
|
end
|
|
|
|
context 'when it is an authenticated user' do
|
|
let(:agent) { create(:user, account: account, custom_attributes: { test: 'test' }, role: :agent) }
|
|
|
|
it 'returns current user information' do
|
|
get '/api/v1/profile',
|
|
headers: agent.create_new_auth_token,
|
|
as: :json
|
|
|
|
expect(response).to have_http_status(:success)
|
|
expect(response).to conform_schema(200)
|
|
json_response = response.parsed_body
|
|
expect(json_response['id']).to eq(agent.id)
|
|
expect(json_response['email']).to eq(agent.email)
|
|
expect(json_response['access_token']).to eq(agent.access_token.token)
|
|
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
|
|
|
|
describe 'PUT /api/v1/profile' do
|
|
context 'when it is an unauthenticated user' do
|
|
it 'returns unauthorized' do
|
|
put '/api/v1/profile'
|
|
|
|
expect(response).to have_http_status(:unauthorized)
|
|
end
|
|
end
|
|
|
|
context 'when it is an authenticated user' do
|
|
let(:agent) { create(:user, password: 'Test123!', account: account, role: :agent) }
|
|
|
|
it 'updates the name' do
|
|
put '/api/v1/profile',
|
|
params: { profile: { name: 'test' } },
|
|
headers: agent.create_new_auth_token,
|
|
as: :json
|
|
|
|
expect(response).to have_http_status(:success)
|
|
expect(response).to conform_schema(200)
|
|
json_response = response.parsed_body
|
|
agent.reload
|
|
expect(json_response['id']).to eq(agent.id)
|
|
expect(json_response['name']).to eq(agent.name)
|
|
expect(agent.name).to eq('test')
|
|
end
|
|
|
|
it 'updates custom attributes' do
|
|
put '/api/v1/profile',
|
|
params: { profile: { phone_number: '+123456789' } },
|
|
headers: agent.create_new_auth_token,
|
|
as: :json
|
|
|
|
expect(response).to have_http_status(:success)
|
|
expect(response).to conform_schema(200)
|
|
agent.reload
|
|
|
|
expect(agent.custom_attributes['phone_number']).to eq('+123456789')
|
|
end
|
|
|
|
it 'updates the message_signature' do
|
|
put '/api/v1/profile',
|
|
params: { profile: { name: 'test', message_signature: 'Thanks\nMy Signature' } },
|
|
headers: agent.create_new_auth_token,
|
|
as: :json
|
|
|
|
expect(response).to have_http_status(:success)
|
|
json_response = response.parsed_body
|
|
agent.reload
|
|
expect(json_response['id']).to eq(agent.id)
|
|
expect(json_response['name']).to eq(agent.name)
|
|
expect(agent.name).to eq('test')
|
|
expect(json_response['message_signature']).to eq('Thanks\nMy Signature')
|
|
end
|
|
|
|
it 'updates the password when current password is provided' do
|
|
put '/api/v1/profile',
|
|
params: { profile: { current_password: 'Test123!', password: 'Test1234!', password_confirmation: 'Test1234!' } },
|
|
headers: agent.create_new_auth_token,
|
|
as: :json
|
|
|
|
expect(response).to have_http_status(:success)
|
|
expect(response).to conform_schema(200)
|
|
expect(agent.reload.valid_password?('Test1234!')).to be true
|
|
end
|
|
|
|
it 'does not reset the display name if updates the password' do
|
|
display_name = agent.display_name
|
|
|
|
put '/api/v1/profile',
|
|
params: { profile: { current_password: 'Test123!', password: 'Test1234!', password_confirmation: 'Test1234!' } },
|
|
headers: agent.create_new_auth_token,
|
|
as: :json
|
|
|
|
expect(response).to have_http_status(:success)
|
|
expect(agent.reload.display_name).to eq(display_name)
|
|
end
|
|
|
|
it 'throws error when current password provided is invalid' do
|
|
put '/api/v1/profile',
|
|
params: { profile: { current_password: 'Test', password: 'test123', password_confirmation: 'test123' } },
|
|
headers: agent.create_new_auth_token,
|
|
as: :json
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
end
|
|
|
|
it 'validate name' do
|
|
user_name = 'test' * 999
|
|
put '/api/v1/profile',
|
|
params: { profile: { name: user_name } },
|
|
headers: agent.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
|
|
|
|
it 'updates avatar' do
|
|
# no avatar before upload
|
|
expect(agent.avatar.attached?).to be(false)
|
|
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
|
|
put '/api/v1/profile',
|
|
params: { profile: { avatar: file } },
|
|
headers: agent.create_new_auth_token
|
|
|
|
expect(response).to have_http_status(:success)
|
|
agent.reload
|
|
expect(agent.avatar.attached?).to be(true)
|
|
end
|
|
|
|
it 'updates the ui settings' do
|
|
put '/api/v1/profile',
|
|
params: { profile: { ui_settings: { is_contact_sidebar_open: false } } },
|
|
headers: agent.create_new_auth_token,
|
|
as: :json
|
|
|
|
expect(response).to have_http_status(:success)
|
|
json_response = response.parsed_body
|
|
expect(json_response['ui_settings']['is_contact_sidebar_open']).to be(false)
|
|
end
|
|
end
|
|
|
|
context 'when an authenticated user updates email' do
|
|
let(:agent) { create(:user, password: 'Test123!', account: account, role: :agent) }
|
|
|
|
it 'populates the unconfirmed email' do
|
|
new_email = Faker::Internet.email
|
|
put '/api/v1/profile',
|
|
params: { profile: { email: new_email } },
|
|
headers: agent.create_new_auth_token,
|
|
as: :json
|
|
|
|
expect(response).to have_http_status(:success)
|
|
agent.reload
|
|
|
|
expect(agent.unconfirmed_email).to eq(new_email)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'DELETE /api/v1/profile/avatar' do
|
|
let(:agent) { create(:user, password: 'Test123!', account: account, role: :agent) }
|
|
|
|
context 'when it is an unauthenticated user' do
|
|
it 'returns unauthorized' do
|
|
delete '/api/v1/profile/avatar'
|
|
|
|
expect(response).to have_http_status(:unauthorized)
|
|
end
|
|
end
|
|
|
|
context 'when it is an authenticated user' do
|
|
before do
|
|
agent.avatar.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
|
end
|
|
|
|
it 'deletes the agent avatar' do
|
|
delete '/api/v1/profile/avatar',
|
|
headers: agent.create_new_auth_token,
|
|
as: :json
|
|
|
|
expect(response).to have_http_status(:success)
|
|
json_response = response.parsed_body
|
|
expect(json_response['avatar_url']).to be_empty
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'POST /api/v1/profile/availability' do
|
|
context 'when it is an unauthenticated user' do
|
|
it 'returns unauthorized' do
|
|
post '/api/v1/profile/availability'
|
|
|
|
expect(response).to have_http_status(:unauthorized)
|
|
end
|
|
end
|
|
|
|
context 'when it is an authenticated user' do
|
|
let(:agent) { create(:user, password: 'Test123!', account: account, role: :agent) }
|
|
|
|
it 'updates the availability status' do
|
|
post '/api/v1/profile/availability',
|
|
params: { profile: { availability: 'busy', account_id: account.id } },
|
|
headers: agent.create_new_auth_token,
|
|
as: :json
|
|
|
|
expect(response).to have_http_status(:success)
|
|
expect(OnlineStatusTracker.get_status(account.id, agent.id)).to eq('busy')
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'POST /api/v1/profile/auto_offline' do
|
|
context 'when it is an unauthenticated user' do
|
|
it 'returns unauthorized' do
|
|
post '/api/v1/profile/auto_offline'
|
|
expect(response).to have_http_status(:unauthorized)
|
|
end
|
|
end
|
|
|
|
context 'when it is an authenticated user' do
|
|
let(:agent) { create(:user, password: 'Test123!', account: account, role: :agent) }
|
|
|
|
it 'updates the auto offline status' do
|
|
post '/api/v1/profile/auto_offline',
|
|
params: { profile: { auto_offline: false, account_id: account.id } },
|
|
headers: agent.create_new_auth_token,
|
|
as: :json
|
|
|
|
expect(response).to have_http_status(:success)
|
|
json_response = response.parsed_body
|
|
expect(json_response['accounts'].first['auto_offline']).to be(false)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'PUT /api/v1/profile/set_active_account' do
|
|
context 'when it is an unauthenticated user' do
|
|
it 'returns unauthorized' do
|
|
put '/api/v1/profile/set_active_account'
|
|
|
|
expect(response).to have_http_status(:unauthorized)
|
|
end
|
|
end
|
|
|
|
context 'when it is an authenticated user' do
|
|
let(:agent) { create(:user, password: 'Test123!', account: account, role: :agent) }
|
|
|
|
it 'updates the last active account id' do
|
|
put '/api/v1/profile/set_active_account',
|
|
params: { profile: { account_id: account.id } },
|
|
headers: agent.create_new_auth_token,
|
|
as: :json
|
|
|
|
expect(response).to have_http_status(:success)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'POST /api/v1/profile/resend_confirmation' do
|
|
context 'when it is an unauthenticated user' do
|
|
it 'returns unauthorized' do
|
|
post '/api/v1/profile/resend_confirmation'
|
|
|
|
expect(response).to have_http_status(:unauthorized)
|
|
end
|
|
end
|
|
|
|
context 'when it is an authenticated user' do
|
|
let(:agent) do
|
|
create(:user, password: 'Test123!', email: 'test-unconfirmed@email.com', account: account, role: :agent,
|
|
unconfirmed_email: 'test-unconfirmed@email.com')
|
|
end
|
|
|
|
it 'does not send the confirmation email if the user is already confirmed' do
|
|
expect do
|
|
post '/api/v1/profile/resend_confirmation',
|
|
headers: agent.create_new_auth_token,
|
|
as: :json
|
|
end.not_to have_enqueued_mail(Devise::Mailer, :confirmation_instructions)
|
|
|
|
expect(response).to have_http_status(:success)
|
|
end
|
|
|
|
it 'resends the confirmation email if the user is unconfirmed' do
|
|
agent.confirmed_at = nil
|
|
agent.save!
|
|
|
|
expect do
|
|
post '/api/v1/profile/resend_confirmation',
|
|
headers: agent.create_new_auth_token,
|
|
as: :json
|
|
end.to have_enqueued_mail(Devise::Mailer, :confirmation_instructions)
|
|
|
|
expect(response).to have_http_status(:success)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'POST /api/v1/profile/reset_access_token' do
|
|
context 'when it is an unauthenticated user' do
|
|
it 'returns unauthorized' do
|
|
post '/api/v1/profile/reset_access_token'
|
|
|
|
expect(response).to have_http_status(:unauthorized)
|
|
end
|
|
end
|
|
|
|
context 'when it is an authenticated user' do
|
|
let(:agent) { create(:user, account: account, role: :agent) }
|
|
|
|
it 'regenerates the access token' do
|
|
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)
|
|
agent.reload
|
|
expect(agent.access_token.token).not_to eq(old_token)
|
|
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
|