mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 19:17:48 +00:00 
			
		
		
		
	feat: allow SP initiated SAML (#12447)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
		| @@ -22,6 +22,20 @@ | |||||||
|     }, |     }, | ||||||
|     "FORGOT_PASSWORD": "Forgot your password?", |     "FORGOT_PASSWORD": "Forgot your password?", | ||||||
|     "CREATE_NEW_ACCOUNT": "Create a new account", |     "CREATE_NEW_ACCOUNT": "Create a new account", | ||||||
|     "SUBMIT": "Login" |     "SUBMIT": "Login", | ||||||
|  |     "SAML": { | ||||||
|  |       "LABEL": "Log in via SSO", | ||||||
|  |       "TITLE": "Initiate Single Sign-on (SSO)", | ||||||
|  |       "SUBTITLE": "Enter your work email to access your organization", | ||||||
|  |       "BACK_TO_LOGIN": "Login via Password", | ||||||
|  |       "WORK_EMAIL": { | ||||||
|  |         "LABEL": "Work Email", | ||||||
|  |         "PLACEHOLDER": "Enter your work email" | ||||||
|  |       }, | ||||||
|  |       "SUBMIT": "Continue with SSO", | ||||||
|  |       "API": { | ||||||
|  |         "ERROR_MESSAGE": "SSO authentication failed" | ||||||
|  |       } | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,3 +1,5 @@ | |||||||
|  | import { parseBoolean } from '@chatwoot/utils'; | ||||||
|  |  | ||||||
| const { | const { | ||||||
|   API_CHANNEL_NAME: apiChannelName, |   API_CHANNEL_NAME: apiChannelName, | ||||||
|   API_CHANNEL_THUMBNAIL: apiChannelThumbnail, |   API_CHANNEL_THUMBNAIL: apiChannelThumbnail, | ||||||
| @@ -15,6 +17,7 @@ const { | |||||||
|   LOGO: logo, |   LOGO: logo, | ||||||
|   LOGO_DARK: logoDark, |   LOGO_DARK: logoDark, | ||||||
|   PRIVACY_URL: privacyURL, |   PRIVACY_URL: privacyURL, | ||||||
|  |   IS_ENTERPRISE: isEnterprise, | ||||||
|   TERMS_URL: termsURL, |   TERMS_URL: termsURL, | ||||||
|   WIDGET_BRAND_URL: widgetBrandURL, |   WIDGET_BRAND_URL: widgetBrandURL, | ||||||
|   DISABLE_USER_PROFILE_UPDATE: disableUserProfileUpdate, |   DISABLE_USER_PROFILE_UPDATE: disableUserProfileUpdate, | ||||||
| @@ -30,8 +33,8 @@ const state = { | |||||||
|   chatwootInboxToken, |   chatwootInboxToken, | ||||||
|   deploymentEnv, |   deploymentEnv, | ||||||
|   createNewAccountFromDashboard, |   createNewAccountFromDashboard, | ||||||
|   directUploadsEnabled: directUploadsEnabled === 'true', |   directUploadsEnabled: parseBoolean(directUploadsEnabled), | ||||||
|   disableUserProfileUpdate: disableUserProfileUpdate === 'true', |   disableUserProfileUpdate: parseBoolean(disableUserProfileUpdate), | ||||||
|   displayManifest, |   displayManifest, | ||||||
|   gitSha, |   gitSha, | ||||||
|   hCaptchaSiteKey, |   hCaptchaSiteKey, | ||||||
| @@ -42,6 +45,7 @@ const state = { | |||||||
|   privacyURL, |   privacyURL, | ||||||
|   termsURL, |   termsURL, | ||||||
|   widgetBrandURL, |   widgetBrandURL, | ||||||
|  |   isEnterprise: parseBoolean(isEnterprise), | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export const getters = { | export const getters = { | ||||||
|   | |||||||
| @@ -55,6 +55,7 @@ const model = defineModel({ | |||||||
|     <input |     <input | ||||||
|       v-bind="$attrs" |       v-bind="$attrs" | ||||||
|       v-model="model" |       v-model="model" | ||||||
|  |       :name="name" | ||||||
|       :type="type" |       :type="type" | ||||||
|       class="block w-full border-none rounded-md shadow-sm bg-n-alpha-black2 appearance-none outline outline-1 focus:outline focus:outline-1 text-n-slate-12 placeholder:text-n-slate-10 sm:text-sm sm:leading-6 px-3 py-3" |       class="block w-full border-none rounded-md shadow-sm bg-n-alpha-black2 appearance-none outline outline-1 focus:outline focus:outline-1 text-n-slate-12 placeholder:text-n-slate-10 sm:text-sm sm:leading-6 px-3 py-3" | ||||||
|       :class="{ |       :class="{ | ||||||
|   | |||||||
| @@ -41,7 +41,14 @@ export const validateRouteAccess = (to, next, chatwootConfig = {}) => { | |||||||
|     to.meta && |     to.meta && | ||||||
|     to.meta.requireSignupEnabled; |     to.meta.requireSignupEnabled; | ||||||
|  |  | ||||||
|   if (!to.name || isAnInalidSignupNavigation) { |   // Disable navigation to SAML login if enterprise is not enabled | ||||||
|  |   // SAML route has an attribute (requireEnterprise) in it's definition | ||||||
|  |   const isEnterpriseOnlyPath = | ||||||
|  |     chatwootConfig.isEnterprise !== 'true' && | ||||||
|  |     to.meta && | ||||||
|  |     to.meta.requireEnterprise; | ||||||
|  |  | ||||||
|  |   if (!to.name || isAnInalidSignupNavigation || isEnterpriseOnlyPath) { | ||||||
|     next(frontendURL('login')); |     next(frontendURL('login')); | ||||||
|     return; |     return; | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -85,6 +85,9 @@ export default { | |||||||
|     showSignupLink() { |     showSignupLink() { | ||||||
|       return parseBoolean(window.chatwootConfig.signupEnabled); |       return parseBoolean(window.chatwootConfig.signupEnabled); | ||||||
|     }, |     }, | ||||||
|  |     showSamlLogin() { | ||||||
|  |       return this.globalConfig.isEnterprise; | ||||||
|  |     }, | ||||||
|   }, |   }, | ||||||
|   created() { |   created() { | ||||||
|     if (this.ssoAuthToken) { |     if (this.ssoAuthToken) { | ||||||
| @@ -302,5 +305,13 @@ export default { | |||||||
|         <Spinner color-scheme="primary" size="" /> |         <Spinner color-scheme="primary" size="" /> | ||||||
|       </div> |       </div> | ||||||
|     </section> |     </section> | ||||||
|  |     <div v-if="showSamlLogin" class="mt-6 text-center"> | ||||||
|  |       <router-link | ||||||
|  |         to="/app/login/sso" | ||||||
|  |         class="inline-flex items-center text-sm font-medium text-n-brand hover:text-n-brand-dark" | ||||||
|  |       > | ||||||
|  |         {{ $t('LOGIN.SAML.LABEL') }} | ||||||
|  |       </router-link> | ||||||
|  |     </div> | ||||||
|   </main> |   </main> | ||||||
| </template> | </template> | ||||||
|   | |||||||
							
								
								
									
										102
									
								
								app/javascript/v3/views/login/Saml.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								app/javascript/v3/views/login/Saml.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,102 @@ | |||||||
|  | <script setup> | ||||||
|  | import { ref, computed, onMounted } from 'vue'; | ||||||
|  | import { useStore } from 'vuex'; | ||||||
|  | import { required, email } from '@vuelidate/validators'; | ||||||
|  | import { useVuelidate } from '@vuelidate/core'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  |  | ||||||
|  | // components | ||||||
|  | import FormInput from '../../components/Form/Input.vue'; | ||||||
|  | import NextButton from 'dashboard/components-next/button/Button.vue'; | ||||||
|  |  | ||||||
|  | const store = useStore(); | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const credentials = ref({ | ||||||
|  |   email: '', | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const loginApi = ref({ | ||||||
|  |   showLoading: false, | ||||||
|  |   hasErrored: false, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const validations = { | ||||||
|  |   credentials: { | ||||||
|  |     email: { | ||||||
|  |       required, | ||||||
|  |       email, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const v$ = useVuelidate(validations, { credentials }); | ||||||
|  |  | ||||||
|  | const globalConfig = computed(() => store.getters['globalConfig/get']); | ||||||
|  | const csrfToken = ref(''); | ||||||
|  |  | ||||||
|  | onMounted(() => { | ||||||
|  |   csrfToken.value = | ||||||
|  |     document | ||||||
|  |       .querySelector('meta[name="csrf-token"]') | ||||||
|  |       ?.getAttribute('content') || ''; | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <main | ||||||
|  |     class="flex flex-col w-full min-h-screen py-20 bg-n-brand/5 dark:bg-n-background sm:px-6 lg:px-8" | ||||||
|  |   > | ||||||
|  |     <section class="max-w-5xl mx-auto"> | ||||||
|  |       <img | ||||||
|  |         :src="globalConfig.logo" | ||||||
|  |         :alt="globalConfig.installationName" | ||||||
|  |         class="block w-auto h-8 mx-auto dark:hidden" | ||||||
|  |       /> | ||||||
|  |       <img | ||||||
|  |         v-if="globalConfig.logoDark" | ||||||
|  |         :src="globalConfig.logoDark" | ||||||
|  |         :alt="globalConfig.installationName" | ||||||
|  |         class="hidden w-auto h-8 mx-auto dark:block" | ||||||
|  |       /> | ||||||
|  |       <h2 class="mt-6 text-3xl font-medium text-center text-n-slate-12"> | ||||||
|  |         {{ t('LOGIN.SAML.TITLE') }} | ||||||
|  |       </h2> | ||||||
|  |     </section> | ||||||
|  |     <section | ||||||
|  |       class="bg-white shadow sm:mx-auto mt-11 sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg" | ||||||
|  |       :class="{ | ||||||
|  |         'animate-wiggle': loginApi.hasErrored, | ||||||
|  |       }" | ||||||
|  |     > | ||||||
|  |       <form class="space-y-5" method="POST" action="/api/v1/auth/saml_login"> | ||||||
|  |         <input type="hidden" name="authenticity_token" :value="csrfToken" I /> | ||||||
|  |         <FormInput | ||||||
|  |           v-model="credentials.email" | ||||||
|  |           name="email" | ||||||
|  |           type="text" | ||||||
|  |           :tabindex="1" | ||||||
|  |           required | ||||||
|  |           :label="t('LOGIN.SAML.WORK_EMAIL.LABEL')" | ||||||
|  |           :placeholder="t('LOGIN.SAML.WORK_EMAIL.PLACEHOLDER')" | ||||||
|  |           :has-error="v$.credentials.email.$error" | ||||||
|  |           @input="v$.credentials.email.$touch" | ||||||
|  |         /> | ||||||
|  |         <NextButton | ||||||
|  |           lg | ||||||
|  |           type="submit" | ||||||
|  |           class="w-full" | ||||||
|  |           :tabindex="2" | ||||||
|  |           :label="t('LOGIN.SAML.SUBMIT')" | ||||||
|  |           :disabled="loginApi.showLoading" | ||||||
|  |           :is-loading="loginApi.showLoading" | ||||||
|  |         /> | ||||||
|  |       </form> | ||||||
|  |     </section> | ||||||
|  |     <p class="mt-6 text-sm text-center text-n-slate-11"> | ||||||
|  |       <router-link to="/app/login" class="text-link text-n-brand"> | ||||||
|  |         {{ t('LOGIN.SAML.BACK_TO_LOGIN') }} | ||||||
|  |       </router-link> | ||||||
|  |     </p> | ||||||
|  |   </main> | ||||||
|  | </template> | ||||||
| @@ -1,6 +1,7 @@ | |||||||
| import { frontendURL } from 'dashboard/helper/URLHelper'; | import { frontendURL } from 'dashboard/helper/URLHelper'; | ||||||
|  |  | ||||||
| import Login from './login/Index.vue'; | import Login from './login/Index.vue'; | ||||||
|  | import SamlLogin from './login/Saml.vue'; | ||||||
| import Signup from './auth/signup/Index.vue'; | import Signup from './auth/signup/Index.vue'; | ||||||
| import ResetPassword from './auth/reset/password/Index.vue'; | import ResetPassword from './auth/reset/password/Index.vue'; | ||||||
| import Confirmation from './auth/confirmation/Index.vue'; | import Confirmation from './auth/confirmation/Index.vue'; | ||||||
| @@ -20,6 +21,12 @@ export default [ | |||||||
|       authError: route.query.error, |       authError: route.query.error, | ||||||
|     }), |     }), | ||||||
|   }, |   }, | ||||||
|  |   { | ||||||
|  |     path: frontendURL('login/sso'), | ||||||
|  |     name: 'sso_login', | ||||||
|  |     component: SamlLogin, | ||||||
|  |     meta: { requireEnterprise: true }, | ||||||
|  |   }, | ||||||
|   { |   { | ||||||
|     path: frontendURL('auth/signup'), |     path: frontendURL('auth/signup'), | ||||||
|     name: 'auth_signup', |     name: 'auth_signup', | ||||||
|   | |||||||
| @@ -36,6 +36,10 @@ en: | |||||||
|       success: 'Channel reauthorized successfully' |       success: 'Channel reauthorized successfully' | ||||||
|       not_required: 'Reauthorization is not required for this inbox' |       not_required: 'Reauthorization is not required for this inbox' | ||||||
|       invalid_channel: 'Invalid channel type for reauthorization' |       invalid_channel: 'Invalid channel type for reauthorization' | ||||||
|  |   auth: | ||||||
|  |     saml: | ||||||
|  |       invalid_email: 'Please enter a valid email address' | ||||||
|  |       authentication_failed: 'Authentication failed. Please check your credentials and try again.' | ||||||
|   messages: |   messages: | ||||||
|     reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions. |     reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions. | ||||||
|     reset_password_failure: Uh ho! We could not find any user with the specified email. |     reset_password_failure: Uh ho! We could not find any user with the specified email. | ||||||
|   | |||||||
| @@ -325,6 +325,9 @@ Rails.application.routes.draw do | |||||||
|         resources :webhooks, only: [:create] |         resources :webhooks, only: [:create] | ||||||
|       end |       end | ||||||
|  |  | ||||||
|  |       # Frontend API endpoint to trigger SAML authentication flow | ||||||
|  |       post 'auth/saml_login', to: 'auth#saml_login' | ||||||
|  |  | ||||||
|       resource :profile, only: [:show, :update] do |       resource :profile, only: [:show, :update] do | ||||||
|         delete :avatar, on: :collection |         delete :avatar, on: :collection | ||||||
|         member do |         member do | ||||||
|   | |||||||
							
								
								
									
										49
									
								
								enterprise/app/controllers/api/v1/auth_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								enterprise/app/controllers/api/v1/auth_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | |||||||
|  | class Api::V1::AuthController < Api::BaseController | ||||||
|  |   skip_before_action :authenticate_user!, only: [:saml_login] | ||||||
|  |   before_action :find_user_and_account, only: [:saml_login] | ||||||
|  |  | ||||||
|  |   def saml_login | ||||||
|  |     return if @account.nil? | ||||||
|  |  | ||||||
|  |     saml_initiation_url = "/auth/saml?account_id=#{@account.id}" | ||||||
|  |     redirect_to saml_initiation_url, status: :temporary_redirect | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def find_user_and_account | ||||||
|  |     return unless validate_email_presence | ||||||
|  |  | ||||||
|  |     find_saml_enabled_account | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def validate_email_presence | ||||||
|  |     @email = params[:email]&.downcase&.strip | ||||||
|  |     return true if @email.present? | ||||||
|  |  | ||||||
|  |     render json: { error: I18n.t('auth.saml.invalid_email') }, status: :bad_request | ||||||
|  |     false | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def find_saml_enabled_account | ||||||
|  |     user = User.from_email(@email) | ||||||
|  |     return render_saml_error unless user | ||||||
|  |  | ||||||
|  |     account_user = find_account_with_saml(user) | ||||||
|  |     return render_saml_error unless account_user | ||||||
|  |  | ||||||
|  |     @account = account_user.account | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def find_account_with_saml(user) | ||||||
|  |     user.account_users | ||||||
|  |         .joins(account: :saml_settings) | ||||||
|  |         .where.not(saml_settings: { sso_url: [nil, ''] }) | ||||||
|  |         .where.not(saml_settings: { certificate: [nil, ''] }) | ||||||
|  |         .find { |account_user| account_user.account.feature_enabled?('saml') } | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def render_saml_error | ||||||
|  |     render json: { error: I18n.t('auth.saml.authentication_failed') }, status: :unauthorized | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										131
									
								
								spec/enterprise/controllers/api/v1/auth_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										131
									
								
								spec/enterprise/controllers/api/v1/auth_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,131 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  |  | ||||||
|  | require 'rails_helper' | ||||||
|  |  | ||||||
|  | RSpec.describe 'Api::V1::Auth', type: :request do | ||||||
|  |   let(:account) { create(:account) } | ||||||
|  |   let(:user) { create(:user, email: 'user@example.com') } | ||||||
|  |  | ||||||
|  |   before do | ||||||
|  |     account.enable_features('saml') | ||||||
|  |     account.save! | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def json_response | ||||||
|  |     JSON.parse(response.body, symbolize_names: true) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe 'POST /api/v1/auth/saml_login' do | ||||||
|  |     context 'when email is blank' do | ||||||
|  |       it 'returns bad request' do | ||||||
|  |         post '/api/v1/auth/saml_login', params: { email: '' } | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:bad_request) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when email is nil' do | ||||||
|  |       it 'returns bad request' do | ||||||
|  |         post '/api/v1/auth/saml_login', params: {} | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:bad_request) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when user does not exist' do | ||||||
|  |       it 'returns unauthorized with generic message' do | ||||||
|  |         post '/api/v1/auth/saml_login', params: { email: 'nonexistent@example.com' } | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:unauthorized) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when user exists but has no SAML enabled accounts' do | ||||||
|  |       before do | ||||||
|  |         create(:account_user, user: user, account: account) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'returns unauthorized' do | ||||||
|  |         post '/api/v1/auth/saml_login', params: { email: user.email } | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:unauthorized) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when user has account without SAML feature enabled' do | ||||||
|  |       let(:saml_settings) { create(:account_saml_settings, account: account) } | ||||||
|  |  | ||||||
|  |       before do | ||||||
|  |         saml_settings | ||||||
|  |         create(:account_user, user: user, account: account) | ||||||
|  |         account.disable_features('saml') | ||||||
|  |         account.save! | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'returns unauthorized' do | ||||||
|  |         post '/api/v1/auth/saml_login', params: { email: user.email } | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:unauthorized) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when user has valid SAML configuration' do | ||||||
|  |       let(:saml_settings) do | ||||||
|  |         create(:account_saml_settings, account: account) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       before do | ||||||
|  |         saml_settings | ||||||
|  |         create(:account_user, user: user, account: account) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'redirects to SAML initiation URL' do | ||||||
|  |         post '/api/v1/auth/saml_login', params: { email: user.email } | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:temporary_redirect) | ||||||
|  |         expect(response.location).to include("/auth/saml?account_id=#{account.id}") | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'handles email case insensitivity' do | ||||||
|  |         post '/api/v1/auth/saml_login', params: { email: user.email.upcase } | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:temporary_redirect) | ||||||
|  |         expect(response.location).to include("/auth/saml?account_id=#{account.id}") | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'strips whitespace from email' do | ||||||
|  |         post '/api/v1/auth/saml_login', params: { email: "  #{user.email}  " } | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:temporary_redirect) | ||||||
|  |         expect(response.location).to include("/auth/saml?account_id=#{account.id}") | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when user has multiple accounts with SAML' do | ||||||
|  |       let(:account2) { create(:account) } | ||||||
|  |       let(:saml_settings1) do | ||||||
|  |         create(:account_saml_settings, account: account) | ||||||
|  |       end | ||||||
|  |       let(:saml_settings2) do | ||||||
|  |         create(:account_saml_settings, account: account2) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       before do | ||||||
|  |         account2.enable_features('saml') | ||||||
|  |         account2.save! | ||||||
|  |         saml_settings1 | ||||||
|  |         saml_settings2 | ||||||
|  |         create(:account_user, user: user, account: account) | ||||||
|  |         create(:account_user, user: user, account: account2) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'redirects to the first SAML enabled account' do | ||||||
|  |         post '/api/v1/auth/saml_login', params: { email: user.email } | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:temporary_redirect) | ||||||
|  |         returned_account_id = response.location.match(/account_id=(\d+)/)[1].to_i | ||||||
|  |         expect([account.id, account2.id]).to include(returned_account_id) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
		Reference in New Issue
	
	Block a user
	 Shivam Mishra
					Shivam Mishra