Merge branch 'develop' into feat/CW-5624

This commit is contained in:
Sivin Varghese
2025-09-24 18:58:41 +05:30
committed by GitHub
27 changed files with 329 additions and 88 deletions
@@ -1,8 +1,10 @@
<script setup>
import { defineProps, computed } from 'vue';
import { defineProps, computed, reactive } from 'vue';
import Message from './Message.vue';
import { MESSAGE_TYPES } from './constants.js';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { useMapGetter } from 'dashboard/composables/store.js';
import MessageApi from 'dashboard/api/inbox/message.js';
/**
* Props definition for the component
@@ -43,6 +45,48 @@ const allMessages = computed(() => {
return useCamelCase(props.messages, { deep: true });
});
const currentChat = useMapGetter('getSelectedChat');
// Cache for fetched reply messages to avoid duplicate API calls
const fetchedReplyMessages = reactive(new Map());
/**
* Fetches a specific message from the API by trying to get messages around it
* @param {number} messageId - The ID of the message to fetch
* @param {number} conversationId - The ID of the conversation
* @returns {Promise<Object|null>} - The fetched message or null if not found/error
*/
const fetchReplyMessage = async (messageId, conversationId) => {
// Return cached result if already fetched
if (fetchedReplyMessages.has(messageId)) {
return fetchedReplyMessages.get(messageId);
}
try {
const response = await MessageApi.getPreviousMessages({
conversationId,
before: messageId + 100,
after: messageId - 100,
});
const messages = response.data?.payload || [];
const targetMessage = messages.find(msg => msg.id === messageId);
if (targetMessage) {
const camelCaseMessage = useCamelCase(targetMessage);
fetchedReplyMessages.set(messageId, camelCaseMessage);
return camelCaseMessage;
}
// Cache null result to avoid repeated API calls
fetchedReplyMessages.set(messageId, null);
return null;
} catch (error) {
fetchedReplyMessages.set(messageId, null);
return null;
}
};
/**
* Determines if a message should be grouped with the next message
* @param {Number} index - Index of the current message
@@ -90,10 +134,26 @@ const getInReplyToMessage = parentMessage => {
if (!inReplyToMessageId) return null;
// Find in-reply-to message in the messages prop
const replyMessage = props.messages?.find(
message => message.id === inReplyToMessageId
);
// Try to find in current messages first
let replyMessage = props.messages?.find(msg => msg.id === inReplyToMessageId);
// Then try store messages
if (!replyMessage && currentChat.value?.messages) {
replyMessage = currentChat.value.messages.find(
msg => msg.id === inReplyToMessageId
);
}
// Then check fetch cache
if (!replyMessage && fetchedReplyMessages.has(inReplyToMessageId)) {
replyMessage = fetchedReplyMessages.get(inReplyToMessageId);
}
// If still not found and we have conversation context, fetch it
if (!replyMessage && currentChat.value?.id) {
fetchReplyMessage(inReplyToMessageId, currentChat.value.id);
return null; // Let UI handle loading state
}
return replyMessage ? useCamelCase(replyMessage) : null;
};
+1
View File
@@ -49,4 +49,5 @@ export const PREMIUM_FEATURES = [
FEATURE_FLAGS.AUDIT_LOGS,
FEATURE_FLAGS.HELP_CENTER,
FEATURE_FLAGS.CAPTAIN_V2,
FEATURE_FLAGS.SAML,
];
@@ -145,3 +145,34 @@ export const extractFilenameFromUrl = url => {
return match ? match[1] : url;
}
};
/**
* Normalizes a comma/newline separated list of domains
* @param {string} domains - The comma/newline separated list of domains
* @returns {string} - The normalized list of domains
* - Converts newlines to commas
* - Trims whitespace
* - Lowercases entries
* - Removes empty values
* - De-duplicates while preserving original order
*/
export const sanitizeAllowedDomains = domains => {
if (!domains) return '';
const tokens = domains
.replace(/\r\n/g, '\n')
.replace(/\s*\n\s*/g, ',')
.split(',')
.map(d => d.trim().toLowerCase())
.filter(d => d.length > 0);
// De-duplicate while preserving order using Set and filter index
const seen = new Set();
const unique = tokens.filter(d => {
if (seen.has(d)) return false;
seen.add(d);
return true;
});
return unique.join(',');
};
@@ -8,6 +8,7 @@ import {
timeStampAppendedURL,
getHostNameFromURL,
extractFilenameFromUrl,
sanitizeAllowedDomains,
} from '../URLHelper';
describe('#URL Helpers', () => {
@@ -318,4 +319,32 @@ describe('#URL Helpers', () => {
).toBe('file.doc');
});
});
describe('sanitizeAllowedDomains', () => {
it('returns empty string for falsy input', () => {
expect(sanitizeAllowedDomains('')).toBe('');
expect(sanitizeAllowedDomains(null)).toBe('');
expect(sanitizeAllowedDomains(undefined)).toBe('');
});
it('trims whitespace and converts newlines to commas', () => {
const input = ' example.com \n foo.bar\nbar.baz ';
expect(sanitizeAllowedDomains(input)).toBe('example.com,foo.bar,bar.baz');
});
it('handles Windows newlines and mixed spacing', () => {
const input = ' example.com\r\n\tfoo.bar , bar.baz ';
expect(sanitizeAllowedDomains(input)).toBe('example.com,foo.bar,bar.baz');
});
it('removes empty values from repeated commas', () => {
const input = ',,example.com,,foo.bar,,';
expect(sanitizeAllowedDomains(input)).toBe('example.com,foo.bar');
});
it('lowercases entries and de-duplicates preserving order', () => {
const input = 'Example.com,FOO.bar,example.com,Bar.Baz,foo.BAR';
expect(sanitizeAllowedDomains(input)).toBe('example.com,foo.bar,bar.baz');
});
});
});
@@ -5,6 +5,8 @@
"PLACEHOLDER": "Search",
"EMPTY_STATE": "No results found"
},
"CLOSE": "Close"
"CLOSE": "Close",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger Script",
"MESSENGER_SUB_HEAD": "Place this button inside your body tag",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Agents",
"INBOX_AGENTS_SUB_TEXT": "Add or remove agents from this inbox",
"AGENT_ASSIGNMENT": "Conversation Assignment",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -1,10 +1,14 @@
<script setup>
import { useI18n } from 'vue-i18n';
defineProps({
title: { type: String, required: true },
description: { type: String, required: true },
withBorder: { type: Boolean, default: false },
hideContent: { type: Boolean, default: false },
beta: { type: Boolean, default: false },
});
const { t } = useI18n();
</script>
<template>
@@ -17,8 +21,15 @@ defineProps({
>
<header class="grid grid-cols-4">
<div class="col-span-3">
<h4 class="text-lg font-medium text-n-slate-12">
<h4 class="text-lg font-medium text-n-slate-12 flex items-center gap-2">
<slot name="title">{{ title }}</slot>
<div
v-if="beta"
v-tooltip.top="t('GENERAL.BETA_DESCRIPTION')"
class="text-xs uppercase text-n-iris-11 border border-1 border-n-iris-10 leading-none rounded-lg px-1 py-0.5"
>
{{ t('GENERAL.BETA') }}
</div>
</h4>
<p class="text-n-slate-11 text-sm mt-2">
<slot name="description">{{ description }}</slot>
@@ -30,6 +30,10 @@ const props = defineProps({
type: String,
default: '',
},
provider: {
type: String,
default: '',
},
customRoleId: {
type: Number,
default: null,
@@ -203,6 +207,7 @@ const resetPassword = async () => {
<div class="flex flex-row justify-start w-full gap-2 px-0 py-2">
<div class="w-[50%] ltr:text-left rtl:text-right">
<Button
v-if="provider !== 'saml'"
ghost
type="button"
icon="i-lucide-lock-keyhole"
@@ -261,6 +261,7 @@ const confirmDeletion = () => {
v-if="showEditPopup"
:id="currentAgent.id"
:name="currentAgent.name"
:provider="currentAgent.provider"
:type="currentAgent.role"
:email="currentAgent.email"
:availability="currentAgent.availability_status"
@@ -7,7 +7,9 @@ import SmtpSettings from '../SmtpSettings.vue';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import NextButton from 'dashboard/components-next/button/Button.vue';
import TextArea from 'next/textarea/TextArea.vue';
import WhatsappReauthorize from '../channels/whatsapp/Reauthorize.vue';
import { sanitizeAllowedDomains } from 'dashboard/helper/URLHelper';
export default {
components: {
@@ -15,6 +17,7 @@ export default {
ImapSettings,
SmtpSettings,
NextButton,
TextArea,
WhatsappReauthorize,
},
mixins: [inboxMixin],
@@ -33,6 +36,8 @@ export default {
whatsAppInboxAPIKey: '',
isRequestingReauthorization: false,
isSyncingTemplates: false,
allowedDomains: '',
isUpdatingAllowedDomains: false,
};
},
validations: {
@@ -57,6 +62,7 @@ export default {
methods: {
setDefaults() {
this.hmacMandatory = this.inbox.hmac_mandatory || false;
this.allowedDomains = this.inbox.allowed_domains || '';
},
handleHmacFlag() {
this.updateInbox();
@@ -76,6 +82,28 @@ export default {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
async updateAllowedDomains() {
this.isUpdatingAllowedDomains = true;
const sanitizedAllowedDomains = sanitizeAllowedDomains(
this.allowedDomains
);
try {
const payload = {
id: this.inbox.id,
formData: false,
channel: {
allowed_domains: sanitizedAllowedDomains,
},
};
await this.$store.dispatch('inboxes/updateInbox', payload);
this.allowedDomains = sanitizedAllowedDomains;
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
} finally {
this.isUpdatingAllowedDomains = false;
}
},
async updateWhatsAppInboxAPIKey() {
try {
const payload = {
@@ -180,6 +208,30 @@ export default {
/>
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.TITLE')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.SUBTITLE')"
>
<div class="flex flex-col w-full max-w-3xl gap-4">
<TextArea
v-model="allowedDomains"
:placeholder="
$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.PLACEHOLDER')
"
auto-height
min-height="8rem"
class="w-full"
/>
<div>
<NextButton
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:is-loading="isUpdatingAllowedDomains"
@click="updateAllowedDomains"
/>
</div>
</div>
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_VERIFICATION')"
>
@@ -171,6 +171,7 @@ onMounted(() => {
<SectionLayout
:title="t('SECURITY_SETTINGS.SAML.TITLE')"
:description="t('SECURITY_SETTINGS.SAML.NOTE')"
beta
:hide-content="!hasFeature || !isEnabled || isLoading"
>
<template #headerActions>
+1 -1
View File
@@ -541,7 +541,7 @@ const countries = [
},
{
name: 'Guyana',
dial_code: '+595',
dial_code: '+592',
emoji: '🇬🇾',
id: 'GY',
},
@@ -5,6 +5,10 @@ export default {
type: String,
default: '',
},
bg: {
type: String,
default: 'bg-white dark:bg-n-solid-2',
},
},
};
</script>
@@ -15,7 +19,7 @@ export default {
<div class="w-full border-t border-n-strong" />
</div>
<div v-if="label" class="relative flex justify-center text-sm">
<span class="bg-white dark:bg-n-solid-2 px-2 text-n-slate-10">
<span class="px-2 text-n-slate-10" :class="bg">
{{ label }}
</span>
</div>
@@ -1,9 +1,8 @@
import { shallowMount } from '@vue/test-utils';
import GoogleOAuthButton from './Button.vue';
function getWrapper(showSeparator) {
function getWrapper() {
return shallowMount(GoogleOAuthButton, {
propsData: { showSeparator: showSeparator },
mocks: { $t: text => text },
});
}
@@ -20,16 +19,6 @@ describe('GoogleOAuthButton.vue', () => {
window.chatwootConfig = {};
});
it('renders the OR separator if showSeparator is true', () => {
const wrapper = getWrapper(true);
expect(wrapper.findComponent({ ref: 'divider' }).exists()).toBe(true);
});
it('does not render the OR separator if showSeparator is false', () => {
const wrapper = getWrapper(false);
expect(wrapper.findComponent({ ref: 'divider' }).exists()).toBe(false);
});
it('generates the correct Google Auth URL', () => {
const wrapper = getWrapper();
const googleAuthUrl = new URL(wrapper.vm.getGoogleAuthUrl());
@@ -42,7 +31,5 @@ describe('GoogleOAuthButton.vue', () => {
);
expect(params.get('response_type')).toBe('code');
expect(params.get('scope')).toBe('email profile');
expect(wrapper.findComponent({ ref: 'divider' }).exists()).toBe(true);
});
});
@@ -1,16 +1,5 @@
<script>
import SimpleDivider from '../Divider/SimpleDivider.vue';
export default {
components: {
SimpleDivider,
},
props: {
showSeparator: {
type: Boolean,
default: true,
},
},
methods: {
getGoogleAuthUrl() {
// Ideally a request to /auth/google_oauth2 should be made
@@ -52,11 +41,5 @@ export default {
{{ $t('LOGIN.OAUTH.GOOGLE_LOGIN') }}
</span>
</a>
<SimpleDivider
v-if="showSeparator"
ref="divider"
:label="$t('COMMON.OR')"
class="uppercase"
/>
</div>
</template>
@@ -5,6 +5,7 @@ import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
import SimpleDivider from '../../../../../components/Divider/SimpleDivider.vue';
import FormInput from '../../../../../components/Form/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { isValidPassword } from 'shared/helpers/Validators';
@@ -17,6 +18,7 @@ export default {
FormInput,
GoogleOAuthButton,
NextButton,
SimpleDivider,
VueHcaptcha,
},
setup() {
@@ -210,9 +212,17 @@ export default {
:is-loading="isSignupInProgress"
/>
</form>
<GoogleOAuthButton v-if="showGoogleOAuth" class="flex-col-reverse">
{{ $t('REGISTER.OAUTH.GOOGLE_SIGNUP') }}
</GoogleOAuthButton>
<div class="flex flex-col">
<SimpleDivider
v-if="showGoogleOAuth || showSamlLogin"
:label="$t('COMMON.OR')"
bg="bg-n-background"
class="uppercase"
/>
<GoogleOAuthButton v-if="showGoogleOAuth">
{{ $t('REGISTER.OAUTH.GOOGLE_SIGNUP') }}
</GoogleOAuthButton>
</div>
<p
class="text-sm mb-1 mt-5 text-n-slate-12 [&>a]:text-n-brand [&>a]:font-medium [&>a]:hover:brightness-110"
v-html="termsLink"
+21 -9
View File
@@ -11,6 +11,7 @@ import SessionStorage from 'shared/helpers/sessionStorage';
import { useBranding } from 'shared/composables/useBranding';
// components
import SimpleDivider from '../../components/Divider/SimpleDivider.vue';
import FormInput from '../../components/Form/Input.vue';
import GoogleOAuthButton from '../../components/GoogleOauth/Button.vue';
import Spinner from 'shared/components/Spinner.vue';
@@ -30,6 +31,7 @@ export default {
GoogleOAuthButton,
Spinner,
NextButton,
SimpleDivider,
MfaVerification,
},
props: {
@@ -253,7 +255,25 @@ export default {
}"
>
<div v-if="!email">
<GoogleOAuthButton v-if="showGoogleOAuth" />
<div class="flex flex-col">
<GoogleOAuthButton v-if="showGoogleOAuth" />
<div v-if="showSamlLogin" class="mt-4 text-center">
<router-link
to="/app/login/sso"
class="inline-flex justify-center w-full px-4 py-3 bg-n-background dark:bg-n-solid-3 rounded-md shadow-sm ring-1 ring-inset ring-n-container dark:ring-n-container focus:outline-offset-0 hover:bg-n-alpha-2 dark:hover:bg-n-alpha-2"
>
<span class="i-lucide-key h-6 text-n-slate-11" />
<span class="ml-2 text-base font-medium text-n-slate-12">
{{ $t('LOGIN.SAML.LABEL') }}
</span>
</router-link>
</div>
<SimpleDivider
v-if="showGoogleOAuth || showSamlLogin"
:label="$t('COMMON.OR')"
class="uppercase"
/>
</div>
<form class="space-y-5" @submit.prevent="submitFormLogin">
<FormInput
v-model="credentials.email"
@@ -305,13 +325,5 @@ export default {
<Spinner color-scheme="primary" size="" />
</div>
</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>
</template>
+28 -3
View File
@@ -1,14 +1,22 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { ref, nextTick, computed, onMounted } from 'vue';
import { useStore } from 'vuex';
import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
// components
import FormInput from '../../components/Form/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
authError: {
type: String,
default: '',
},
});
const store = useStore();
const { t } = useI18n();
@@ -21,6 +29,16 @@ const loginApi = ref({
hasErrored: false,
});
const handleAuthError = () => {
if (!props.authError) {
return;
}
const translatedMessage = t('LOGIN.SAML.API.ERROR_MESSAGE');
useAlert(translatedMessage);
loginApi.value.hasErrored = true;
};
const validations = {
credentials: {
email: {
@@ -35,11 +53,13 @@ const v$ = useVuelidate(validations, { credentials });
const globalConfig = computed(() => store.getters['globalConfig/get']);
const csrfToken = ref('');
onMounted(() => {
onMounted(async () => {
csrfToken.value =
document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content') || '';
await nextTick(handleAuthError);
});
</script>
@@ -70,7 +90,6 @@ onMounted(() => {
}"
>
<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"
@@ -82,6 +101,12 @@ onMounted(() => {
:has-error="v$.credentials.email.$error"
@input="v$.credentials.email.$touch"
/>
<input
type="hidden"
class="h-0"
name="authenticity_token"
:value="csrfToken"
/>
<NextButton
lg
type="submit"
+3
View File
@@ -26,6 +26,9 @@ export default [
name: 'sso_login',
component: SamlLogin,
meta: { requireEnterprise: true },
props: route => ({
authError: route.query.error,
}),
},
{
path: frontendURL('auth/signup'),
+9 -1
View File
@@ -244,8 +244,16 @@ class Message < ApplicationRecord
def should_index?
return false unless ChatwootApp.advanced_search_allowed?
return false unless account.feature_enabled?('advanced_search')
return false unless incoming? || outgoing?
# For Chatwoot Cloud:
# - Enable indexing only if the account is paid.
# - The `advanced_search_indexing` feature flag is used only in the cloud.
#
# For Self-hosted:
# - Adding an extra feature flag here would cause confusion.
# - If the user has configured Elasticsearch, enabling `advanced_search`
# should automatically work without any additional flags.
return false if ChatwootApp.chatwoot_cloud? && !account.feature_enabled?('advanced_search_indexing')
true
end
@@ -5,6 +5,7 @@ json.availability_status resource.availability_status
json.auto_offline resource.auto_offline
json.confirmed resource.confirmed?
json.email resource.email
json.provider resource.provider
json.available_name resource.available_name
json.custom_attributes resource.custom_attributes if resource.custom_attributes.present?
json.name resource.name
+5
View File
@@ -209,3 +209,8 @@
display_name: SAML
enabled: false
premium: true
- name: advanced_search_indexing
display_name: Advanced Search Indexing
enabled: false
premium: true
chatwoot_internal: true
@@ -44,6 +44,16 @@ class Api::V1::AuthController < Api::BaseController
end
def render_saml_error
render json: { error: I18n.t('auth.saml.authentication_failed') }, status: :unauthorized
redirect_to sso_login_page_url(error: 'saml-authentication-failed')
end
def sso_login_page_url(error: nil)
frontend_url = ENV.fetch('FRONTEND_URL', nil)
params = { error: error }.compact
query = params.to_query
query_fragment = query.present? ? "?#{query}" : ''
"#{frontend_url}/app/login/sso#{query_fragment}"
end
end
@@ -15,13 +15,14 @@ class Enterprise::Billing::HandleStripeEventService
channel_email
channel_instagram
captain_integration
advanced_search_indexing
].freeze
# Additional features available starting with the Business plan
BUSINESS_PLAN_FEATURES = %w[sla custom_roles].freeze
# Additional features available only in the Enterprise plan
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding].freeze
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
def perform(event:)
@event = event
@@ -9,10 +9,8 @@ RSpec.describe 'Api::V1::Auth', type: :request do
before do
account.enable_features('saml')
account.save!
end
def json_response
JSON.parse(response.body, symbolize_names: true)
allow(ENV).to receive(:fetch).and_call_original
allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('http://www.example.com')
end
describe 'POST /api/v1/auth/saml_login' do
@@ -33,10 +31,10 @@ RSpec.describe 'Api::V1::Auth', type: :request do
end
context 'when user does not exist' do
it 'returns unauthorized with generic message' do
it 'redirects to SSO login page with error' do
post '/api/v1/auth/saml_login', params: { email: 'nonexistent@example.com' }
expect(response).to have_http_status(:unauthorized)
expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed')
end
end
@@ -45,10 +43,10 @@ RSpec.describe 'Api::V1::Auth', type: :request do
create(:account_user, user: user, account: account)
end
it 'returns unauthorized' do
it 'redirects to SSO login page with error' do
post '/api/v1/auth/saml_login', params: { email: user.email }
expect(response).to have_http_status(:unauthorized)
expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed')
end
end
@@ -62,10 +60,10 @@ RSpec.describe 'Api::V1::Auth', type: :request do
account.save!
end
it 'returns unauthorized' do
it 'redirects to SSO login page with error' do
post '/api/v1/auth/saml_login', params: { email: user.email }
expect(response).to have_http_status(:unauthorized)
expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed')
end
end
@@ -82,21 +80,6 @@ RSpec.describe 'Api::V1::Auth', type: :request do
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
@@ -122,7 +105,6 @@ RSpec.describe 'Api::V1::Auth', type: :request do
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
+15 -3
View File
@@ -621,7 +621,7 @@ RSpec.describe Message do
before do
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(true)
account.enable_features('advanced_search')
account.enable_features('advanced_search_indexing')
end
context 'when advanced search is not allowed globally' do
@@ -634,9 +634,10 @@ RSpec.describe Message do
end
end
context 'when advanced search feature is not enabled for account' do
context 'when advanced search feature is not enabled for account on chatwoot cloud' do
before do
account.disable_features('advanced_search')
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
account.disable_features('advanced_search_indexing')
end
it 'returns false' do
@@ -644,6 +645,17 @@ RSpec.describe Message do
end
end
context 'when advanced search feature is not enabled for account on self-hosted' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
account.disable_features('advanced_search_indexing')
end
it 'returns true' do
expect(message.should_index?).to be true
end
end
context 'when message type is not incoming or outgoing' do
before do
message.message_type = 'activity'