+ {{ + t( + 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.DESCRIPTION' + ) + }} +
+{{
+ JSON.stringify(inboxCapacityLimitsEmpty, null, 2)
+ }}
+ + {{ $t('MFA_VERIFICATION.DESCRIPTION') }} +
++ {{ $t('MFA_VERIFICATION.HELP_TEXT') }} +
+- {{ t(`${BASE_KEY}.FORM.INBOXES.DESCRIPTION`) }} -
-+ {{ t(`${BASE_KEY}.FORM.INBOXES.DESCRIPTION`) }} +
++ {{ $t('MFA_SETTINGS.MANAGEMENT.BACKUP_CODES_DESC') }} +
+ ++ {{ $t('MFA_SETTINGS.MANAGEMENT.DISABLE_MFA_DESC') }} +
+ ++ {{ $t('MFA_SETTINGS.SUBTITLE') }} +
++ {{ $t('MFA_SETTINGS.DESCRIPTION') }} +
++ {{ $t('MFA_SETTINGS.SETUP.STEP1_DESCRIPTION') }} +
++ {{ $t('MFA_SETTINGS.BACKUP.DESCRIPTION') }} +
++ {{ $t('MFA_SETTINGS.BACKUP.IMPORTANT') }} + {{ $t('MFA_SETTINGS.BACKUP.IMPORTANT_NOTE') }} +
++ {{ $t('MFA_SETTINGS.ENHANCE_SECURITY_DESC') }} +
+ ++ {{ $t('MFA_SETTINGS.STATUS_ENABLED_DESC') }} +
++ {{ t('SECURITY_SETTINGS.SAML.ATTRIBUTE_MAPPING.DESCRIPTION') }} +
+ +emailfirst_namelast_nameHi <%= @resource.name %>,
+ +<% account_user = @resource&.account_users&.first %> +<% is_saml_account = account_user&.account&.saml_enabled? %> + +<% if account_user&.inviter.present? && @resource.unconfirmed_email.blank? %> + <% if is_saml_account %> +<%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to access <%= global_config['BRAND_NAME'] || 'Chatwoot' %> via Single Sign-On (SSO).
+Your organization uses SSO for secure authentication. You will not need a password to access your account.
+ <% else %> +<%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to try out <%= global_config['BRAND_NAME'] || 'Chatwoot' %>.
+ <% end %> +<% end %> + +<% if @resource.confirmed? %> +You can login to your <%= global_config['BRAND_NAME'] || 'Chatwoot' %> account through the link below:
+<% else %> + <% if account_user&.inviter.blank? %> ++ Welcome to <%= global_config['BRAND_NAME'] || 'Chatwoot' %>! We have a suite of powerful tools ready for you to explore. Before that we quickly need to verify your email address to know it's really you. +
+ <% end %> + <% unless is_saml_account %> +Please take a moment and click the link below and activate your account.
+ <% end %> +<% end %> + + +<% if @resource.unconfirmed_email.present? %> +<%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %>
+<% elsif @resource.confirmed? %> + <% if is_saml_account %> +You can now access your account by logging in through your organization's SSO portal.
+ <% else %> +<%= link_to 'Login to my account', frontend_url('auth/sign_in') %>
+ <% end %> +<% elsif account_user&.inviter.present? %> + <% if is_saml_account %> +You can access your account by logging in through your organization's SSO portal.
+ <% else %> +<%= link_to 'Confirm my account', frontend_url('auth/password/edit', reset_password_token: @resource.send(:set_reset_password_token)) %>
+ <% end %> +<% else %> +<%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %>
+<% end %> diff --git a/enterprise/lib/tasks.rb b/enterprise/lib/tasks.rb deleted file mode 100644 index 139819bbb..000000000 --- a/enterprise/lib/tasks.rb +++ /dev/null @@ -1,4 +0,0 @@ -# Load all rake tasks from the enterprise/lib/tasks directory -module Tasks - Dir.glob(File.join(File.dirname(__FILE__), 'tasks', '*.rake')).each { |r| load r } -end diff --git a/enterprise/lib/tasks/search.rake b/enterprise/lib/tasks/search.rake index 03bad2c1b..4ec137434 100644 --- a/enterprise/lib/tasks/search.rake +++ b/enterprise/lib/tasks/search.rake @@ -1,22 +1,4 @@ -module Tasks::SearchTaskHelpers - def check_opensearch_config - if ENV['OPENSEARCH_URL'].blank? - puts 'Skipping reindex as OPENSEARCH_URL is not configured' - return false - end - true - end - - def reindex_account(account) - Messages::ReindexService.new(account: account).perform - puts "Reindex task queued for account #{account.id}" - end -end - namespace :search do - desc 'Reindex messages using searchkick' - include Tasks::SearchTaskHelpers - desc 'Reindex messages for all accounts' task all: :environment do next unless check_opensearch_config @@ -47,3 +29,16 @@ namespace :search do reindex_account(account) end end + +def check_opensearch_config + if ENV['OPENSEARCH_URL'].blank? + puts 'Skipping reindex as OPENSEARCH_URL is not configured' + return false + end + true +end + +def reindex_account(account) + Messages::ReindexService.new(account: account).perform + puts "Reindex task queued for account #{account.id}" +end diff --git a/enterprise/tasks_railtie.rb b/enterprise/tasks_railtie.rb new file mode 100644 index 000000000..9a7d09360 --- /dev/null +++ b/enterprise/tasks_railtie.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class TasksRailtie < Rails::Railtie + rake_tasks do + # Load all rake tasks from enterprise/lib/tasks + Dir.glob(Rails.root.join('enterprise/lib/tasks/**/*.rake')).each { |f| load f } + end +end diff --git a/lib/filters/filter_keys.yml b/lib/filters/filter_keys.yml index 902b5f219..626a0cbd5 100644 --- a/lib/filters/filter_keys.yml +++ b/lib/filters/filter_keys.yml @@ -4,7 +4,7 @@ # 3. Automation Filters (app/services/automation_rules/conditions_filter_service.rb), (app/services/automation_rules/condition_validation_service.rb) -# Format +# Format # - Parent Key (conversation, contact, messages) # - Key (attribute_name) # - attribute_type: "standard" : supported ["standard", "additional_attributes (only for conversations and messages)"] @@ -138,7 +138,7 @@ contacts: - "does_not_contain" phone_number: attribute_type: "standard" - data_type: "text_case_insensitive" + data_type: "text" # Text is not explicity defined in filters, default filter will be used filter_operators: - "equal_to" - "not_equal_to" diff --git a/lib/integrations/openai_base_service.rb b/lib/integrations/openai_base_service.rb index f06baf5b5..7b4a93f95 100644 --- a/lib/integrations/openai_base_service.rb +++ b/lib/integrations/openai_base_service.rb @@ -81,7 +81,7 @@ class Integrations::OpenaiBaseService end def api_url - endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || 'https://api.openai.com/' + endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/' endpoint = endpoint.chomp('/') "#{endpoint}/v1/chat/completions" end diff --git a/package.json b/package.json index 3972a328e..66dd84aa2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/chatwoot", - "version": "4.5.2", + "version": "4.6.0", "license": "MIT", "scripts": { "eslint": "eslint app/**/*.{js,vue}", diff --git a/spec/controllers/api/v1/accounts/portals_controller_spec.rb b/spec/controllers/api/v1/accounts/portals_controller_spec.rb index d0ea13e2b..f38660706 100644 --- a/spec/controllers/api/v1/accounts/portals_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/portals_controller_spec.rb @@ -154,6 +154,25 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do portal.reload expect(portal.archived).to be_truthy end + + it 'clears associated web widget when inbox selection is blank' do + web_widget_inbox = create(:inbox, account: account) + portal.update!(channel_web_widget: web_widget_inbox.channel) + + expect(portal.channel_web_widget_id).to eq(web_widget_inbox.channel.id) + + put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}", + params: { + portal: { name: portal.name }, + inbox_id: '' + }, + headers: admin.create_new_auth_token + + expect(response).to have_http_status(:success) + portal.reload + expect(portal.channel_web_widget_id).to be_nil + expect(response.parsed_body['inbox']).to be_nil + end end end diff --git a/spec/controllers/api/v1/accounts/whatsapp/authorizations_controller_spec.rb b/spec/controllers/api/v1/accounts/whatsapp/authorizations_controller_spec.rb index 2e74817a7..7beafb47b 100644 --- a/spec/controllers/api/v1/accounts/whatsapp/authorizations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/whatsapp/authorizations_controller_spec.rb @@ -16,31 +16,7 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do let(:agent) { create(:user, account: account, role: :agent) } let(:administrator) { create(:user, account: account, role: :administrator) } - context 'when feature is not enabled' do - before do - account.disable_features!(:whatsapp_embedded_signup) - end - - it 'returns forbidden' do - post "/api/v1/accounts/#{account.id}/whatsapp/authorization", - params: { - code: 'test_code', - business_id: 'test_business_id', - waba_id: 'test_waba_id' - }, - headers: agent.create_new_auth_token, - as: :json - - expect(response).to have_http_status(:forbidden) - expect(response.parsed_body['error']).to eq('WhatsApp embedded signup is not enabled for this account') - end - end - - context 'when feature is enabled' do - before do - account.enable_features!(:whatsapp_embedded_signup) - end - + context 'when authenticated user makes request' do it 'returns unprocessable entity when code is missing' do post "/api/v1/accounts/#{account.id}/whatsapp/authorization", params: { @@ -246,10 +222,6 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do context 'when user is not authorized for the account' do let(:other_account) { create(:account) } - before do - account.enable_features!(:whatsapp_embedded_signup) - end - it 'returns unauthorized' do post "/api/v1/accounts/#{other_account.id}/whatsapp/authorization", params: { @@ -265,10 +237,6 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do end context 'when user is an administrator' do - before do - account.enable_features!(:whatsapp_embedded_signup) - end - it 'allows channel creation' do embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService) whatsapp_channel = create(:channel_whatsapp, account: account, validate_provider_config: false, sync_templates: false) @@ -321,10 +289,6 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do context 'when user is an administrator' do let(:administrator) { create(:user, account: account, role: :administrator) } - before do - account.enable_features!(:whatsapp_embedded_signup) - end - context 'with valid parameters' do let(:valid_params) do { @@ -489,7 +453,6 @@ RSpec.describe 'WhatsApp Authorization API', type: :request do let(:agent) { create(:user, account: account, role: :agent) } before do - account.enable_features!(:whatsapp_embedded_signup) create(:inbox_member, inbox: whatsapp_inbox, user: agent) end diff --git a/spec/controllers/devise_overrides/sessions_controller_spec.rb b/spec/controllers/devise_overrides/sessions_controller_spec.rb new file mode 100644 index 000000000..31f308c8e --- /dev/null +++ b/spec/controllers/devise_overrides/sessions_controller_spec.rb @@ -0,0 +1,146 @@ +require 'rails_helper' + +RSpec.describe DeviseOverrides::SessionsController, type: :controller do + include Devise::Test::ControllerHelpers + + before do + request.env['devise.mapping'] = Devise.mappings[:user] + end + + describe 'POST #create' do + let(:user) { create(:user, password: 'Test@123456') } + + context 'with standard authentication' do + it 'authenticates with valid credentials' do + post :create, params: { email: user.email, password: 'Test@123456' } + + expect(response).to have_http_status(:success) + end + + it 'rejects invalid credentials' do + post :create, params: { email: user.email, password: 'wrong' } + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'with MFA authentication' do + before do + skip('Skipping since MFA is not configured in this environment') unless Chatwoot.encryption_configured? + user.enable_two_factor! + user.update!(otp_required_for_login: true) + end + + it 'requires MFA verification after successful password authentication' do + post :create, params: { email: user.email, password: 'Test@123456' } + + expect(response).to have_http_status(:partial_content) + json_response = response.parsed_body + expect(json_response['mfa_required']).to be(true) + expect(json_response['mfa_token']).to be_present + end + + context 'when verifying MFA' do + let(:mfa_token) { Mfa::TokenService.new(user: user).generate_token } + + it 'authenticates with valid OTP' do + post :create, params: { + mfa_token: mfa_token, + otp_code: user.current_otp + } + + expect(response).to have_http_status(:success) + end + + it 'authenticates with valid backup code' do + backup_codes = user.generate_backup_codes! + + post :create, params: { + mfa_token: mfa_token, + backup_code: backup_codes.first + } + + expect(response).to have_http_status(:success) + end + + it 'rejects invalid OTP' do + post :create, params: { + mfa_token: mfa_token, + otp_code: '000000' + } + + expect(response).to have_http_status(:bad_request) + expect(response.parsed_body['error']).to eq(I18n.t('errors.mfa.invalid_code')) + end + + it 'rejects invalid backup code' do + user.generate_backup_codes! + + post :create, params: { + mfa_token: mfa_token, + backup_code: 'invalid' + } + + expect(response).to have_http_status(:bad_request) + expect(response.parsed_body['error']).to eq(I18n.t('errors.mfa.invalid_code')) + end + + it 'rejects expired MFA token' do + expired_token = JWT.encode( + { user_id: user.id, exp: 1.minute.ago.to_i }, + Rails.application.secret_key_base, + 'HS256' + ) + + post :create, params: { + mfa_token: expired_token, + otp_code: user.current_otp + } + + expect(response).to have_http_status(:unauthorized) + expect(response.parsed_body['error']).to eq(I18n.t('errors.mfa.invalid_token')) + end + + it 'requires either OTP or backup code' do + post :create, params: { mfa_token: mfa_token } + + expect(response).to have_http_status(:bad_request) + expect(response.parsed_body['error']).to eq(I18n.t('errors.mfa.invalid_code')) + end + end + end + + context 'with SSO authentication' do + it 'authenticates with valid SSO token' do + sso_token = user.generate_sso_auth_token + + post :create, params: { + email: user.email, + sso_auth_token: sso_token + } + + expect(response).to have_http_status(:success) + end + + it 'rejects invalid SSO token' do + post :create, params: { + email: user.email, + sso_auth_token: 'invalid' + } + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'GET #new' do + it 'redirects to frontend login page' do + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('/frontend') + + get :new + + expect(response).to redirect_to('/frontend/app/login?error=access-denied') + end + end +end diff --git a/spec/enterprise/builders/agent_builder_spec.rb b/spec/enterprise/builders/agent_builder_spec.rb new file mode 100644 index 000000000..8f0ae4c17 --- /dev/null +++ b/spec/enterprise/builders/agent_builder_spec.rb @@ -0,0 +1,139 @@ +require 'rails_helper' + +RSpec.describe AgentBuilder do + let(:email) { 'agent@example.com' } + let(:name) { 'Test Agent' } + let(:account) { create(:account) } + let!(:inviter) { create(:user, account: account, role: 'administrator') } + let(:builder) do + described_class.new( + email: email, + name: name, + account: account, + inviter: inviter + ) + end + + describe '#perform with SAML enabled' do + let(:saml_settings) do + create(:account_saml_settings, account: account) + end + + before { saml_settings } + + context 'when user does not exist' do + it 'creates a new user with SAML provider' do + expect { builder.perform }.to change(User, :count).by(1) + + user = User.from_email(email) + expect(user.provider).to eq('saml') + end + + it 'creates user with correct attributes' do + user = builder.perform + + expect(user.email).to eq(email) + expect(user.name).to eq(name) + expect(user.provider).to eq('saml') + expect(user.encrypted_password).to be_present + end + + it 'adds user to the account with correct role' do + user = builder.perform + account_user = AccountUser.find_by(user: user, account: account) + + expect(account_user).to be_present + expect(account_user.role).to eq('agent') + expect(account_user.inviter).to eq(inviter) + end + end + + context 'when user already exists with email provider' do + let!(:existing_user) { create(:user, email: email, provider: 'email') } + + it 'does not create a new user' do + expect { builder.perform }.not_to change(User, :count) + end + + it 'converts existing user to SAML provider' do + expect(existing_user.provider).to eq('email') + + builder.perform + + expect(existing_user.reload.provider).to eq('saml') + end + + it 'adds existing user to the account' do + user = builder.perform + account_user = AccountUser.find_by(user: user, account: account) + + expect(account_user).to be_present + expect(account_user.inviter).to eq(inviter) + end + end + + context 'when user already exists with SAML provider' do + let!(:existing_user) { create(:user, email: email, provider: 'saml') } + + it 'does not change the provider' do + expect { builder.perform }.not_to(change { existing_user.reload.provider }) + end + + it 'still adds user to the account' do + user = builder.perform + account_user = AccountUser.find_by(user: user, account: account) + + expect(account_user).to be_present + end + end + end + + describe '#perform without SAML' do + context 'when user does not exist' do + it 'creates a new user with email provider (default behavior)' do + expect { builder.perform }.to change(User, :count).by(1) + + user = User.from_email(email) + expect(user.provider).to eq('email') + end + end + + context 'when user already exists' do + let!(:existing_user) { create(:user, email: email, provider: 'email') } + + it 'does not change the existing user provider' do + expect { builder.perform }.not_to(change { existing_user.reload.provider }) + end + end + end + + describe '#perform with different account configurations' do + context 'when account has no SAML settings' do + # No saml_settings created for this account + + it 'treats account as non-SAML enabled' do + user = builder.perform + expect(user.provider).to eq('email') + end + end + + context 'when SAML settings are deleted after user creation' do + let(:saml_settings) do + create(:account_saml_settings, account: account) + end + let(:existing_user) { create(:user, email: email, provider: 'saml') } + + before do + saml_settings + existing_user + end + + it 'does not affect existing SAML users when adding to account' do + saml_settings.destroy! + + user = builder.perform + expect(user.provider).to eq('saml') # Unchanged + end + end + end +end diff --git a/spec/enterprise/builders/saml_user_builder_spec.rb b/spec/enterprise/builders/saml_user_builder_spec.rb index 63fd65fd3..434beda16 100644 --- a/spec/enterprise/builders/saml_user_builder_spec.rb +++ b/spec/enterprise/builders/saml_user_builder_spec.rb @@ -109,6 +109,56 @@ RSpec.describe SamlUserBuilder do expect { builder.perform }.not_to change(AccountUser, :count) end + + context 'when user is not confirmed' do + let(:unconfirmed_email) { 'unconfirmed_saml_user@example.com' } + let(:unconfirmed_auth_hash) do + { + 'provider' => 'saml', + 'uid' => 'saml-uid-123', + 'info' => { + 'email' => unconfirmed_email, + 'name' => 'SAML User', + 'first_name' => 'SAML', + 'last_name' => 'User' + }, + 'extra' => { + 'raw_info' => { + 'groups' => %w[Administrators Users] + } + } + } + end + let(:unconfirmed_builder) { described_class.new(unconfirmed_auth_hash, account.id) } + let!(:existing_user) do + user = build(:user, email: unconfirmed_email) + user.confirmed_at = nil + user.save!(validate: false) + user + end + + it 'confirms unconfirmed user after SAML authentication' do + expect(existing_user.confirmed?).to be false + + unconfirmed_builder.perform + + expect(existing_user.reload.confirmed?).to be true + end + end + + context 'when user is already confirmed' do + let!(:existing_user) { create(:user, email: email, confirmed_at: Time.current) } + + it 'keeps already confirmed user confirmed' do + expect(existing_user.confirmed?).to be true + original_confirmed_at = existing_user.confirmed_at + + builder.perform + + expect(existing_user.reload.confirmed?).to be true + expect(existing_user.reload.confirmed_at).to be_within(2.seconds).of(original_confirmed_at) + end + end end context 'with role mappings' do diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/assistant_responses_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/assistant_responses_controller_spec.rb index 58c23f897..038ee5e8d 100644 --- a/spec/enterprise/controllers/api/v1/accounts/captain/assistant_responses_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/captain/assistant_responses_controller_spec.rb @@ -90,6 +90,53 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request expect(json_response[:payload][0][:documentable][:id]).to eq(document.id) end end + + context 'when searching' do + before do + create(:captain_assistant_response, + account: account, + assistant: assistant, + question: 'How to reset password?', + answer: 'Click forgot password') + create(:captain_assistant_response, + account: account, + assistant: assistant, + question: 'How to change email?', + answer: 'Go to settings') + end + + it 'finds responses by question text' do + get "/api/v1/accounts/#{account.id}/captain/assistant_responses", + params: { search: 'password' }, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:ok) + expect(json_response[:payload].length).to eq(1) + expect(json_response[:payload][0][:question]).to include('password') + end + + it 'finds responses by answer text' do + get "/api/v1/accounts/#{account.id}/captain/assistant_responses", + params: { search: 'settings' }, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:ok) + expect(json_response[:payload].length).to eq(1) + expect(json_response[:payload][0][:answer]).to include('settings') + end + + it 'returns empty when no matches' do + get "/api/v1/accounts/#{account.id}/captain/assistant_responses", + params: { search: 'nonexistent' }, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:ok) + expect(json_response[:payload].length).to eq(0) + end + end end describe 'GET /api/v1/accounts/:account_id/captain/assistant_responses/:id' do diff --git a/spec/enterprise/jobs/saml/update_account_users_provider_job_spec.rb b/spec/enterprise/jobs/saml/update_account_users_provider_job_spec.rb new file mode 100644 index 000000000..82bb6b015 --- /dev/null +++ b/spec/enterprise/jobs/saml/update_account_users_provider_job_spec.rb @@ -0,0 +1,65 @@ +require 'rails_helper' + +RSpec.describe Saml::UpdateAccountUsersProviderJob, type: :job do + let(:account) { create(:account) } + let!(:user1) { create(:user, accounts: [account], provider: 'email') } + let!(:user2) { create(:user, accounts: [account], provider: 'email') } + let!(:user3) { create(:user, accounts: [account], provider: 'google') } + + describe '#perform' do + context 'when setting provider to saml' do + it 'updates all account users to saml provider' do + described_class.new.perform(account.id, 'saml') + + expect(user1.reload.provider).to eq('saml') + expect(user2.reload.provider).to eq('saml') + expect(user3.reload.provider).to eq('saml') + end + end + + context 'when resetting provider to email' do + before do + # rubocop:disable Rails/SkipsModelValidations + user1.update_column(:provider, 'saml') + user2.update_column(:provider, 'saml') + user3.update_column(:provider, 'saml') + # rubocop:enable Rails/SkipsModelValidations + end + + context 'when users have no other SAML accounts' do + it 'updates all account users to email provider' do + described_class.new.perform(account.id, 'email') + + expect(user1.reload.provider).to eq('email') + expect(user2.reload.provider).to eq('email') + expect(user3.reload.provider).to eq('email') + end + end + + context 'when users belong to other accounts with SAML enabled' do + let(:other_account) { create(:account) } + + before do + create(:account_saml_settings, account: other_account) + user1.account_users.create!(account: other_account, role: :agent) + end + + it 'preserves SAML provider for users with other SAML accounts' do + described_class.new.perform(account.id, 'email') + + expect(user1.reload.provider).to eq('saml') + expect(user2.reload.provider).to eq('email') + expect(user3.reload.provider).to eq('email') + end + end + end + + context 'when account does not exist' do + it 'raises ActiveRecord::RecordNotFound' do + expect do + described_class.new.perform(999_999, 'saml') + end.to raise_error(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/spec/enterprise/mailers/devise_mailer_spec.rb b/spec/enterprise/mailers/devise_mailer_spec.rb new file mode 100644 index 000000000..286e863f7 --- /dev/null +++ b/spec/enterprise/mailers/devise_mailer_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Devise::Mailer' do + describe 'confirmation_instructions with Enterprise features' do + let(:account) { create(:account) } + let!(:confirmable_user) { create(:user, inviter: inviter_val, account: account) } + let(:inviter_val) { nil } + let(:mail) { Devise::Mailer.confirmation_instructions(confirmable_user.reload, nil, {}) } + + before do + confirmable_user.update!(confirmed_at: nil) + confirmable_user.send(:generate_confirmation_token) + end + + context 'with SAML enabled account' do + let(:saml_settings) { create(:account_saml_settings, account: account) } + + before { saml_settings } + + context 'when user has no inviter' do + it 'shows standard welcome message without SSO references' do + expect(mail.body).to match('We have a suite of powerful tools ready for you to explore.') + expect(mail.body).not_to match('via Single Sign-On') + end + + it 'does not show activation instructions for SAML accounts' do + expect(mail.body).not_to match('Please take a moment and click the link below and activate your account') + end + + it 'shows confirmation link' do + expect(mail.body).to include("app/auth/confirmation?confirmation_token=#{confirmable_user.confirmation_token}") + end + end + + context 'when user has inviter and SAML is enabled' do + let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) } + + it 'mentions SSO invitation' do + expect(mail.body).to match( + "#{CGI.escapeHTML(inviter_val.name)}, with #{CGI.escapeHTML(account.name)}, has invited you to access.*via Single Sign-On \\(SSO\\)" + ) + end + + it 'explains SSO authentication' do + expect(mail.body).to match('Your organization uses SSO for secure authentication') + expect(mail.body).to match('You will not need a password to access your account') + end + + it 'does not show standard invitation message' do + expect(mail.body).not_to match('has invited you to try out') + end + + it 'directs to SSO portal instead of password reset' do + expect(mail.body).to match('You can access your account by logging in through your organization\'s SSO portal') + expect(mail.body).not_to include('app/auth/password/edit') + end + end + + context 'when user is already confirmed and has inviter' do + let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) } + + before do + confirmable_user.confirm + end + + it 'shows SSO login instructions' do + expect(mail.body).to match('You can now access your account by logging in through your organization\'s SSO portal') + expect(mail.body).not_to include('/auth/sign_in') + end + end + + context 'when user updates email on SAML account' do + let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) } + + before do + confirmable_user.update!(email: 'updated@example.com') + end + + it 'still shows confirmation link for email verification' do + expect(mail.body).to include('app/auth/confirmation?confirmation_token') + expect(confirmable_user.unconfirmed_email.blank?).to be false + end + end + + context 'when user is already confirmed with no inviter' do + before do + confirmable_user.confirm + end + + it 'shows SSO login instructions instead of regular login' do + expect(mail.body).to match('You can now access your account by logging in through your organization\'s SSO portal') + expect(mail.body).not_to include('/auth/sign_in') + end + end + end + + context 'when account does not have SAML enabled' do + context 'when user has inviter' do + let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) } + + it 'shows standard invitation without SSO references' do + expect(mail.body).to match('has invited you to try out Chatwoot') + expect(mail.body).not_to match('via Single Sign-On') + expect(mail.body).not_to match('SSO portal') + end + + it 'shows password reset link' do + expect(mail.body).to include('app/auth/password/edit') + end + end + + context 'when user has no inviter' do + it 'shows standard welcome message and activation instructions' do + expect(mail.body).to match('We have a suite of powerful tools ready for you to explore') + expect(mail.body).to match('Please take a moment and click the link below and activate your account') + end + + it 'shows confirmation link' do + expect(mail.body).to include("app/auth/confirmation?confirmation_token=#{confirmable_user.confirmation_token}") + end + end + + context 'when user is already confirmed' do + let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) } + + before do + confirmable_user.confirm + end + + it 'shows regular login link' do + expect(mail.body).to include('/auth/sign_in') + expect(mail.body).not_to match('SSO portal') + end + end + + context 'when user updates email' do + before do + confirmable_user.update!(email: 'updated@example.com') + end + + it 'shows confirmation link for email verification' do + expect(mail.body).to include('app/auth/confirmation?confirmation_token') + expect(confirmable_user.unconfirmed_email.blank?).to be false + end + end + end + end +end diff --git a/spec/enterprise/models/account_saml_settings_spec.rb b/spec/enterprise/models/account_saml_settings_spec.rb index 65d5119d7..ac35eb486 100644 --- a/spec/enterprise/models/account_saml_settings_spec.rb +++ b/spec/enterprise/models/account_saml_settings_spec.rb @@ -114,4 +114,21 @@ RSpec.describe AccountSamlSettings, type: :model do expect(fingerprint.count(':')).to eq(19) # 20 bytes = 19 colons end end + + describe 'callbacks' do + describe 'after_create_commit' do + it 'queues job to set account users to saml provider' do + expect(Saml::UpdateAccountUsersProviderJob).to receive(:perform_later).with(account.id, 'saml') + create(:account_saml_settings, account: account) + end + end + + describe 'after_destroy_commit' do + it 'queues job to reset account users provider' do + settings = create(:account_saml_settings, account: account) + expect(Saml::UpdateAccountUsersProviderJob).to receive(:perform_later).with(account.id, 'email') + settings.destroy + end + end + end end diff --git a/spec/enterprise/models/inbox_spec.rb b/spec/enterprise/models/inbox_spec.rb index b0e063600..4ba1a7021 100644 --- a/spec/enterprise/models/inbox_spec.rb +++ b/spec/enterprise/models/inbox_spec.rb @@ -29,11 +29,11 @@ RSpec.describe Inbox do it 'returns member ids with assignment capacity with inbox max_assignment_limit is configured' do # agent 1 has 1 conversations, agent 2 has 2 conversations, agent 3 has 3 conversations and agent 4 with none inbox.update(auto_assignment_config: { max_assignment_limit: 2 }) - expect(inbox.member_ids_with_assignment_capacity).to eq([inbox_member_1.user_id, inbox_member_4.user_id]) + expect(inbox.member_ids_with_assignment_capacity).to contain_exactly(inbox_member_1.user_id, inbox_member_4.user_id) end it 'returns all member ids when inbox max_assignment_limit is not configured' do - expect(inbox.member_ids_with_assignment_capacity).to eq(inbox.members.ids) + expect(inbox.member_ids_with_assignment_capacity).to match_array(inbox.members.ids) end end diff --git a/spec/listeners/automation_rule_listener_labels_spec.rb b/spec/listeners/automation_rule_listener_labels_spec.rb new file mode 100644 index 000000000..36002f7f3 --- /dev/null +++ b/spec/listeners/automation_rule_listener_labels_spec.rb @@ -0,0 +1,244 @@ +require 'rails_helper' + +describe AutomationRuleListener do + let(:listener) { described_class.instance } + let!(:account) { create(:account) } + let!(:user) { create(:user, account: account) } + let!(:inbox) { create(:inbox, account: account) } + let!(:contact) { create(:contact, account: account) } + let!(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) } + let(:label1) { create(:label, account: account, title: 'bug') } + let(:label2) { create(:label, account: account, title: 'feature') } + let(:label3) { create(:label, account: account, title: 'urgent') } + + before do + Current.user = user + end + + describe 'conversation_updated with label conditions and actions' do + context 'when label is added and automation rule has label condition' do + let(:automation_rule) do + create(:automation_rule, + event_name: 'conversation_updated', + account: account, + conditions: [ + { + attribute_key: 'labels', + filter_operator: 'equal_to', + values: ['bug'], + query_operator: nil + } + ], + actions: [ + { + action_name: 'add_label', + action_params: ['urgent'] + }, + { + action_name: 'send_message', + action_params: ['Bug report received. We will investigate this issue.'] + } + ]) + end + + it 'triggers automation when the specified label is added' do + automation_rule # Create the automation rule + expect(Messages::MessageBuilder).to receive(:new).and_call_original + + # Add the 'bug' label to trigger the automation + conversation.add_labels(['bug']) + + # Dispatch the event + event = Events::Base.new('conversation_updated', Time.zone.now, { + conversation: conversation, + changed_attributes: { label_list: [[], ['bug']] } + }) + + listener.conversation_updated(event) + + # Verify the label was added by automation + expect(conversation.reload.label_list).to include('urgent') + + # Verify a message was sent + expect(conversation.messages.last.content).to eq('Bug report received. We will investigate this issue.') + end + + it 'does not trigger automation when a different label is added' do + automation_rule # Create the automation rule + expect(Messages::MessageBuilder).not_to receive(:new) + + # Add a different label + conversation.add_labels(['feature']) + + event = Events::Base.new('conversation_updated', Time.zone.now, { + conversation: conversation, + changed_attributes: { label_list: [[], ['feature']] } + }) + + listener.conversation_updated(event) + + # Verify the automation did not run + expect(conversation.reload.label_list).not_to include('urgent') + end + end + + context 'when automation rule has is_present label condition' do + let(:automation_rule) do + create(:automation_rule, + event_name: 'conversation_updated', + account: account, + conditions: [ + { + attribute_key: 'labels', + filter_operator: 'is_present', + values: [], + query_operator: nil + } + ], + actions: [ + { + action_name: 'send_message', + action_params: ['Thank you for adding a label to categorize this conversation.'] + } + ]) + end + + it 'triggers automation when any label is added to an unlabeled conversation' do + automation_rule # Create the automation rule + expect(Messages::MessageBuilder).to receive(:new).and_call_original + + # Add any label to trigger the automation + conversation.add_labels(['feature']) + + event = Events::Base.new('conversation_updated', Time.zone.now, { + conversation: conversation, + changed_attributes: { label_list: [[], ['feature']] } + }) + + listener.conversation_updated(event) + + # Verify a message was sent + expect(conversation.messages.last.content).to eq('Thank you for adding a label to categorize this conversation.') + end + + it 'still triggers when labels are removed but conversation still has labels' do + automation_rule # Create the automation rule + # Start with multiple labels + conversation.add_labels(%w[bug feature]) + conversation.reload + + expect(Messages::MessageBuilder).to receive(:new).and_call_original + + # Remove one label but conversation still has labels + conversation.update_labels(['bug']) + + event = Events::Base.new('conversation_updated', Time.zone.now, { + conversation: conversation, + changed_attributes: { label_list: [%w[bug feature], ['bug']] } + }) + + listener.conversation_updated(event) + + # Should still trigger because conversation has labels (is_present condition) + expect(conversation.messages.last.content).to eq('Thank you for adding a label to categorize this conversation.') + end + + it 'does not trigger when all labels are removed' do + automation_rule # Create the automation rule + # Start with labels + conversation.add_labels(['bug']) + conversation.reload + + expect(Messages::MessageBuilder).not_to receive(:new) + + # Remove all labels + conversation.update_labels([]) + + event = Events::Base.new('conversation_updated', Time.zone.now, { + conversation: conversation, + changed_attributes: { label_list: [['bug'], []] } + }) + + listener.conversation_updated(event) + end + end + + context 'when automation rule has remove_label action' do + let!(:automation_rule) do + create(:automation_rule, + event_name: 'conversation_updated', + account: account, + conditions: [ + { + attribute_key: 'labels', + filter_operator: 'equal_to', + values: ['urgent'], + query_operator: nil + } + ], + actions: [ + { + action_name: 'remove_label', + action_params: ['bug'] + } + ]) + end + + it 'removes specified labels when condition is met' do + automation_rule # Create the automation rule + # Start with both labels + conversation.add_labels(%w[bug urgent]) + + event = Events::Base.new('conversation_updated', Time.zone.now, { + conversation: conversation, + changed_attributes: { label_list: [['bug'], %w[bug urgent]] } + }) + + listener.conversation_updated(event) + + # Verify the bug label was removed but urgent remains + expect(conversation.reload.label_list).to include('urgent') + expect(conversation.reload.label_list).not_to include('bug') + end + end + end + + describe 'preventing infinite loops' do + let!(:automation_rule) do + create(:automation_rule, + event_name: 'conversation_updated', + account: account, + conditions: [ + { + attribute_key: 'labels', + filter_operator: 'equal_to', + values: ['bug'], + query_operator: nil + } + ], + actions: [ + { + action_name: 'add_label', + action_params: ['processed'] + } + ]) + end + + it 'does not trigger automation when performed by automation rule' do + automation_rule # Create the automation rule + conversation.add_labels(['bug']) + + # Simulate event performed by automation rule + event = Events::Base.new('conversation_updated', Time.zone.now, { + conversation: conversation, + changed_attributes: { label_list: [[], ['bug']] }, + performed_by: automation_rule + }) + + # Should not process the event since it was performed by automation + expect(AutomationRules::ActionService).not_to receive(:new) + + listener.conversation_updated(event) + end + end +end diff --git a/spec/mailers/administrator_notifications/base_mailer_spec.rb b/spec/mailers/administrator_notifications/base_mailer_spec.rb index 619fef0a7..1524a46cc 100644 --- a/spec/mailers/administrator_notifications/base_mailer_spec.rb +++ b/spec/mailers/administrator_notifications/base_mailer_spec.rb @@ -17,8 +17,7 @@ RSpec.describe AdministratorNotifications::BaseMailer do # Call the private method admin_emails = mailer.send(:admin_emails) - expect(admin_emails).to include(admin1.email) - expect(admin_emails).to include(admin2.email) + expect(admin_emails).to contain_exactly(admin1.email, admin2.email) expect(admin_emails).not_to include(agent.email) end end @@ -49,7 +48,7 @@ RSpec.describe AdministratorNotifications::BaseMailer do # Mock the send_mail_with_liquid method expect(mailer).to receive(:send_mail_with_liquid).with( - to: [admin1.email, admin2.email], + to: contain_exactly(admin1.email, admin2.email), subject: subject ).and_return(true) diff --git a/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb b/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb index e5cd7327b..39bc4ee1a 100644 --- a/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb +++ b/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb @@ -9,6 +9,7 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do let(:class_instance) { described_class.new } let!(:account) { create(:account) } let!(:administrator) { create(:user, :administrator, email: 'agent1@example.com', account: account) } + let!(:another_administrator) { create(:user, :administrator, email: 'agent2@example.com', account: account) } describe 'facebook_disconnect' do before do @@ -26,7 +27,7 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do end it 'renders the receiver email' do - expect(mail.to).to eq([administrator.email]) + expect(mail.to).to contain_exactly(administrator.email, another_administrator.email) end end end @@ -41,7 +42,7 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do end it 'renders the receiver email' do - expect(mail.to).to eq([administrator.email]) + expect(mail.to).to contain_exactly(administrator.email, another_administrator.email) end end @@ -55,7 +56,7 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do end it 'renders the receiver email' do - expect(mail.to).to eq([administrator.email]) + expect(mail.to).to contain_exactly(administrator.email, another_administrator.email) end end end diff --git a/spec/mailers/administrator_notifications/integrations_notification_mailer_spec.rb b/spec/mailers/administrator_notifications/integrations_notification_mailer_spec.rb index 331d33d06..04bc405f4 100644 --- a/spec/mailers/administrator_notifications/integrations_notification_mailer_spec.rb +++ b/spec/mailers/administrator_notifications/integrations_notification_mailer_spec.rb @@ -6,6 +6,7 @@ RSpec.describe AdministratorNotifications::IntegrationsNotificationMailer do let!(:account) { create(:account) } let!(:administrator) { create(:user, :administrator, email: 'admin@example.com', account: account) } + let!(:another_administrator) { create(:user, :administrator, email: 'owner@example.com', account: account) } describe 'slack_disconnect' do let(:mail) { described_class.with(account: account).slack_disconnect.deliver_now } @@ -15,7 +16,7 @@ RSpec.describe AdministratorNotifications::IntegrationsNotificationMailer do end it 'renders the receiver email' do - expect(mail.to).to eq([administrator.email]) + expect(mail.to).to contain_exactly(administrator.email, another_administrator.email) end it 'includes reconnect instructions in the body' do @@ -35,7 +36,7 @@ RSpec.describe AdministratorNotifications::IntegrationsNotificationMailer do end it 'renders the receiver email' do - expect(mail.to).to eq([administrator.email]) + expect(mail.to).to contain_exactly(administrator.email, another_administrator.email) end end end diff --git a/spec/models/automation_rule_spec.rb b/spec/models/automation_rule_spec.rb index 53ebfa0c7..91452b8a4 100644 --- a/spec/models/automation_rule_spec.rb +++ b/spec/models/automation_rule_spec.rb @@ -60,6 +60,32 @@ RSpec.describe AutomationRule do expect(rule.valid?).to be false expect(rule.errors.messages[:conditions]).to eq(['Automation conditions should have query operator.']) end + + it 'allows labels as a valid condition attribute' do + params[:conditions] = [ + { + attribute_key: 'labels', + filter_operator: 'equal_to', + values: ['bug'], + query_operator: nil + } + ] + rule = FactoryBot.build(:automation_rule, params) + expect(rule.valid?).to be true + end + + it 'validates label condition operators' do + params[:conditions] = [ + { + attribute_key: 'labels', + filter_operator: 'is_present', + values: [], + query_operator: nil + } + ] + rule = FactoryBot.build(:automation_rule, params) + expect(rule.valid?).to be true + end end describe 'reauthorizable' do diff --git a/spec/models/concerns/featurable_spec.rb b/spec/models/concerns/featurable_spec.rb deleted file mode 100644 index 1cf0b87f2..000000000 --- a/spec/models/concerns/featurable_spec.rb +++ /dev/null @@ -1,57 +0,0 @@ -require 'rails_helper' - -RSpec.describe Featurable do - let(:account) { create(:account) } - - describe 'WhatsApp embedded signup feature' do - it 'is disabled by default' do - expect(account.feature_whatsapp_embedded_signup?).to be false - expect(account.feature_enabled?('whatsapp_embedded_signup')).to be false - end - - describe '#enable_features!' do - it 'enables the whatsapp embedded signup feature' do - account.enable_features!(:whatsapp_embedded_signup) - expect(account.feature_whatsapp_embedded_signup?).to be true - expect(account.feature_enabled?('whatsapp_embedded_signup')).to be true - end - - it 'enables multiple features at once' do - account.enable_features!(:whatsapp_embedded_signup, :help_center) - expect(account.feature_whatsapp_embedded_signup?).to be true - expect(account.feature_help_center?).to be true - end - end - - describe '#disable_features!' do - before do - account.enable_features!(:whatsapp_embedded_signup) - end - - it 'disables the whatsapp embedded signup feature' do - expect(account.feature_whatsapp_embedded_signup?).to be true - - account.disable_features!(:whatsapp_embedded_signup) - expect(account.feature_whatsapp_embedded_signup?).to be false - end - end - - describe '#enabled_features' do - it 'includes whatsapp_embedded_signup when enabled' do - account.enable_features!(:whatsapp_embedded_signup) - expect(account.enabled_features).to include('whatsapp_embedded_signup' => true) - end - - it 'does not include whatsapp_embedded_signup when disabled' do - account.disable_features!(:whatsapp_embedded_signup) - expect(account.enabled_features).not_to include('whatsapp_embedded_signup' => true) - end - end - - describe '#all_features' do - it 'includes whatsapp_embedded_signup in all features list' do - expect(account.all_features).to have_key('whatsapp_embedded_signup') - end - end - end -end diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index a29359528..007ce987f 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -136,7 +136,7 @@ RSpec.describe Conversation do notifiable_assignee_change: false, changed_attributes: changed_attributes, performed_by: nil - ).exactly(2).times + ) end it 'runs after_update callbacks' do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 845ec0625..213988a0d 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -111,6 +111,113 @@ RSpec.describe User do end end + describe '2FA/MFA functionality' do + before do + skip('Skipping since MFA is not configured in this environment') unless Chatwoot.encryption_configured? + end + + let(:user) { create(:user, password: 'Test@123456') } + + describe '#enable_two_factor!' do + it 'generates OTP secret for 2FA setup' do + expect(user.otp_secret).to be_nil + expect(user.otp_required_for_login).to be_falsey + + user.enable_two_factor! + + expect(user.otp_secret).not_to be_nil + # otp_required_for_login is false until verification is complete + expect(user.otp_required_for_login).to be_falsey + end + end + + describe '#disable_two_factor!' do + before do + user.enable_two_factor! + user.update!(otp_required_for_login: true) # Simulate verified 2FA + user.generate_backup_codes! + end + + it 'disables 2FA and clears OTP secret' do + user.disable_two_factor! + + expect(user.otp_secret).to be_nil + expect(user.otp_required_for_login).to be_falsey + expect(user.otp_backup_codes).to be_blank # Can be nil or empty array + end + end + + describe '#generate_backup_codes!' do + before do + user.enable_two_factor! + end + + it 'generates 10 backup codes' do + codes = user.generate_backup_codes! + + expect(codes).to be_an(Array) + expect(codes.length).to eq(10) + expect(codes.first).to match(/\A[A-F0-9]{8}\z/) # 8-character hex codes + expect(user.otp_backup_codes).not_to be_nil + end + end + + describe '#two_factor_provisioning_uri' do + before do + user.enable_two_factor! + end + + it 'generates a valid provisioning URI for QR code' do + uri = user.two_factor_provisioning_uri + + expect(uri).to include('otpauth://totp/') + expect(uri).to include(CGI.escape(user.email)) + expect(uri).to include('Chatwoot') + end + end + + describe '#validate_backup_code!' do + let(:backup_codes) { user.generate_backup_codes! } + + before do + user.enable_two_factor! + backup_codes + end + + it 'validates and invalidates correct backup code' do + code = backup_codes.first + result = user.validate_backup_code!(code) + expect(result).to be_truthy + + # Verify it's marked as used + user.reload + expect(user.otp_backup_codes).to include('XXXXXXXX') + end + + it 'rejects invalid backup code' do + result = user.validate_backup_code!('invalid') + expect(result).to be_falsey + end + + it 'rejects already used backup code' do + code = backup_codes.first + user.validate_backup_code!(code) + + # Try to use the same code again + result = user.validate_backup_code!(code) + expect(result).to be_falsey + end + + it 'handles blank code' do + result = user.validate_backup_code!(nil) + expect(result).to be_falsey + + result = user.validate_backup_code!('') + expect(result).to be_falsey + end + end + end + describe '#active_account_user' do let(:user) { create(:user) } let(:account1) { create(:account) } diff --git a/spec/requests/api/v1/profile/mfa_controller_spec.rb b/spec/requests/api/v1/profile/mfa_controller_spec.rb new file mode 100644 index 000000000..97a2e206f --- /dev/null +++ b/spec/requests/api/v1/profile/mfa_controller_spec.rb @@ -0,0 +1,274 @@ +require 'rails_helper' + +RSpec.describe 'MFA API', type: :request do + before do + skip('Skipping since MFA is not configured in this environment') unless Chatwoot.encryption_configured? + allow(Chatwoot).to receive(:mfa_enabled?).and_return(true) + end + + let(:account) { create(:account) } + let(:user) { create(:user, account: account, password: 'Test@123456') } + + describe 'GET /api/v1/profile/mfa' do + context 'when 2FA is disabled' do + it 'returns MFA disabled status' do + get '/api/v1/profile/mfa', + headers: user.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + expect(json_response['enabled']).to be_falsey + expect(json_response['backup_codes_generated']).to be_falsey + end + end + + context 'when 2FA is enabled' do + before do + user.enable_two_factor! + user.update!(otp_required_for_login: true) + end + + it 'returns MFA enabled status' do + get '/api/v1/profile/mfa', + headers: user.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + expect(json_response['enabled']).to be_truthy + end + + context 'with backup codes generated' do + before do + user.generate_backup_codes! + end + + it 'indicates backup codes are generated' do + get '/api/v1/profile/mfa', + headers: user.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + expect(json_response['backup_codes_generated']).to be_truthy + end + end + end + end + + describe 'POST /api/v1/profile/mfa' do + context 'when 2FA is not enabled' do + it 'enables 2FA and returns QR code URL' do + post '/api/v1/profile/mfa', + headers: user.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + expect(json_response['provisioning_url']).not_to be_nil + expect(json_response['provisioning_url']).to include('otpauth://totp') + expect(json_response['secret']).not_to be_nil + + user.reload + expect(user.otp_secret).not_to be_nil + end + end + + context 'when 2FA is already enabled' do + before do + user.enable_two_factor! + user.update!(otp_required_for_login: true) + end + + it 'returns error message' do + post '/api/v1/profile/mfa', + headers: user.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + json_response = response.parsed_body + expect(json_response['error']).to eq(I18n.t('errors.mfa.already_enabled')) + end + end + end + + describe 'POST /api/v1/profile/mfa/verify' do + before do + user.enable_two_factor! + end + + context 'with valid OTP code' do + it 'verifies and confirms 2FA setup with backup codes' do + otp_code = user.current_otp + + post '/api/v1/profile/mfa/verify', + params: { otp_code: otp_code }, + headers: user.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + expect(json_response['enabled']).to be_truthy + expect(json_response['backup_codes']).to be_an(Array) + expect(json_response['backup_codes'].length).to eq(10) + + user.reload + expect(user.otp_required_for_login).to be_truthy + expect(user.otp_backup_codes).not_to be_nil + end + end + + context 'with invalid OTP code' do + it 'returns error message' do + post '/api/v1/profile/mfa/verify', + params: { otp_code: '000000' }, + headers: user.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + json_response = response.parsed_body + expect(json_response['error']).to eq(I18n.t('errors.mfa.invalid_code')) + end + end + + context 'when 2FA is already verified' do + before do + user.update!(otp_required_for_login: true) + end + + it 'returns already enabled error' do + post '/api/v1/profile/mfa/verify', + params: { otp_code: user.current_otp }, + headers: user.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + json_response = response.parsed_body + expect(json_response['error']).to eq(I18n.t('errors.mfa.already_enabled')) + end + end + end + + describe 'DELETE /api/v1/profile/mfa' do + context 'when 2FA is enabled' do + before do + user.enable_two_factor! + user.update!(otp_required_for_login: true) + user.generate_backup_codes! + end + + context 'with valid password and OTP' do + it 'disables 2FA successfully' do + otp_code = user.current_otp + + delete '/api/v1/profile/mfa', + params: { password: 'Test@123456', otp_code: otp_code }, + headers: user.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + expect(json_response['enabled']).to be_falsey + + user.reload + expect(user.otp_required_for_login).to be_falsey + expect(user.otp_secret).to be_nil + expect(user.otp_backup_codes).to be_blank + end + end + + context 'with invalid password' do + it 'returns error message' do + otp_code = user.current_otp + + delete '/api/v1/profile/mfa', + params: { password: 'wrong_password', otp_code: otp_code }, + headers: user.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + json_response = response.parsed_body + expect(json_response['error']).to include('Invalid') + end + end + + context 'with invalid OTP' do + it 'returns error message' do + delete '/api/v1/profile/mfa', + params: { password: 'Test@123456', otp_code: '000000' }, + headers: user.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + json_response = response.parsed_body + expect(json_response['error']).to include('Invalid') + end + end + end + + context 'when 2FA is not enabled' do + it 'returns not enabled error' do + delete '/api/v1/profile/mfa', + params: { password: 'Test@123456', otp_code: '123456' }, + headers: user.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + json_response = response.parsed_body + expect(json_response['error']).to eq(I18n.t('errors.mfa.not_enabled')) + end + end + end + + describe 'POST /api/v1/profile/mfa/backup_codes' do + context 'when 2FA is enabled' do + before do + user.enable_two_factor! + user.update!(otp_required_for_login: true) + end + + context 'with valid OTP' do + it 'generates new backup codes' do + otp_code = user.current_otp + + post '/api/v1/profile/mfa/backup_codes', + params: { otp_code: otp_code }, + headers: user.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + expect(json_response['backup_codes']).to be_an(Array) + expect(json_response['backup_codes'].length).to eq(10) + end + end + + context 'with invalid OTP' do + it 'returns error message' do + post '/api/v1/profile/mfa/backup_codes', + params: { otp_code: '000000' }, + headers: user.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + json_response = response.parsed_body + expect(json_response['error']).to eq(I18n.t('errors.mfa.invalid_code')) + end + end + end + + context 'when 2FA is not enabled' do + it 'returns not enabled error' do + post '/api/v1/profile/mfa/backup_codes', + params: { otp_code: '123456' }, + headers: user.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + json_response = response.parsed_body + expect(json_response['error']).to eq(I18n.t('errors.mfa.not_enabled')) + end + end + end +end diff --git a/spec/services/automation_rules/action_service_spec.rb b/spec/services/automation_rules/action_service_spec.rb index e63fd7545..b4eaa5dd0 100644 --- a/spec/services/automation_rules/action_service_spec.rb +++ b/spec/services/automation_rules/action_service_spec.rb @@ -118,6 +118,45 @@ RSpec.describe AutomationRules::ActionService do end end + describe '#perform with add_label action' do + before do + rule.actions << { action_name: 'add_label', action_params: %w[bug feature] } + rule.save + end + + it 'will add labels to conversation' do + described_class.new(rule, account, conversation).perform + expect(conversation.reload.label_list).to include('bug', 'feature') + end + + it 'will not duplicate existing labels' do + conversation.add_labels(['bug']) + described_class.new(rule, account, conversation).perform + expect(conversation.reload.label_list.count('bug')).to eq(1) + expect(conversation.reload.label_list).to include('feature') + end + end + + describe '#perform with remove_label action' do + before do + conversation.add_labels(%w[bug feature support]) + rule.actions << { action_name: 'remove_label', action_params: %w[bug feature] } + rule.save + end + + it 'will remove specified labels from conversation' do + described_class.new(rule, account, conversation).perform + expect(conversation.reload.label_list).not_to include('bug', 'feature') + expect(conversation.reload.label_list).to include('support') + end + + it 'will not fail if labels do not exist on conversation' do + conversation.update_labels(['support']) # Remove bug and feature first + expect { described_class.new(rule, account, conversation).perform }.not_to raise_error + expect(conversation.reload.label_list).to include('support') + end + end + describe '#perform with add_private_note action' do let(:message_builder) { double } diff --git a/spec/services/automation_rules/conditions_filter_service_spec.rb b/spec/services/automation_rules/conditions_filter_service_spec.rb index 7082d31b1..5efd26341 100644 --- a/spec/services/automation_rules/conditions_filter_service_spec.rb +++ b/spec/services/automation_rules/conditions_filter_service_spec.rb @@ -134,5 +134,86 @@ RSpec.describe AutomationRules::ConditionsFilterService do end end end + + context 'when conditions based on labels' do + before do + conversation.add_labels(['bug']) + end + + context 'when filter_operator is equal_to' do + before do + rule.conditions = [ + { 'values': ['bug'], 'attribute_key': 'labels', 'query_operator': nil, 'filter_operator': 'equal_to' } + ] + rule.save + end + + it 'will return true when conversation has the label' do + expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(true) + end + + it 'will return false when conversation does not have the label' do + rule.conditions = [ + { 'values': ['feature'], 'attribute_key': 'labels', 'query_operator': nil, 'filter_operator': 'equal_to' } + ] + rule.save + expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(false) + end + end + + context 'when filter_operator is not_equal_to' do + before do + rule.conditions = [ + { 'values': ['feature'], 'attribute_key': 'labels', 'query_operator': nil, 'filter_operator': 'not_equal_to' } + ] + rule.save + end + + it 'will return true when conversation does not have the label' do + expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(true) + end + + it 'will return false when conversation has the label' do + conversation.add_labels(['feature']) + expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(false) + end + end + + context 'when filter_operator is is_present' do + before do + rule.conditions = [ + { 'values': [], 'attribute_key': 'labels', 'query_operator': nil, 'filter_operator': 'is_present' } + ] + rule.save + end + + it 'will return true when conversation has any labels' do + expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(true) + end + + it 'will return false when conversation has no labels' do + conversation.update_labels([]) + expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(false) + end + end + + context 'when filter_operator is is_not_present' do + before do + rule.conditions = [ + { 'values': [], 'attribute_key': 'labels', 'query_operator': nil, 'filter_operator': 'is_not_present' } + ] + rule.save + end + + it 'will return false when conversation has any labels' do + expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(false) + end + + it 'will return true when conversation has no labels' do + conversation.update_labels([]) + expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(true) + end + end + end end end diff --git a/spec/services/base_token_service_spec.rb b/spec/services/base_token_service_spec.rb new file mode 100644 index 000000000..1b34aedf2 --- /dev/null +++ b/spec/services/base_token_service_spec.rb @@ -0,0 +1,42 @@ +require 'rails_helper' + +describe BaseTokenService do + let(:payload) { { user_id: 1, exp: 5.minutes.from_now.to_i } } + let(:token_service) { described_class.new(payload: payload) } + + describe '#generate_token' do + it 'generates a JWT token with the provided payload' do + token = token_service.generate_token + expect(token).to be_present + expect(token).to be_a(String) + end + + it 'encodes the payload correctly' do + token = token_service.generate_token + decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first + expect(decoded['user_id']).to eq(1) + end + end + + describe '#decode_token' do + let(:token) { token_service.generate_token } + let(:decoder_service) { described_class.new(token: token) } + + it 'decodes a valid JWT token' do + decoded = decoder_service.decode_token + expect(decoded[:user_id]).to eq(1) + end + + it 'returns empty hash for invalid token' do + invalid_service = described_class.new(token: 'invalid_token') + expect(invalid_service.decode_token).to eq({}) + end + + it 'returns empty hash for expired token' do + expired_payload = { user_id: 1, exp: 1.minute.ago.to_i } + expired_token = JWT.encode(expired_payload, Rails.application.secret_key_base, 'HS256') + expired_service = described_class.new(token: expired_token) + expect(expired_service.decode_token).to eq({}) + end + end +end diff --git a/spec/services/mfa/authentication_service_spec.rb b/spec/services/mfa/authentication_service_spec.rb new file mode 100644 index 000000000..c4cc5ef5e --- /dev/null +++ b/spec/services/mfa/authentication_service_spec.rb @@ -0,0 +1,106 @@ +require 'rails_helper' + +describe Mfa::AuthenticationService do + before do + skip('Skipping since MFA is not configured in this environment') unless Chatwoot.encryption_configured? + user.enable_two_factor! + user.update!(otp_required_for_login: true) + end + + let(:user) { create(:user) } + + describe '#authenticate' do + context 'with OTP code' do + context 'when OTP is valid' do + it 'returns true' do + valid_otp = user.current_otp + service = described_class.new(user: user, otp_code: valid_otp) + expect(service.authenticate).to be_truthy + end + end + + context 'when OTP is invalid' do + it 'returns false' do + service = described_class.new(user: user, otp_code: '000000') + expect(service.authenticate).to be_falsey + end + end + + context 'when OTP is nil' do + it 'returns false' do + service = described_class.new(user: user, otp_code: nil) + expect(service.authenticate).to be_falsey + end + end + end + + context 'with backup code' do + let(:backup_codes) { user.generate_backup_codes! } + + context 'when backup code is valid' do + it 'returns true and invalidates the code' do + valid_code = backup_codes.first + service = described_class.new(user: user, backup_code: valid_code) + + expect(service.authenticate).to be_truthy + + # Code should be invalidated after use + user.reload + expect(user.otp_backup_codes).to include('XXXXXXXX') + end + end + + context 'when backup code is invalid' do + it 'returns false' do + service = described_class.new(user: user, backup_code: 'invalid') + expect(service.authenticate).to be_falsey + end + end + + context 'when backup code has already been used' do + it 'returns false' do + valid_code = backup_codes.first + # Use the code once + service = described_class.new(user: user, backup_code: valid_code) + service.authenticate + + # Try to use it again + service2 = described_class.new(user: user.reload, backup_code: valid_code) + expect(service2.authenticate).to be_falsey + end + end + end + + context 'with neither OTP nor backup code' do + it 'returns false' do + service = described_class.new(user: user) + expect(service.authenticate).to be_falsey + end + end + + context 'when user is nil' do + it 'returns false' do + service = described_class.new(user: nil, otp_code: '123456') + expect(service.authenticate).to be_falsey + end + end + + context 'when both OTP and backup code are provided' do + it 'uses OTP authentication first' do + valid_otp = user.current_otp + backup_codes = user.generate_backup_codes! + + service = described_class.new( + user: user, + otp_code: valid_otp, + backup_code: backup_codes.first + ) + + expect(service.authenticate).to be_truthy + # Backup code should not be consumed + user.reload + expect(user.otp_backup_codes).not_to include('XXXXXXXX') + end + end + end +end diff --git a/spec/services/mfa/token_service_spec.rb b/spec/services/mfa/token_service_spec.rb new file mode 100644 index 000000000..7d4fe55b6 --- /dev/null +++ b/spec/services/mfa/token_service_spec.rb @@ -0,0 +1,72 @@ +require 'rails_helper' + +describe Mfa::TokenService do + before do + skip('Skipping since MFA is not configured in this environment') unless Chatwoot.encryption_configured? + end + + let(:user) { create(:user) } + let(:token_service) { described_class.new(user: user) } + + describe '#generate_token' do + it 'generates a JWT token with user_id' do + token = token_service.generate_token + expect(token).to be_present + expect(token).to be_a(String) + end + + it 'includes user_id in the payload' do + token = token_service.generate_token + decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first + expect(decoded['user_id']).to eq(user.id) + end + + it 'sets expiration to 5 minutes from now' do + allow(Time).to receive(:now).and_return(Time.zone.parse('2024-01-01 12:00:00')) + token = token_service.generate_token + decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first + expected_exp = Time.zone.parse('2024-01-01 12:05:00').to_i + expect(decoded['exp']).to eq(expected_exp) + end + end + + describe '#verify_token' do + let(:valid_token) { token_service.generate_token } + + context 'with valid token' do + it 'returns the user' do + verifier = described_class.new(token: valid_token) + verified_user = verifier.verify_token + expect(verified_user).to eq(user) + end + end + + context 'with invalid token' do + it 'returns nil for malformed token' do + verifier = described_class.new(token: 'invalid_token') + expect(verifier.verify_token).to be_nil + end + + it 'returns nil for expired token' do + expired_payload = { user_id: user.id, exp: 1.minute.ago.to_i } + expired_token = JWT.encode(expired_payload, Rails.application.secret_key_base, 'HS256') + verifier = described_class.new(token: expired_token) + expect(verifier.verify_token).to be_nil + end + + it 'returns nil for non-existent user' do + payload = { user_id: 999_999, exp: 5.minutes.from_now.to_i } + token = JWT.encode(payload, Rails.application.secret_key_base, 'HS256') + verifier = described_class.new(token: token) + expect(verifier.verify_token).to be_nil + end + end + + context 'with blank token' do + it 'returns nil' do + verifier = described_class.new(token: nil) + expect(verifier.verify_token).to be_nil + end + end + end +end diff --git a/spec/services/widget/token_service_expiry_spec.rb b/spec/services/widget/token_service_expiry_spec.rb new file mode 100644 index 000000000..051a757a5 --- /dev/null +++ b/spec/services/widget/token_service_expiry_spec.rb @@ -0,0 +1,42 @@ +require 'rails_helper' + +RSpec.describe Widget::TokenService, type: :service do + describe 'token expiry configuration' do + let(:service) { described_class.new(payload: {}) } + + before do + # Clear any existing configs to ensure test isolation + InstallationConfig.where(name: 'WIDGET_TOKEN_EXPIRY').destroy_all + end + + context 'with valid configuration' do + before do + create(:installation_config, name: 'WIDGET_TOKEN_EXPIRY', value: '30') + end + + it 'uses the configured value for token expiry' do + freeze_time do + token = service.generate_token + decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first + expect(decoded['iat']).to eq(Time.now.to_i) + expect(decoded['exp']).to eq(30.days.from_now.to_i) + end + end + end + + context 'with empty configuration' do + before do + create(:installation_config, name: 'WIDGET_TOKEN_EXPIRY', value: '') + end + + it 'uses the default expiry' do + freeze_time do + token = service.generate_token + decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first + expect(decoded['iat']).to eq(Time.now.to_i) + expect(decoded['exp']).to eq(180.days.from_now.to_i) + end + end + end + end +end diff --git a/spec/services/widget/token_service_spec.rb b/spec/services/widget/token_service_spec.rb new file mode 100644 index 000000000..724728b51 --- /dev/null +++ b/spec/services/widget/token_service_spec.rb @@ -0,0 +1,43 @@ +require 'rails_helper' + +describe Widget::TokenService do + let(:payload) { { source_id: 'contact_123', inbox_id: 1 } } + let(:token_service) { described_class.new(payload: payload) } + + describe 'inheritance' do + it 'inherits from BaseTokenService' do + expect(described_class.superclass).to eq(BaseTokenService) + end + end + + describe '#generate_token' do + it 'generates a JWT token with the provided payload' do + token = token_service.generate_token + expect(token).to be_present + expect(token).to be_a(String) + end + + it 'encodes the payload correctly' do + token = token_service.generate_token + decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first + expect(decoded['source_id']).to eq('contact_123') + expect(decoded['inbox_id']).to eq(1) + end + end + + describe '#decode_token' do + let(:token) { token_service.generate_token } + let(:decoder_service) { described_class.new(token: token) } + + it 'decodes a valid JWT token' do + decoded = decoder_service.decode_token + expect(decoded[:source_id]).to eq('contact_123') + expect(decoded[:inbox_id]).to eq(1) + end + + it 'returns empty hash for invalid token' do + invalid_service = described_class.new(token: 'invalid_token') + expect(invalid_service.decode_token).to eq({}) + end + end +end