diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 5ce211b7..b62b5acf 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -72,6 +72,9 @@ jobs: # E2E runs over plain HTTP, so the Secure session/csrf cookies must be off # or real login + writes would be rejected. LIBREDESK_APP__SERVER__DISABLE_SECURE_COOKIES: "true" + # The livechat widget suite makes far more than 100 widget requests a minute. + LIBREDESK_RATE_LIMIT__WIDGET__REQUESTS_PER_MINUTE: "100000" + LIBREDESK_RATE_LIMIT__PUBLIC__REQUESTS_PER_MINUTE: "100000" CYPRESS_SYSTEM_PASSWORD: "StrongPass!123" CYPRESS_MAILHOG_URL: "http://localhost:8025" run: | diff --git a/cmd/chat.go b/cmd/chat.go index 622d0539..215803fa 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -383,10 +383,10 @@ func handleAuthExchange(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "first_name"), nil, envelope.InputError) } if len(claims.LastName) > maxNameLength { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.maxLength", "max", strconv.Itoa(maxNameLength)), nil, envelope.InputError) + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldTooLong", "field", "{globals.terms.name}", "max", strconv.Itoa(maxNameLength)), nil, envelope.InputError) } if len(claims.PhoneNumber) > maxPhoneNumberLength { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.maxLength", "max", strconv.Itoa(maxPhoneNumberLength)), nil, envelope.InputError) + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldTooLong", "field", "{globals.terms.phoneNumber}", "max", strconv.Itoa(maxPhoneNumberLength)), nil, envelope.InputError) } // Country code is cosmetic - drop an invalid one instead of failing the whole exchange. if len(claims.PhoneNumberCountryCode) > maxPhoneCountryCodeLength { @@ -1175,7 +1175,7 @@ func validateFormData(app *App, formData map[string]any, config livechat.Config, return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.name}"), nil) } if len(finalName) > maxNameLength { - return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.maxLength", "max", strconv.Itoa(maxNameLength)), nil) + return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.fieldTooLong", "field", "{globals.terms.name}", "max", strconv.Itoa(maxNameLength)), nil) } case "email": @@ -1184,7 +1184,7 @@ func validateFormData(app *App, formData map[string]any, config livechat.Config, return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.email}"), nil) } if len(finalEmail) > maxEmailLength { - return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.maxLength", "max", strconv.Itoa(maxEmailLength)), nil) + return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.fieldTooLong", "field", "{globals.terms.email}", "max", strconv.Itoa(maxEmailLength)), nil) } if finalEmail != "" && !stringutil.ValidEmail(finalEmail) { return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidEmail"), nil) @@ -1197,10 +1197,10 @@ func validateFormData(app *App, formData map[string]any, config livechat.Config, return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.phoneNumber}"), nil) } if len(finalPhone) > maxPhoneNumberLength { - return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.maxLength", "max", strconv.Itoa(maxPhoneNumberLength)), nil) + return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.fieldTooLong", "field", "{globals.terms.phoneNumber}", "max", strconv.Itoa(maxPhoneNumberLength)), nil) } if len(finalPhoneCountryCode) > maxPhoneCountryCodeLength { - return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.maxLength", "max", strconv.Itoa(maxPhoneCountryCodeLength)), nil) + return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.fieldTooLong", "field", "{globals.terms.phoneNumber}", "max", strconv.Itoa(maxPhoneCountryCodeLength)), nil) } if finalPhone == "" { finalPhoneCountryCode = "" diff --git a/frontend/apps/widget/src/views/HomeView.vue b/frontend/apps/widget/src/views/HomeView.vue index f6fc268e..324d95f6 100644 --- a/frontend/apps/widget/src/views/HomeView.vue +++ b/frontend/apps/widget/src/views/HomeView.vue @@ -58,6 +58,8 @@ const mostRecentConversation = computed(() => { const canStartConversation = computed(() => { const userConfig = userStore.isVisitor ? config.value.visitors : config.value.users + // Mirrors the server check, else the button is offered and the send fails. + if (!userConfig?.allow_start_conversation) return false return userConfig?.prevent_multiple_conversations !== true || !chatStore.hasConversations }) diff --git a/frontend/cypress/e2e/integration/livechat/config.cy.js b/frontend/cypress/e2e/integration/livechat/config.cy.js new file mode 100644 index 00000000..1f4a5ea8 --- /dev/null +++ b/frontend/cypress/e2e/integration/livechat/config.cy.js @@ -0,0 +1,280 @@ +const startText = 'Cypress start chat' + +const withStart = (overrides = {}) => ({ + visitors: { allow_start_conversation: true, start_conversation_button_text: startText }, + users: { allow_start_conversation: true, start_conversation_button_text: startText }, + ...overrides +}) + +describe('Live chat widget config applied end to end', () => { + it('shows brand name, logo, greeting and introduction on the home screen', () => { + cy.createLivechatInbox( + withStart({ + brand_name: 'Cypress Brand', + logo_url: `${Cypress.config('baseUrl')}/static/public/launcher-logo.png`, + greeting_message: 'Hello from Cypress', + introduction_message: 'We reply fast' + }) + ).then((inbox) => { + cy.visitWidgetHost(inbox.uuid) + cy.widgetLauncher().click() + cy.widgetBody().contains('Hello from Cypress').should('be.visible') + cy.widgetBody().contains('We reply fast').should('be.visible') + cy.widgetBody().find('img[src*="launcher-logo.png"]').should('exist') + cy.widgetBody().contains(startText).click() + cy.widgetBody().contains('Cypress Brand').should('be.visible') + }) + }) + + it('applies the launcher color from config', () => { + cy.createLivechatInbox( + withStart({ colors: { primary: '#ff0000' }, launcher: { color: '#00ff00', position: 'right', spacing: { side: 20, bottom: 20 } } }) + ).then((inbox) => { + cy.visitWidgetHost(inbox.uuid) + cy.widgetLauncher().then((el) => { + const style = el[0].ownerDocument.defaultView.getComputedStyle(el[0]) + expect(style.backgroundColor, 'launcher color not applied').to.eq('rgb(0, 255, 0)') + }) + }) + }) + + it('shows the notice banner only when enabled', () => { + cy.createLivechatInbox( + withStart({ notice_banner: { enabled: true, text: 'Cypress notice text' } }) + ).then((inbox) => { + cy.openWidget(inbox) + cy.widgetBody().contains(startText).click() + cy.widgetBody().contains('Cypress notice text').should('be.visible') + }) + + cy.createLivechatInbox( + withStart({ notice_banner: { enabled: false, text: 'Hidden notice' } }) + ).then((inbox) => { + cy.openWidget(inbox) + cy.widgetBody().contains(startText).click() + cy.widgetBody().find('textarea').should('be.visible') + cy.widgetBody().contains('Hidden notice').should('not.exist') + }) + }) + + it('hides the powered-by link when show_powered_by is false', () => { + cy.createLivechatInbox(withStart({ show_powered_by: true })).then((inbox) => { + cy.openWidget(inbox) + cy.widgetBody().contains(startText).click() + cy.widgetBody().find('a[href="https://libredesk.io"]').should('exist') + }) + + cy.createLivechatInbox(withStart({ show_powered_by: false })).then((inbox) => { + cy.openWidget(inbox) + cy.widgetBody().contains(startText).click() + cy.widgetBody().find('textarea').should('be.visible') + cy.widgetBody().find('a[href="https://libredesk.io"]').should('not.exist') + }) + }) + + it('toggles the emoji and file upload actions from features', () => { + cy.createLivechatInbox(withStart({ features: { emoji: true, file_upload: true } })).then((inbox) => { + cy.openWidget(inbox) + cy.widgetBody().contains(startText).click() + cy.widgetBody().find('button[aria-label="Add emoji"]').should('exist') + // The attach button only renders once a conversation exists to upload against. + cy.widgetSend(`Message for upload ${Date.now()}`) + cy.widgetBody().find('button[aria-label="Attach file"]').should('exist') + }) + + cy.createLivechatInbox(withStart({ features: { emoji: false, file_upload: false } })).then((inbox) => { + cy.openWidget(inbox) + cy.widgetBody().contains(startText).click() + cy.widgetBody().find('textarea').should('be.visible') + cy.widgetBody().find('button[aria-label="Add emoji"]').should('not.exist') + cy.widgetBody().find('button[aria-label="Attach file"]').should('not.exist') + }) + }) + + it('renders configured home apps', () => { + cy.createLivechatInbox( + withStart({ + home_apps: [ + { type: 'announcement', title: 'Cypress announcement', description: 'Read this', url: '' }, + { type: 'external_link', text: 'Cypress docs link', url: 'https://example.test/docs' } + ] + }) + ).then((inbox) => { + cy.openWidget(inbox) + cy.widgetBody().contains('Cypress announcement').should('be.visible') + cy.widgetBody().contains('Cypress docs link').should('be.visible') + }) + }) + + it('opens straight into the chat when direct_to_conversation is set', () => { + cy.createLivechatInbox(withStart({ direct_to_conversation: true })).then((inbox) => { + cy.openWidget(inbox) + cy.widgetBody().find('textarea', { timeout: 20000 }).should('be.visible') + }) + }) + + it('hides the start button when visitors may not start conversations', () => { + let inbox + cy.createLivechatInbox( + withStart({ visitors: { allow_start_conversation: false, start_conversation_button_text: startText } }) + ).then((created) => { + inbox = created + }) + cy.then(() => cy.openWidget(inbox)) + cy.widgetBody().contains('Home').should('be.visible') + cy.widgetBody().should('not.contain', startText) + cy.then(() => + cy.conversationForInbox(inbox).then((conversation) => { + expect(conversation, 'a conversation was created anyway').to.be.null + }) + ) + }) + + it('replaces the start button with the running conversation when multiples are prevented', () => { + cy.createLivechatInbox( + withStart({ + visitors: { + allow_start_conversation: true, + prevent_multiple_conversations: true, + start_conversation_button_text: startText + } + }) + ).then((inbox) => { + const firstMessage = `First conversation ${Date.now()}` + cy.openWidget(inbox) + cy.widgetBody().contains(startText).click() + cy.widgetSend(firstMessage) + cy.widgetBody().find('button[aria-label="Go back"]').click() + cy.widgetBody().contains('Home').click() + cy.widgetBody().contains(firstMessage, { timeout: 20000 }).should('be.visible') + cy.widgetBody().should('not.contain', startText) + }) + }) + + it('blocks replies to a closed conversation when configured', () => { + let inbox + cy.createLivechatInbox( + withStart({ + visitors: { + allow_start_conversation: true, + prevent_reply_to_closed_conversation: true, + start_conversation_button_text: startText + } + }) + ).then((created) => { + inbox = created + }) + cy.then(() => cy.openWidget(inbox)) + cy.widgetBody().contains(startText).click() + cy.widgetSend(`Message before close ${Date.now()}`) + cy.then(() => cy.closeConversation(inbox)) + cy.widgetBody().find('textarea', { timeout: 20000 }).should('not.exist') + }) + + it('applies dark mode', () => { + cy.createLivechatInbox(withStart({ dark_mode: true })).then((inbox) => { + cy.openWidget(inbox) + cy.widgetBody().find('.dark').should('exist') + }) + }) + + it('paints the home screen background from config', () => { + cy.createLivechatInbox( + withStart({ + home_screen: { + header_text_color: 'light', + background: { type: 'gradient', gradient_start: '#112233', gradient_end: '#445566' }, + fade_background: true + } + }) + ).then((inbox) => { + cy.openWidget(inbox) + cy.widgetBody().contains(startText) + cy.widgetBody().then((body) => { + const hasGradient = Array.from(body[0].querySelectorAll('div')).some((el) => + (el.getAttribute('style') || '').includes('gradient') + ) + expect(hasGradient, 'gradient background not applied').to.be.true + }) + }) + }) + + it('shows the reply expectation message once business hours are configured', () => { + let inbox + cy.login() + cy.api('POST', '/api/v1/business-hours', { + name: `Cypress always open ${Date.now()}`, + is_always_open: true, + hours: {}, + holidays: [] + }) + .its('body.data.id') + .then((id) => cy.setDefaultBusinessHours(String(id))) + + cy.createLivechatInbox( + withStart({ + chat_reply_expectation_message: 'We usually reply in 5 minutes', + show_office_hours_in_chat: true + }) + ).then((created) => { + inbox = created + }) + cy.then(() => cy.openWidget(inbox)) + cy.widgetBody().contains(startText).click() + // The message only renders against a live conversation. + cy.then(() => cy.widgetSend(`Expectation probe ${Date.now()}`)) + cy.widgetBody().contains('We usually reply in 5 minutes', { timeout: 20000 }).should('be.visible') + cy.then(() => cy.setDefaultBusinessHours('')) + }) + + it('renders the widget in the configured language', () => { + cy.createLivechatInbox({ + language: 'de-DE', + fallback_language: 'en-US', + visitors: { allow_start_conversation: true }, + users: { allow_start_conversation: true } + }).then((inbox) => { + cy.openWidget(inbox) + cy.widgetBody().contains('Senden Sie uns eine Nachricht', { timeout: 20000 }).should('be.visible') + }) + }) + + it('collects pre-chat form fields and attaches them to the contact', () => { + const stamp = Date.now() + const email = `prechat.${stamp}@cypress.test` + let inbox + + cy.createLivechatInbox( + withStart({ + prechat_form: { + enabled: true, + title: 'Tell us about you', + fields: [ + { key: 'name', type: 'text', label: 'Name', required: true, enabled: true, order: 1, is_default: true }, + { key: 'email', type: 'email', label: 'Email', required: true, enabled: true, order: 2, is_default: true } + ] + } + }) + ).then((created) => { + inbox = created + }) + + cy.then(() => cy.openWidget(inbox)) + cy.widgetBody().contains(startText).click() + cy.widgetBody().contains('Tell us about you').should('be.visible') + + cy.widgetBody().contains('button', 'Start chat').should('be.disabled') + cy.widgetBody().find('input').eq(0).type('Cypress Prechat') + cy.widgetBody().find('input').eq(1).type(email) + cy.widgetBody().find('textarea').should('be.visible').type(`Prechat message ${stamp}`) + cy.widgetBody().contains('button', 'Start chat').click() + cy.widgetBody().contains(`Prechat message ${stamp}`, { timeout: 20000 }).should('be.visible') + + cy.then(() => + cy.latestConversation(inbox).then((conversation) => { + expect(conversation.contact.email, 'pre-chat email not saved on the contact').to.eq(email) + expect(JSON.stringify(conversation.contact)).to.include('Cypress Prechat') + }) + ) + }) +}) diff --git a/frontend/cypress/e2e/integration/livechat/embed.cy.js b/frontend/cypress/e2e/integration/livechat/embed.cy.js new file mode 100644 index 00000000..267c2b28 --- /dev/null +++ b/frontend/cypress/e2e/integration/livechat/embed.cy.js @@ -0,0 +1,154 @@ +const startButtonText = 'Cypress start chat' + +const embedConfig = (overrides = {}) => ({ + visitors: { allow_start_conversation: true, start_conversation_button_text: startButtonText }, + users: { allow_start_conversation: true, start_conversation_button_text: startButtonText }, + ...overrides +}) + +describe('Live chat widget embedded on a host page', () => { + it('renders the launcher and keeps the panel closed until clicked', () => { + cy.createLivechatInbox(embedConfig()).then((inbox) => { + cy.visitWidgetHost(inbox.uuid) + cy.widgetLauncher().should('be.visible') + cy.get('iframe[src*="/widget?inbox_id="]').should('not.be.visible') + + cy.widgetLauncher().click() + cy.get('iframe[src*="/widget?inbox_id="]').should('be.visible') + cy.widgetBody().contains(startButtonText).should('be.visible') + }) + }) + + it('moves the launcher when the inbox launcher position changes', () => { + cy.createLivechatInbox( + embedConfig({ launcher: { position: 'right', spacing: { side: 20, bottom: 20 } } }) + ).then((inbox) => { + cy.visitWidgetHost(inbox.uuid) + cy.widgetWrapperSide().then((side) => { + expect(side.right, 'launcher not on the right').to.eq('20px') + expect(side.left).to.eq('auto') + }) + + cy.then(() => + cy.saveLivechatInbox(inbox, { launcher: { position: 'left', spacing: { side: 40, bottom: 60 } } }) + ) + cy.then(() => cy.visitWidgetHost(inbox.uuid)) + cy.widgetWrapperSide().then((side) => { + expect(side.left, 'launcher did not move to the left').to.eq('40px') + expect(side.right).to.eq('auto') + expect(side.bottom).to.eq('60px') + }) + }) + }) + + it('starts a conversation as a visitor and shows the agent reply', () => { + const visitorMessage = `Visitor from the widget ${Date.now()}` + const agentMessage = `Agent answer ${Date.now()}` + let inbox + + cy.createLivechatInbox(embedConfig()).then((created) => { + inbox = created + }) + cy.then(() => cy.visitWidgetHost(inbox.uuid)) + cy.widgetLauncher().click() + + cy.widgetBody().contains(startButtonText).click() + cy.widgetBody().find('textarea').should('be.visible').type(visitorMessage) + cy.widgetBody().find('button[aria-label="Send"]').click() + cy.widgetBody().contains(visitorMessage, { timeout: 20000 }).should('be.visible') + + cy.then(() => cy.agentReplyToLatestConversation(inbox, agentMessage)) + cy.widgetBody().contains(agentMessage, { timeout: 20000 }).should('be.visible') + }) + + it('keeps delivering agent replies in the widget after the inbox is saved', () => { + const visitorMessage = `Visitor before save ${Date.now()}` + const beforeSave = `Agent before save ${Date.now()}` + const afterSave = `Agent after save ${Date.now()}` + let inbox + + cy.createLivechatInbox(embedConfig()).then((created) => { + inbox = created + }) + cy.then(() => cy.visitWidgetHost(inbox.uuid)) + cy.widgetLauncher().click() + cy.widgetBody().contains(startButtonText).click() + cy.widgetBody().find('textarea').should('be.visible').type(visitorMessage) + cy.widgetBody().find('button[aria-label="Send"]').click() + cy.widgetBody().contains(visitorMessage, { timeout: 20000 }).should('be.visible') + + cy.then(() => cy.agentReplyToLatestConversation(inbox, beforeSave)) + cy.widgetBody().contains(beforeSave, { timeout: 20000 }).should('be.visible') + + cy.then(() => cy.saveLivechatInbox(inbox, { brand_name: 'Cypress saved' })) + + cy.then(() => cy.agentReplyToLatestConversation(inbox, afterSave)) + cy.widgetBody().contains(afterSave, { timeout: 30000 }).should('be.visible') + }) + + it('identifies the contact from a signed JWT', () => { + const stamp = Date.now() + const email = `jwt.visitor.${stamp}@cypress.test` + const secret = `cypress-secret-${stamp}` + const visitorMessage = `JWT visitor message ${stamp}` + let inbox + + cy.createLivechatInbox(embedConfig(), { secret }).then((created) => { + inbox = created + }) + + cy.then(() => { + cy.intercept('POST', '/api/v1/widget/chat/auth/exchange').as('exchange') + return cy.visitWidgetHost(inbox.uuid, { + secret, + jwtPayload: { + external_user_id: `cypress_${stamp}`, + email, + first_name: 'Cypress', + last_name: 'Visitor' + } + }) + }) + cy.wait('@exchange').its('response.statusCode').should('eq', 200) + + cy.widgetLauncher().click() + cy.widgetBody().contains(startButtonText).click() + cy.widgetBody().find('textarea').should('be.visible').type(visitorMessage) + cy.widgetBody().find('button[aria-label="Send"]').click() + cy.widgetBody().contains(visitorMessage, { timeout: 20000 }).should('be.visible') + + cy.then(() => + cy.latestConversation(inbox).then((conversation) => { + expect(JSON.stringify(conversation), 'conversation not attached to the JWT contact').to.include(email) + }) + ) + }) + + it('enforces a required pre-chat field before the chat opens', () => { + cy.createLivechatInbox( + embedConfig({ + prechat_form: { + enabled: true, + title: 'Before we start', + fields: [ + { + key: 'name', + type: 'text', + label: 'Name', + required: true, + enabled: true, + order: 1, + is_default: true + } + ] + } + }) + ).then((inbox) => { + cy.visitWidgetHost(inbox.uuid) + cy.widgetLauncher().click() + cy.widgetBody().contains(startButtonText).click() + cy.widgetBody().contains('Before we start').should('be.visible') + cy.widgetBody().find('input').should('exist') + }) + }) +}) diff --git a/frontend/cypress/e2e/integration/livechat/session.cy.js b/frontend/cypress/e2e/integration/livechat/session.cy.js index 602f43c5..600c203a 100644 --- a/frontend/cypress/e2e/integration/livechat/session.cy.js +++ b/frontend/cypress/e2e/integration/livechat/session.cy.js @@ -129,4 +129,53 @@ describe('Live chat widget session and auth', () => { }) cy.then(() => cy.waitForFrame(joined(socket), 'session did not survive a reload')) }) + + it('merges an anonymous visitor into the JWT contact', () => { + const stamp = Date.now() + const email = `merge.${stamp}@cypress.test` + const secret = `cypress-secret-${stamp}` + let jwtInbox + let visitorToken + let contactToken + + cy.createLivechatInbox({}, { secret }).then((created) => { + jwtInbox = created + }) + cy.then(() => cy.widgetInit(jwtInbox.uuid, { message: `Anonymous message ${stamp}` })).then((res) => { + visitorToken = res.sessionToken + }) + + cy.then(() => + cy + .signWidgetJWT( + { external_user_id: `merge_${stamp}`, email, first_name: 'Merged', last_name: 'Contact' }, + secret + ) + .then((jwt) => + cy + .widgetApi('POST', '/api/v1/widget/chat/auth/exchange', null, jwtInbox.uuid, { jwt }) + .then(({ status, body }) => { + expect(status).to.eq(200) + contactToken = body.data.session_token + }) + ) + ) + + cy.then(() => + cy + .widgetApi('GET', '/api/v1/widget/chat/conversations', contactToken, jwtInbox.uuid, null, { + headers: { 'X-Libredesk-Visitor-Token': visitorToken } + }) + .then((res) => { + expect(res.status).to.eq(200) + expect(res.headers['x-libredesk-clear-visitor'], 'visitor token not cleared after merge').to.eq('true') + }) + ) + + cy.then(() => + cy.latestConversation(jwtInbox).then((conversation) => { + expect(conversation.contact.email, 'conversation not moved to the JWT contact').to.eq(email) + }) + ) + }) }) diff --git a/frontend/cypress/e2e/integration/livechat/settings.cy.js b/frontend/cypress/e2e/integration/livechat/settings.cy.js index dff2baae..8d65e1d1 100644 --- a/frontend/cypress/e2e/integration/livechat/settings.cy.js +++ b/frontend/cypress/e2e/integration/livechat/settings.cy.js @@ -126,4 +126,22 @@ describe('Live chat widget settings and init rules', () => { .should('eq', 400) }) }) + + it('names the field when a pre-chat value is too long', () => { + cy.createLivechatInbox({ + prechat_form: { + enabled: true, + fields: [ + { key: 'name', type: 'text', label: 'Name', required: true, enabled: true, order: 1, is_default: true } + ] + } + }).then((inbox) => { + cy.widgetInit(inbox.uuid, { form_data: { name: 'x'.repeat(300) } }, { failOnStatusCode: false }).then( + (res) => { + expect(res.status).to.eq(400) + expect(res.body.message, 'error does not name the field').to.match(/name/i) + } + ) + }) + }) }) diff --git a/frontend/cypress/support/livechat.js b/frontend/cypress/support/livechat.js index 8d26a03c..f210d0c4 100644 --- a/frontend/cypress/support/livechat.js +++ b/frontend/cypress/support/livechat.js @@ -152,3 +152,185 @@ after(() => { cy.api('DELETE', `/api/v1/inboxes/${id}`, null, { failOnStatusCode: false }) }) }) + +const embedHostPath = '/__widget-embed-test' + +// Served from the app's own origin, else the iframe is cross-origin and its DOM is unreachable. +Cypress.Commands.add('visitWidgetHost', (inboxUuid, { secret = null, jwtPayload = null } = {}) => { + const html = ` +widget embed host + +

