diff --git a/.env.example b/.env.example
index 2ab2933dc..de671599c 100644
--- a/.env.example
+++ b/.env.example
@@ -6,6 +6,13 @@
# Use `rake secret` to generate this variable
SECRET_KEY_BASE=replace_with_lengthy_secure_hex
+# Active Record Encryption keys (required for MFA/2FA functionality)
+# Generate these keys by running: rails db:encryption:init
+# IMPORTANT: Use different keys for each environment (development, staging, production)
+# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=
+# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=
+# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=
+
# Replace with the URL you are planning to use for your app
FRONTEND_URL=http://0.0.0.0:3000
# To use a dedicated URL for help center pages
diff --git a/.github/workflows/run_mfa_spec.yml b/.github/workflows/run_mfa_spec.yml
new file mode 100644
index 000000000..61b406f8a
--- /dev/null
+++ b/.github/workflows/run_mfa_spec.yml
@@ -0,0 +1,99 @@
+name: Run MFA Tests
+permissions:
+ contents: read
+
+on:
+ pull_request:
+
+# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
+concurrency:
+ group: pr-${{ github.workflow }}-${{ github.head_ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ runs-on: ubuntu-22.04
+ # Only run if MFA test keys are available
+ if: github.event_name == 'workflow_dispatch' || (github.repository == 'chatwoot/chatwoot' && github.actor != 'dependabot[bot]')
+
+ services:
+ postgres:
+ image: pgvector/pgvector:pg15
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: ''
+ POSTGRES_DB: postgres
+ POSTGRES_HOST_AUTH_METHOD: trust
+ ports:
+ - 5432:5432
+ options: >-
+ --mount type=tmpfs,destination=/var/lib/postgresql/data
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ redis:
+ image: redis
+ ports:
+ - 6379:6379
+ options: --entrypoint redis-server
+
+ env:
+ RAILS_ENV: test
+ POSTGRES_HOST: localhost
+ # Active Record encryption keys required for MFA - test keys only, not for production use
+ ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: 'test_key_a6cde8f7b9c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7'
+ ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: 'test_key_b7def9a8c0d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d8'
+ ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: 'test_salt_c8efa0b9d1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d9'
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: ruby/setup-ruby@v1
+ with:
+ bundler-cache: true
+
+ - name: Create database
+ run: bundle exec rake db:create
+
+ - name: Install pgvector extension
+ run: |
+ PGPASSWORD="" psql -h localhost -U postgres -d chatwoot_test -c "CREATE EXTENSION IF NOT EXISTS vector;"
+
+ - name: Seed database
+ run: bundle exec rake db:schema:load
+
+ - name: Run MFA-related backend tests
+ run: |
+ bundle exec rspec \
+ spec/services/mfa/token_service_spec.rb \
+ spec/services/mfa/authentication_service_spec.rb \
+ spec/requests/api/v1/profile/mfa_controller_spec.rb \
+ spec/controllers/devise_overrides/sessions_controller_spec.rb \
+ --profile=10 \
+ --format documentation
+ env:
+ NODE_OPTIONS: --openssl-legacy-provider
+
+ - name: Run MFA-related tests in user_spec
+ run: |
+ # Run specific MFA-related tests from user_spec
+ bundle exec rspec spec/models/user_spec.rb \
+ -e "two factor" \
+ -e "2FA" \
+ -e "MFA" \
+ -e "otp" \
+ -e "backup code" \
+ --profile=10 \
+ --format documentation
+ env:
+ NODE_OPTIONS: --openssl-legacy-provider
+
+ - name: Upload test logs
+ uses: actions/upload-artifact@v4
+ if: failure()
+ with:
+ name: mfa-test-logs
+ path: |
+ log/test.log
+ tmp/screenshots/
diff --git a/Gemfile b/Gemfile
index 927a853a0..265c609c1 100644
--- a/Gemfile
+++ b/Gemfile
@@ -78,6 +78,8 @@ gem 'barnes'
gem 'devise', '>= 4.9.4'
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
gem 'devise_token_auth', '>= 1.2.3'
+# two-factor authentication
+gem 'devise-two-factor', '>= 5.0.0'
# authorization
gem 'jwt'
gem 'pundit'
diff --git a/Gemfile.lock b/Gemfile.lock
index 2cce9f322..16e57d4f8 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -212,6 +212,11 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
+ devise-two-factor (6.1.0)
+ activesupport (>= 7.0, < 8.1)
+ devise (~> 4.0)
+ railties (>= 7.0, < 8.1)
+ rotp (~> 6.0)
devise_token_auth (1.2.5)
bcrypt (~> 3.0)
devise (> 3.5.2, < 5)
@@ -722,7 +727,8 @@ GEM
retriable (3.1.2)
reverse_markdown (2.1.1)
nokogiri
- rexml (3.4.1)
+ rexml (3.4.4)
+ rotp (6.3.0)
rspec-core (3.13.0)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.2)
@@ -1005,6 +1011,7 @@ DEPENDENCIES
debug (~> 1.8)
devise (>= 4.9.4)
devise-secure_password!
+ devise-two-factor (>= 5.0.0)
devise_token_auth (>= 1.2.3)
dotenv-rails (>= 3.0.0)
down
diff --git a/app/builders/agent_builder.rb b/app/builders/agent_builder.rb
index 54f478920..2fe11cae0 100644
--- a/app/builders/agent_builder.rb
+++ b/app/builders/agent_builder.rb
@@ -52,3 +52,5 @@ class AgentBuilder
}.compact))
end
end
+
+AgentBuilder.prepend_mod_with('AgentBuilder')
diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb
index af96441f8..57344cc1e 100644
--- a/app/controllers/api/v1/accounts/portals_controller.rb
+++ b/app/controllers/api/v1/accounts/portals_controller.rb
@@ -85,7 +85,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def live_chat_widget_params
permitted_params = params.permit(:inbox_id)
- return {} if permitted_params[:inbox_id].blank?
+ return {} unless permitted_params.key?(:inbox_id)
+ return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank?
inbox = Inbox.find(permitted_params[:inbox_id])
return {} unless inbox.web_widget?
diff --git a/app/controllers/api/v1/profile/mfa_controller.rb b/app/controllers/api/v1/profile/mfa_controller.rb
new file mode 100644
index 000000000..dd874f222
--- /dev/null
+++ b/app/controllers/api/v1/profile/mfa_controller.rb
@@ -0,0 +1,68 @@
+class Api::V1::Profile::MfaController < Api::BaseController
+ before_action :check_mfa_feature_available
+ before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
+ before_action :check_mfa_disabled, only: [:create, :verify]
+ before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
+ before_action :validate_password, only: [:destroy]
+
+ def show; end
+
+ def create
+ mfa_service.enable_two_factor!
+ end
+
+ def verify
+ @backup_codes = mfa_service.verify_and_activate!
+ end
+
+ def destroy
+ mfa_service.disable_two_factor!
+ end
+
+ def backup_codes
+ @backup_codes = mfa_service.generate_backup_codes!
+ end
+
+ private
+
+ def mfa_service
+ @mfa_service ||= Mfa::ManagementService.new(user: current_user)
+ end
+
+ def check_mfa_enabled
+ render_could_not_create_error(I18n.t('errors.mfa.not_enabled')) unless current_user.mfa_enabled?
+ end
+
+ def check_mfa_feature_available
+ return if Chatwoot.mfa_enabled?
+
+ render json: {
+ error: I18n.t('errors.mfa.feature_unavailable')
+ }, status: :forbidden
+ end
+
+ def check_mfa_disabled
+ render_could_not_create_error(I18n.t('errors.mfa.already_enabled')) if current_user.mfa_enabled?
+ end
+
+ def validate_otp
+ authenticated = Mfa::AuthenticationService.new(
+ user: current_user,
+ otp_code: mfa_params[:otp_code]
+ ).authenticate
+
+ return if authenticated
+
+ render_could_not_create_error(I18n.t('errors.mfa.invalid_code'))
+ end
+
+ def validate_password
+ return if current_user.valid_password?(mfa_params[:password])
+
+ render_could_not_create_error(I18n.t('errors.mfa.invalid_credentials'))
+ end
+
+ def mfa_params
+ params.permit(:otp_code, :password)
+ end
+end
diff --git a/app/controllers/devise_overrides/sessions_controller.rb b/app/controllers/devise_overrides/sessions_controller.rb
index fc7b12767..bf3a7f221 100644
--- a/app/controllers/devise_overrides/sessions_controller.rb
+++ b/app/controllers/devise_overrides/sessions_controller.rb
@@ -9,13 +9,11 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def create
- # Authenticate user via the temporary sso auth token
- if params[:sso_auth_token].present? && @resource.present?
- authenticate_resource_with_sso_token
- yield @resource if block_given?
- render_create_success
- else
- super
+ return handle_mfa_verification if mfa_verification_request?
+ return handle_sso_authentication if sso_authentication_request?
+
+ super do |resource|
+ return handle_mfa_required(resource) if resource&.mfa_enabled?
end
end
@@ -25,6 +23,20 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
private
+ def mfa_verification_request?
+ params[:mfa_token].present?
+ end
+
+ def sso_authentication_request?
+ params[:sso_auth_token].present? && @resource.present?
+ end
+
+ def handle_sso_authentication
+ authenticate_resource_with_sso_token
+ yield @resource if block_given?
+ render_create_success
+ end
+
def login_page_url(error: nil)
frontend_url = ENV.fetch('FRONTEND_URL', nil)
@@ -46,6 +58,41 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
user = User.from_email(params[:email])
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
end
+
+ def handle_mfa_required(resource)
+ render json: {
+ mfa_required: true,
+ mfa_token: Mfa::TokenService.new(user: resource).generate_token
+ }, status: :partial_content
+ end
+
+ def handle_mfa_verification
+ user = Mfa::TokenService.new(token: params[:mfa_token]).verify_token
+ return render_mfa_error('errors.mfa.invalid_token', :unauthorized) unless user
+
+ authenticated = Mfa::AuthenticationService.new(
+ user: user,
+ otp_code: params[:otp_code],
+ backup_code: params[:backup_code]
+ ).authenticate
+
+ return render_mfa_error('errors.mfa.invalid_code') unless authenticated
+
+ sign_in_mfa_user(user)
+ end
+
+ def sign_in_mfa_user(user)
+ @resource = user
+ @token = @resource.create_token
+ @resource.save!
+
+ sign_in(:user, @resource, store: false, bypass: false)
+ render_create_success
+ end
+
+ def render_mfa_error(message_key, status = :bad_request)
+ render json: { error: I18n.t(message_key) }, status: status
+ end
end
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
diff --git a/app/javascript/dashboard/api/captain/response.js b/app/javascript/dashboard/api/captain/response.js
index e3c42757a..d48bd81c7 100644
--- a/app/javascript/dashboard/api/captain/response.js
+++ b/app/javascript/dashboard/api/captain/response.js
@@ -6,11 +6,11 @@ class CaptainResponses extends ApiClient {
super('captain/assistant_responses', { accountScoped: true });
}
- get({ page = 1, searchKey, assistantId, documentId, status } = {}) {
+ get({ page = 1, search, assistantId, documentId, status } = {}) {
return axios.get(this.url, {
params: {
page,
- searchKey,
+ search,
assistant_id: assistantId,
document_id: documentId,
status,
diff --git a/app/javascript/dashboard/api/mfa.js b/app/javascript/dashboard/api/mfa.js
new file mode 100644
index 000000000..c18bea3e9
--- /dev/null
+++ b/app/javascript/dashboard/api/mfa.js
@@ -0,0 +1,28 @@
+/* global axios */
+import ApiClient from './ApiClient';
+
+class MfaAPI extends ApiClient {
+ constructor() {
+ super('profile/mfa', { accountScoped: false });
+ }
+
+ enable() {
+ return axios.post(`${this.url}`);
+ }
+
+ verify(otpCode) {
+ return axios.post(`${this.url}/verify`, { otp_code: otpCode });
+ }
+
+ disable(password, otpCode) {
+ return axios.delete(this.url, {
+ data: { password, otp_code: otpCode },
+ });
+ }
+
+ regenerateBackupCodes(otpCode) {
+ return axios.post(`${this.url}/backup_codes`, { otp_code: otpCode });
+ }
+}
+
+export default new MfaAPI();
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue
index f74bf95e2..b99f08a29 100644
--- a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue
@@ -51,12 +51,20 @@ const originalState = reactive({ ...state });
const liveChatWidgets = computed(() => {
const inboxes = store.getters['inboxes/getInboxes'];
- return inboxes
+ const widgetOptions = inboxes
.filter(inbox => inbox.channel_type === 'Channel::WebWidget')
.map(inbox => ({
value: inbox.id,
label: inbox.name,
}));
+
+ return [
+ {
+ value: '',
+ label: t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.NONE_OPTION'),
+ },
+ ...widgetOptions,
+ ];
});
const rules = {
@@ -108,7 +116,7 @@ watch(
widgetColor: newVal.color,
homePageLink: newVal.homepage_link,
slug: newVal.slug,
- liveChatWidgetInboxId: newVal.inbox?.id,
+ liveChatWidgetInboxId: newVal.inbox?.id || '',
});
if (newVal.logo) {
const {
diff --git a/app/javascript/dashboard/components-next/captain/AnimatingImg/AnimatingImg.story.vue b/app/javascript/dashboard/components-next/captain/AnimatingImg/AnimatingImg.story.vue
new file mode 100644
index 000000000..c75e6bd6d
--- /dev/null
+++ b/app/javascript/dashboard/components-next/captain/AnimatingImg/AnimatingImg.story.vue
@@ -0,0 +1,34 @@
+
+
+
+
+ {{ $t('MFA_VERIFICATION.DESCRIPTION') }} +
++ {{ $t('MFA_VERIFICATION.HELP_TEXT') }} +
++ {{ $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') }} +
+Hi <%= @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/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/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/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/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/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/inbox_spec.rb b/spec/enterprise/models/inbox_spec.rb index 6b0130c28..4ba1a7021 100644 --- a/spec/enterprise/models/inbox_spec.rb +++ b/spec/enterprise/models/inbox_spec.rb @@ -33,7 +33,7 @@ RSpec.describe Inbox do 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/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/line/incoming_message_service_spec.rb b/spec/services/line/incoming_message_service_spec.rb index 3bcdf8b04..9160efff2 100644 --- a/spec/services/line/incoming_message_service_spec.rb +++ b/spec/services/line/incoming_message_service_spec.rb @@ -105,6 +105,40 @@ describe Line::IncomingMessageService do }.with_indifferent_access end + let(:file_params) do + { + 'destination': '2342234234', + 'events': [ + { + 'replyToken': '0f3779fba3b349968c5d07db31eab56f', + 'type': 'message', + 'mode': 'active', + 'timestamp': 1_462_629_479_859, + 'source': { + 'type': 'user', + 'userId': 'U4af4980629' + }, + 'message': { + 'type': 'file', + 'id': '354718', + 'fileName': 'contacts.csv', + 'fileSize': 2978 + } + }, + { + 'replyToken': '8cf9239d56244f4197887e939187e19e', + 'type': 'follow', + 'mode': 'active', + 'timestamp': 1_462_629_479_859, + 'source': { + 'type': 'user', + 'userId': 'U4af4980629' + } + } + ] + }.with_indifferent_access + end + let(:sticker_params) do { 'destination': '2342234234', @@ -241,5 +275,35 @@ describe Line::IncomingMessageService do expect(line_channel.inbox.messages.first.attachments.first.file.blob.filename.to_s).to eq('media-354718.mp4') end end + + context 'when valid file message params' do + it 'creates appropriate conversations, message and contacts' do + line_bot = double + line_user_profile = double + allow(Line::Bot::Client).to receive(:new).and_return(line_bot) + allow(line_bot).to receive(:get_profile).and_return(line_user_profile) + file = fixture_file_upload(Rails.root.join('spec/assets/contacts.csv'), 'text/csv') + allow(line_bot).to receive(:get_message_content).and_return( + OpenStruct.new({ + body: Base64.encode64(file.read), + content_type: 'text/csv' + }) + ) + allow(line_user_profile).to receive(:body).and_return( + { + 'displayName': 'LINE Test', + 'userId': 'U4af4980629', + 'pictureUrl': 'https://test.com' + }.to_json + ) + described_class.new(inbox: line_channel.inbox, params: file_params).perform + expect(line_channel.inbox.conversations).not_to eq(0) + expect(Contact.all.first.name).to eq('LINE Test') + expect(Contact.all.first.additional_attributes['social_line_user_id']).to eq('U4af4980629') + expect(line_channel.inbox.messages.first.content).to be_nil + expect(line_channel.inbox.messages.first.attachments.first.file_type).to eq('file') + expect(line_channel.inbox.messages.first.attachments.first.file.blob.filename.to_s).to eq('contacts.csv') + end + 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_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