Merge branch 'develop' into feat/email-forwarding
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
+47
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user