Customer site

+ + +` + cy.intercept('GET', `${embedHostPath}*`, { + statusCode: 200, + headers: { 'content-type': 'text/html; charset=utf-8' }, + body: html + }).as('embedHost') + cy.visit(embedHostPath) + return cy.window({ timeout: 20000 }).its('Libredesk.toggleButton', { timeout: 20000 }) +}) + +Cypress.Commands.add('widgetLauncher', () => + cy.window().its('Libredesk.toggleButton').then((el) => cy.wrap(el, { log: false })) +) + +Cypress.Commands.add('widgetWrapperSide', () => + cy.window().its('Libredesk.widgetButtonWrapper').then((el) => { + const s = el.ownerDocument.defaultView.getComputedStyle(el) + return { left: s.left, right: s.right, bottom: s.bottom } + }) +) + +Cypress.Commands.add('widgetBody', () => + cy + .get('iframe[src*="/widget?inbox_id="]', { timeout: 20000 }) + .its('0.contentDocument.body', { timeout: 20000 }) + .should('not.be.empty') + .then((body) => cy.wrap(body, { log: false })) +) + +// No cy.login() here: cy.session() blanks the page, which would tear down an embedded widget mid-test. +Cypress.Commands.add('latestConversation', (inbox) => { + return cy + .api( + 'GET', + '/api/v1/conversations/all?order=desc&order_by=conversations.created_at&page=1&page_size=50' + ) + .then(({ body }) => { + const match = body.data.results.find((c) => c.inbox_name === inbox.payload.name) + expect(match, `no conversation found for inbox ${inbox.payload.name}`).to.exist + return match + }) +}) + +Cypress.Commands.add('agentReplyToLatestConversation', (inbox, body) => + cy.latestConversation(inbox).then((conversation) => + cy + .api('POST', `/api/v1/conversations/${conversation.uuid}/messages`, { + message: `

