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>
424 lines
15 KiB
Ruby
424 lines
15 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'rails_helper'
|
|
|
|
RSpec.describe Account, type: :model do
|
|
include ActiveJob::TestHelper
|
|
|
|
describe 'associations' do
|
|
it { is_expected.to have_many(:sla_policies).dependent(:destroy_async) }
|
|
it { is_expected.to have_many(:applied_slas).dependent(:destroy_async) }
|
|
it { is_expected.to have_many(:custom_roles).dependent(:destroy_async) }
|
|
end
|
|
|
|
describe '#selected_feature_flags=' do
|
|
it 'keeps advanced assignment enabled when assignment v2 is selected for a business account' do
|
|
account = build(:account, custom_attributes: { 'plan_name' => 'Business' })
|
|
|
|
account.selected_feature_flags = [:feature_assignment_v2]
|
|
|
|
expect(account).to be_feature_assignment_v2
|
|
expect(account).to be_feature_advanced_assignment
|
|
end
|
|
|
|
it 'disables advanced assignment when assignment v2 is not selected' do
|
|
account = build(:account, custom_attributes: { 'plan_name' => 'Business' })
|
|
account.enable_features(:assignment_v2, :advanced_assignment)
|
|
|
|
account.selected_feature_flags = []
|
|
|
|
expect(account).not_to be_feature_assignment_v2
|
|
expect(account).not_to be_feature_advanced_assignment
|
|
end
|
|
end
|
|
|
|
describe '#api_and_webhooks_enabled?' do
|
|
let(:account) { create(:account) }
|
|
|
|
it 'is always enabled for self-hosted enterprise accounts' do
|
|
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
|
|
account.disable_features!('api_and_webhooks')
|
|
|
|
expect(account.api_and_webhooks_enabled?).to be true
|
|
end
|
|
|
|
it 'uses the account feature flag on Chatwoot Cloud' do
|
|
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
|
account.disable_features!('api_and_webhooks')
|
|
|
|
expect(account.api_and_webhooks_enabled?).to be false
|
|
|
|
account.enable_features!('api_and_webhooks')
|
|
|
|
expect(account.api_and_webhooks_enabled?).to be true
|
|
end
|
|
end
|
|
|
|
describe 'sla_policies' do
|
|
let!(:account) { create(:account) }
|
|
let!(:sla_policy) { create(:sla_policy, account: account) }
|
|
|
|
it 'returns associated sla policies' do
|
|
expect(account.sla_policies).to eq([sla_policy])
|
|
end
|
|
|
|
it 'deletes associated sla policies' do
|
|
perform_enqueued_jobs do
|
|
account.destroy!
|
|
end
|
|
expect { sla_policy.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
|
end
|
|
end
|
|
|
|
context 'with usage_limits' do
|
|
let(:captain_limits) do
|
|
{
|
|
:startups => { :documents => 100, :responses => 100 },
|
|
:business => { :documents => 200, :responses => 300 },
|
|
:enterprise => { :documents => 300, :responses => 500 }
|
|
}.with_indifferent_access
|
|
end
|
|
let(:account) { create(:account, { custom_attributes: { plan_name: 'startups' } }) }
|
|
let(:assistant) { create(:captain_assistant, account: account) }
|
|
|
|
before do
|
|
create(:installation_config, name: 'ACCOUNT_AGENTS_LIMIT', value: 20)
|
|
end
|
|
|
|
describe 'when captain limits are configured' do
|
|
before do
|
|
create_list(:captain_document, 3, account: account, assistant: assistant, status: :available)
|
|
create(:installation_config, name: 'CAPTAIN_CLOUD_PLAN_LIMITS', value: captain_limits.to_json)
|
|
end
|
|
|
|
## Document
|
|
it 'updates document count accurately' do
|
|
account.update_document_usage
|
|
expect(account.custom_attributes['captain_documents_usage']).to eq(3)
|
|
end
|
|
|
|
it 'handles zero documents' do
|
|
account.captain_documents.destroy_all
|
|
account.update_document_usage
|
|
expect(account.custom_attributes['captain_documents_usage']).to eq(0)
|
|
end
|
|
|
|
it 'reflects document limits' do
|
|
document_limits = account.usage_limits[:captain][:documents]
|
|
|
|
expect(document_limits[:consumed]).to eq 3
|
|
expect(document_limits[:current_available]).to eq captain_limits[:startups][:documents] - 3
|
|
end
|
|
|
|
## Responses
|
|
it 'incrementing responses updates usage_limits' do
|
|
account.increment_response_usage
|
|
|
|
responses_limits = account.usage_limits[:captain][:responses]
|
|
|
|
expect(account.custom_attributes['captain_responses_usage']).to eq 1
|
|
expect(responses_limits[:consumed]).to eq 1
|
|
expect(responses_limits[:current_available]).to eq captain_limits[:startups][:responses] - 1
|
|
end
|
|
|
|
it 'reseting responses limits updates usage_limits' do
|
|
account.custom_attributes['captain_responses_usage'] = 30
|
|
account.save!
|
|
|
|
responses_limits = account.usage_limits[:captain][:responses]
|
|
|
|
expect(responses_limits[:consumed]).to eq 30
|
|
expect(responses_limits[:current_available]).to eq captain_limits[:startups][:responses] - 30
|
|
|
|
account.reset_response_usage
|
|
responses_limits = account.usage_limits[:captain][:responses]
|
|
|
|
expect(account.custom_attributes['captain_responses_usage']).to eq 0
|
|
expect(responses_limits[:consumed]).to eq 0
|
|
expect(responses_limits[:current_available]).to eq captain_limits[:startups][:responses]
|
|
end
|
|
|
|
it 'returns monthly limit accurately' do
|
|
%w[startups business enterprise].each do |plan|
|
|
account.custom_attributes = { 'plan_name': plan }
|
|
account.save!
|
|
expect(account.captain_monthly_limit).to eq captain_limits[plan]
|
|
end
|
|
end
|
|
|
|
it 'current_available is never out of bounds' do
|
|
account.custom_attributes['captain_responses_usage'] = 3000
|
|
account.save!
|
|
|
|
responses_limits = account.usage_limits[:captain][:responses]
|
|
expect(responses_limits[:consumed]).to eq 3000
|
|
expect(responses_limits[:current_available]).to eq 0
|
|
|
|
account.custom_attributes['captain_responses_usage'] = -100
|
|
account.save!
|
|
|
|
responses_limits = account.usage_limits[:captain][:responses]
|
|
expect(responses_limits[:consumed]).to eq 0
|
|
expect(responses_limits[:current_available]).to eq captain_limits[:startups][:responses]
|
|
end
|
|
end
|
|
|
|
describe 'when captain limits are not configured' do
|
|
it 'returns default values' do
|
|
account.custom_attributes = { 'plan_name': 'unknown' }
|
|
expect(account.captain_monthly_limit).to eq(
|
|
{ documents: ChatwootApp.max_limit, responses: ChatwootApp.max_limit }.with_indifferent_access
|
|
)
|
|
end
|
|
end
|
|
|
|
describe 'when limits are configured for an account' do
|
|
before do
|
|
create(:installation_config, name: 'CAPTAIN_CLOUD_PLAN_LIMITS', value: captain_limits.to_json)
|
|
account.update(limits: { captain_documents: 5555, captain_responses: 9999 })
|
|
end
|
|
|
|
it 'returns limits based on custom attributes' do
|
|
usage_limits = account.usage_limits
|
|
expect(usage_limits[:captain][:documents][:total_count]).to eq(5555)
|
|
expect(usage_limits[:captain][:responses][:total_count]).to eq(9999)
|
|
end
|
|
end
|
|
|
|
describe 'audit logs' do
|
|
it 'returns audit logs' do
|
|
# checking whether associated_audits method is present
|
|
expect(account.associated_audits.present?).to be false
|
|
end
|
|
|
|
it 'creates audit logs when account is updated' do
|
|
account.update(name: 'New Name')
|
|
expect(Audited::Audit.where(auditable_type: 'Account', action: 'update').count).to eq 1
|
|
end
|
|
end
|
|
|
|
it 'returns max limits from global config when enterprise version' do
|
|
expect(account.usage_limits[:agents]).to eq(20)
|
|
end
|
|
|
|
it 'returns max limits from account when enterprise version' do
|
|
account.update(limits: { agents: 10 })
|
|
expect(account.usage_limits[:agents]).to eq(10)
|
|
end
|
|
|
|
it 'returns limits based on subscription' do
|
|
account.update(limits: { agents: 10 }, custom_attributes: { subscribed_quantity: 5 })
|
|
expect(account.usage_limits[:agents]).to eq(5)
|
|
end
|
|
|
|
it 'returns max limits from global config if account limit is absent' do
|
|
account.update(limits: { agents: '' })
|
|
expect(account.usage_limits[:agents]).to eq(20)
|
|
end
|
|
|
|
it 'returns max limits from app limit if account limit and installation config is absent' do
|
|
account.update(limits: { agents: '' })
|
|
InstallationConfig.where(name: 'ACCOUNT_AGENTS_LIMIT').update(value: '')
|
|
|
|
expect(account.usage_limits[:agents]).to eq(ChatwootApp.max_limit)
|
|
end
|
|
end
|
|
|
|
describe 'subscribed_features' do
|
|
let(:account) { create(:account) }
|
|
let(:plan_features) do
|
|
{
|
|
'hacker' => %w[feature1 feature2],
|
|
'startups' => %w[feature1 feature2 feature3 feature4]
|
|
}
|
|
end
|
|
|
|
before do
|
|
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLAN_FEATURES').first_or_create(value: plan_features)
|
|
end
|
|
|
|
context 'when plan_name is hacker' do
|
|
it 'returns the features for the hacker plan' do
|
|
account.custom_attributes = { 'plan_name': 'hacker' }
|
|
account.save!
|
|
|
|
expect(account.subscribed_features).to eq(%w[feature1 feature2])
|
|
end
|
|
end
|
|
|
|
context 'when plan_name is startups' do
|
|
it 'returns the features for the startups plan' do
|
|
account.custom_attributes = { 'plan_name': 'startups' }
|
|
account.save!
|
|
|
|
expect(account.subscribed_features).to eq(%w[feature1 feature2 feature3 feature4])
|
|
end
|
|
end
|
|
|
|
context 'when plan_features is blank' do
|
|
it 'returns an empty array' do
|
|
account.custom_attributes = {}
|
|
account.save!
|
|
|
|
expect(account.subscribed_features).to be_nil
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'default features' do
|
|
before do
|
|
InstallationConfig.find_or_initialize_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS').update!(
|
|
value: Featurable::FEATURE_LIST,
|
|
locked: true
|
|
)
|
|
end
|
|
|
|
it 'enables Captain V2 for new self-hosted enterprise accounts' do
|
|
allow(ChatwootApp).to receive(:self_hosted_enterprise?).and_return(true)
|
|
|
|
account = create(:account)
|
|
|
|
expect(account).to be_feature_enabled('captain_integration')
|
|
expect(account).to be_feature_enabled('captain_integration_v2')
|
|
expect(account.captain_preferences[:models]['assistant']).to eq('gpt-5.2')
|
|
expect(account.captain_models).to be_nil
|
|
end
|
|
|
|
it 'marks new cloud accounts as eligible for the Captain V2 paid-plan default' do
|
|
allow(ChatwootApp).to receive(:self_hosted_enterprise?).and_return(false)
|
|
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
|
|
|
account = create(:account)
|
|
|
|
expect(account.internal_attributes[Enterprise::Account::CAPTAIN_V2_DEFAULT_ELIGIBLE]).to be true
|
|
expect(account).not_to be_feature_enabled('captain_integration')
|
|
expect(account).not_to be_feature_enabled('captain_integration_v2')
|
|
end
|
|
end
|
|
|
|
describe 'captain document sync cadence' do
|
|
let(:account) { create(:account) }
|
|
|
|
it 'has no cadence when installation config is missing' do
|
|
account.update!(custom_attributes: { plan_name: 'business' })
|
|
expect(account.captain_document_sync_interval).to be_nil
|
|
end
|
|
|
|
it 'uses configured plan intervals from installation config' do
|
|
intervals = {
|
|
business: 48,
|
|
enterprise: 24
|
|
}
|
|
create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: intervals.to_json)
|
|
account.update!(custom_attributes: { plan_name: 'business' })
|
|
|
|
expect(account.captain_document_sync_interval).to eq(2.days)
|
|
end
|
|
|
|
it 'normalizes configured plan name casing' do
|
|
create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { business: 24 }.to_json)
|
|
account.update!(custom_attributes: { plan_name: 'Business' })
|
|
|
|
expect(account.captain_document_sync_interval).to eq(1.day)
|
|
end
|
|
|
|
it 'uses the enterprise cadence for self-hosted enterprise installs without a plan_name' do
|
|
allow(ChatwootApp).to receive(:self_hosted_enterprise?).and_return(true)
|
|
create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { enterprise: 6 }.to_json)
|
|
account.update!(custom_attributes: {})
|
|
|
|
expect(account.captain_document_sync_interval).to eq(6.hours)
|
|
end
|
|
|
|
it 'allows installation config to disable a plan cadence' do
|
|
create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { business: nil }.to_json)
|
|
account.update!(custom_attributes: { plan_name: 'business' })
|
|
|
|
expect(account.captain_document_sync_interval).to be_nil
|
|
end
|
|
|
|
it 'has no cadence when installation config is invalid' do
|
|
create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: 'invalid-json')
|
|
account.update!(custom_attributes: { plan_name: 'business' })
|
|
|
|
expect(account.captain_document_sync_interval).to be_nil
|
|
end
|
|
|
|
it 'treats invalid plan interval values as disabled' do
|
|
intervals = {
|
|
business: false,
|
|
enterprise: { hours: 6 },
|
|
startups: '168'
|
|
}
|
|
create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: intervals.to_json)
|
|
|
|
account.update!(custom_attributes: { plan_name: 'business' })
|
|
expect(account.captain_document_sync_interval).to be_nil
|
|
|
|
account.update!(custom_attributes: { plan_name: 'enterprise' })
|
|
expect(account.captain_document_sync_interval).to be_nil
|
|
|
|
account.update!(custom_attributes: { plan_name: 'startups' })
|
|
expect(account.captain_document_sync_interval).to be_nil
|
|
end
|
|
end
|
|
|
|
describe 'account deletion' do
|
|
let(:account) { create(:account) }
|
|
let(:admin) { create(:user, account: account, role: :administrator) }
|
|
|
|
describe '#mark_for_deletion' do
|
|
it 'sets the marked_for_deletion_at and marked_for_deletion_reason attributes' do
|
|
expect do
|
|
account.mark_for_deletion('inactivity')
|
|
end.to change { account.reload.custom_attributes['marked_for_deletion_at'] }.from(nil).to(be_present)
|
|
.and change { account.reload.custom_attributes['marked_for_deletion_reason'] }.from(nil).to('inactivity')
|
|
end
|
|
|
|
it 'sends a user-initiated deletion email when reason is manual_deletion' do
|
|
mailer = double
|
|
expect(AdministratorNotifications::AccountNotificationMailer).to receive(:with).with(account: account).and_return(mailer)
|
|
expect(mailer).to receive(:account_deletion_user_initiated).with(account, 'manual_deletion').and_return(mailer)
|
|
expect(mailer).to receive(:deliver_later)
|
|
|
|
account.mark_for_deletion('manual_deletion')
|
|
end
|
|
|
|
it 'sends a system-initiated deletion email when reason is not manual_deletion' do
|
|
mailer = double
|
|
expect(AdministratorNotifications::AccountNotificationMailer).to receive(:with).with(account: account).and_return(mailer)
|
|
expect(mailer).to receive(:account_deletion_for_inactivity).with(account, 'inactivity').and_return(mailer)
|
|
expect(mailer).to receive(:deliver_later)
|
|
|
|
account.mark_for_deletion('inactivity')
|
|
end
|
|
|
|
it 'returns true when successful' do
|
|
expect(account.mark_for_deletion).to be_truthy
|
|
end
|
|
end
|
|
|
|
describe '#unmark_for_deletion' do
|
|
before do
|
|
account.update!(
|
|
custom_attributes: {
|
|
'marked_for_deletion_at' => 7.days.from_now.iso8601,
|
|
'marked_for_deletion_reason' => 'test_reason'
|
|
}
|
|
)
|
|
end
|
|
|
|
it 'removes the marked_for_deletion_at and marked_for_deletion_reason attributes' do
|
|
expect do
|
|
account.unmark_for_deletion
|
|
end.to change { account.reload.custom_attributes['marked_for_deletion_at'] }.from(be_present).to(nil)
|
|
.and change { account.reload.custom_attributes['marked_for_deletion_reason'] }.from('test_reason').to(nil)
|
|
end
|
|
|
|
it 'returns true when successful' do
|
|
expect(account.unmark_for_deletion).to be_truthy
|
|
end
|
|
end
|
|
end
|
|
end
|