${body}

`, + private: false, + sender_type: 'agent' + }) + .its('status') + .should('eq', 200) + ) +) + +Cypress.Commands.add('openWidget', (inbox, opts = {}) => { + cy.visitWidgetHost(inbox.uuid, opts) + cy.widgetLauncher().click() + return cy.widgetBody() +}) + +Cypress.Commands.add('widgetSend', (text) => { + cy.widgetBody().find('textarea').should('be.visible').type(text) + cy.widgetBody().find('button[aria-label="Send"]').click() + return cy.widgetBody().contains(text, { timeout: 20000 }).should('be.visible') +}) + +Cypress.Commands.add('closeConversation', (inbox) => + cy.latestConversation(inbox).then((conversation) => + cy + .api('PUT', `/api/v1/conversations/${conversation.uuid}/status`, { status: 'Closed' }) + .its('status') + .should('eq', 200) + ) +) + +Cypress.Commands.add('conversationForInbox', (inbox) => + cy + .api('GET', '/api/v1/conversations/all?order=desc&order_by=conversations.created_at&page=1&page_size=50') + .then(({ body }) => body.data.results.find((c) => c.inbox_name === inbox.payload.name) || null) +) + +// Retries because the visitor-to-contact merge lands on a later widget request, not on the exchange itself. +Cypress.Commands.add('waitForConversationContact', (inbox, email, timeout = 20000) => + cy.wrap(null, { timeout, log: false }).should(() => { + const found = Cypress.$.ajax({ + url: '/api/v1/conversations/all?order=desc&order_by=conversations.created_at&page=1&page_size=50', + async: false + }) + const results = JSON.parse(found.responseText).data.results + const match = results.find((c) => c.inbox_name === inbox.payload.name) + expect(match && match.contact && match.contact.email, 'contact email on the conversation').to.eq(email) + }) +) + +Cypress.Commands.add('setDefaultBusinessHours', (businessHoursId) => + cy.api('GET', '/api/v1/settings/general').then(({ body }) => { + const settings = { ...body.data, 'app.business_hours_id': businessHoursId } + delete settings['app.version'] + delete settings['app.update'] + delete settings['app.restart_required'] + return cy.api('PUT', '/api/v1/settings/general', settings).its('status').should('eq', 200) + }) +) + +// The widget renders a message optimistically, so the session cookie can lag behind it. +Cypress.Commands.add('waitForWidgetCookie', (inbox, type = 'session', timeout = 20000) => + cy.wrap(null, { timeout, log: false }).should(() => { + const doc = cy.state('window').document + expect(doc.cookie, `libredesk-${type} cookie`).to.include(`libredesk-${type}-${inbox.uuid}=`) + }) +) + +const b64url = (bytes) => { + let s = '' + bytes.forEach((b) => { + s += String.fromCharCode(b) + }) + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +Cypress.Commands.add('signWidgetJWT', (payload, secret) => { + const enc = new TextEncoder() + const body = { exp: Math.floor(Date.now() / 1000) + 3600, ...payload } + const header = b64url(enc.encode(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))) + const claims = b64url(enc.encode(JSON.stringify(body))) + return cy.wrap( + window.crypto.subtle + .importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) + .then((key) => window.crypto.subtle.sign('HMAC', key, enc.encode(`${header}.${claims}`))) + .then((sig) => `${header}.${claims}.${b64url(new Uint8Array(sig))}`), + { log: false } + ) +})