diff --git a/e2e-tests/chatScripts.ts b/e2e-tests/chatScripts.ts index d069bff2c5..4b5cff13e3 100644 --- a/e2e-tests/chatScripts.ts +++ b/e2e-tests/chatScripts.ts @@ -80,3 +80,65 @@ export const getWebchatScript = (): ChatStatement[] => { return defaultScript; }; + +/** + * SMS scripts are structurally the same as webchat scripts (using the same ChatStatement format), + * but must start with a CALLER statement since the client must initiate an SMS conversation. + * BOT messages are expected to arrive after the client sends its first message. + */ +export const defaultSmsScript: ChatStatement[] = [ + callerStatement('hi'), + botStatement('Welcome to the helpline. Please answer the following questions.'), + callerStatement('yes'), + botStatement('How old are you?'), + callerStatement('10'), + botStatement('What is your gender?'), + callerStatement('girl'), + botStatement('We will transfer you now. Please hold for a counsellor.'), + counselorAutoStatement('Hi, this is the counsellor. How can I help you?'), + callerStatement('CALLER TEST SMS MESSAGE'), + counselorStatement('COUNSELLOR TEST SMS MESSAGE'), +]; + +export const smsCommonScripts: Record = { + ca: [ + callerStatement('CALLER TEST SMS MESSAGE'), + counselorAutoStatement("Hi, you've reached a counsellor. What would you like to talk about?"), + counselorStatement('COUNSELLOR TEST SMS MESSAGE'), + ], +}; + +export const smsEnvScripts: Record> = { + development: { + as: [ + callerStatement('hi'), + botStatement("Sorry, I didn't understand that. Please try again."), + callerStatement('hi'), + botStatement('Are you calling about yourself? Please answer Yes or No.'), + callerStatement('yes'), + botStatement('How old are you?'), + callerStatement('10'), + botStatement('What is your gender?'), + callerStatement('girl'), + botStatement("We'll transfer you now. Please hold for a counsellor."), + counselorAutoStatement('Hi, this is the counsellor. How can I help you?'), + callerStatement('CALLER TEST SMS MESSAGE'), + counselorStatement('COUNSELLOR TEST SMS MESSAGE'), + ], + }, +}; + +export const getSmsScript = (): ChatStatement[] => { + const helplineShortCode = getConfigValue('helplineShortCode') as string; + const helplineEnv = getConfigValue('helplineEnv') as string; + + if (smsEnvScripts[helplineEnv]?.[helplineShortCode]) { + return smsEnvScripts[helplineEnv][helplineShortCode]; + } + + if (smsCommonScripts[helplineShortCode]) { + return smsCommonScripts[helplineShortCode]; + } + + return defaultSmsScript; +}; diff --git a/e2e-tests/config.ts b/e2e-tests/config.ts index 6831eb2cee..26cef92eb1 100644 --- a/e2e-tests/config.ts +++ b/e2e-tests/config.ts @@ -41,7 +41,10 @@ export type Config = { }; const helplineShortCode = process.env.HL?.toLocaleLowerCase() || 'e2e'; +// Account to initiate calls into the helpline under test from, for testing voice & SMS const helplineEnv = process.env.HL_ENV?.toLocaleLowerCase() || 'local'; +const clientHelplineShortCode = process.env.CLIENT_HL?.toLocaleLowerCase() || 'as'; +const clientHelplineEnv = process.env.CLIENT_HL_ENV?.toLocaleLowerCase() || 'development'; const shouldLoadFromSsm = process.env.LOAD_SSM_CONFIG && process.env.LOAD_SSM_CONFIG !== 'false'; // These are environments where we want to avoid tests or steps that update HRM data @@ -135,6 +138,18 @@ const configOptions: ConfigOptions = { ssmPath: () => `/${localOverrideEnv}/twilio/${getConfigValue('twilioAccountSid')}/auth_token`, }, + // The twilio account sid and auth token are used to target a flex account + clientTwilioAccountSid: { + envKey: 'CLIENT_TWILIO_ACCOUNT_SID', + ssmPath: `/${clientHelplineEnv}/twilio/${clientHelplineShortCode.toUpperCase()}/account_sid`, + }, + clientTwilioAuthToken: { + envKey: 'CLIENT_TWILIO_AUTH_TOKEN', + // Order is important here. We use a function so that we can reference the clientTwilioAccountSid config value above. + ssmPath: () => + `/${clientHelplineEnv}/twilio/${getConfigValue('clientTwilioAccountSid')}/auth_token`, + }, + // Turn on debug mode. Possibly unused. debug: { envKey: 'DEBUG', @@ -180,6 +195,30 @@ const configOptions: ConfigOptions = { default: `https://assets-${localOverrideEnv}.tl.techmatters.org/aselo-webchat-react-app/${helplineShortCode}/?e2eTestMode=true`, }, + // This should match the number set up for the Voice studio flow on the helpline under test + voicePhoneNumber: { + envKey: 'VOICE_PHONE_NUMBER', + default: '+12607821891', + }, + + // This should match the number set up on the clientTwilioAccountSid that can make outgoing calls + clientVoicePhoneNumber: { + envKey: 'CLIENT_VOICE_PHONE_NUMBER', + default: '+12064083885', + }, + + // This should match the number set up for the SMS studio flow on the helpline under test + smsPhoneNumber: { + envKey: 'SMS_PHONE_NUMBER', + default: () => getConfigValue('voicePhoneNumber'), + }, + + // This should match the number set up on the clientTwilioAccountSid that can send outgoing SMS messages + clientSmsPhoneNumber: { + envKey: 'CLIENT_SMS_PHONE_NUMBER', + default: () => getConfigValue('clientVoicePhoneNumber'), + }, + // inLambda is used to determine if we are running in a lambda or not and set other config values accordingly inLambda: { envKey: 'TEST_IN_LAMBDA', @@ -256,7 +295,7 @@ const setConfigValueFromSsm = async (key: string) => { throw err; } - console.log(`Failed to load config value from SSM at ${option.ssmPath}. Using default value`); + console.warn(`Failed to load config value from SSM at ${option.ssmPath}. Using default value`); setConfigValue(key, typeof option.default === 'function' ? option.default() : option.default); } diff --git a/e2e-tests/contactForm.ts b/e2e-tests/contactForm.ts index e7e171575b..7e9aecf959 100644 --- a/e2e-tests/contactForm.ts +++ b/e2e-tests/contactForm.ts @@ -85,7 +85,7 @@ export function contactForm(page: Page) { } } - return { + const formApi = { selectChildCallType: async () => { const childCallTypeButton = selectors.childCallTypeButton(); const responsePromise = page.waitForResponse('**/contacts/**'); @@ -100,6 +100,28 @@ export function contactForm(page: Page) { await tab.fill(tab); } }, + fillWithContent: async (formContent: any) => { + await formApi.fill([ + { + id: 'childInformation', + label: 'TabbedForms-AddChildInfoTab', + fill: formApi.fillStandardTab, + items: formContent.childInformation, + }, + >{ + id: 'categories', + label: 'TabbedForms-CategoriesTab', + fill: formApi.fillCategoriesTab, + items: formContent.categories, + }, + { + id: 'caseInformation', + label: 'TabbedForms-AddCaseInfoTab', + fill: formApi.fillStandardTab, + items: formContent.caseInformation, + }, + ]); + }, save: async ({ saveAndAddToCase }: { saveAndAddToCase?: boolean } = {}) => { const tab = { id: 'caseInformation', @@ -124,5 +146,6 @@ export function contactForm(page: Page) { }, fillCategoriesTab, fillStandardTab, - }; + } as const; + return formApi; } diff --git a/e2e-tests/deleteChatChannels.ts b/e2e-tests/deleteConversations.ts similarity index 88% rename from e2e-tests/deleteChatChannels.ts rename to e2e-tests/deleteConversations.ts index 3c15d958c0..59b33ed447 100644 --- a/e2e-tests/deleteChatChannels.ts +++ b/e2e-tests/deleteConversations.ts @@ -23,12 +23,13 @@ * send new messages from the e2e test user. */ -import { deleteChatChannels } from './twilio/channels'; +import { deleteChatConversations, deleteSmsConversations } from './twilio/channels'; import { initConfig } from './config'; const main = async () => { await initConfig(); - await deleteChatChannels(); + await deleteChatConversations(); + await deleteSmsConversations(); }; main(); diff --git a/e2e-tests/formContentsByHelpline.ts b/e2e-tests/formContentsByHelpline.ts index b1d0caf6a3..3fbb37a484 100644 --- a/e2e-tests/formContentsByHelpline.ts +++ b/e2e-tests/formContentsByHelpline.ts @@ -27,7 +27,7 @@ export const formContentsByHelpline = { Accessibility: ['Education'], }, caseInformation: { - callSummary: 'E2E TEST CALL', + callSummary: 'E2E TEST PREPOPULATED FORM', }, }, ca: { @@ -54,3 +54,19 @@ export const formContentsByHelpline = { }, }, }; + +export const formContentsByHelplineForEmptyForm = { + ...formContentsByHelpline, + e2e: { + ...formContentsByHelpline.e2e, + childInformation: { + ...formContentsByHelpline.e2e.childInformation, + + gender: 'Unknown', + age: 'Unknown', + }, + caseInformation: { + callSummary: 'E2E TEST EMPTY FORM', + }, + }, +}; diff --git a/e2e-tests/package.json b/e2e-tests/package.json index 14da237ed9..b55dbf52be 100644 --- a/e2e-tests/package.json +++ b/e2e-tests/package.json @@ -5,19 +5,19 @@ "main": "index.js", "scripts": { "postinstall": "npx playwright install chromium", - "deleteChatChannels": "tsx deleteChatChannels.ts", + "deleteChatChannels": "tsx deleteConversations.ts", "test": "npx playwright test --workers 1 ", "test:ui": "npx playwright test --workers 1 --config ui-tests/playwright.ui-test.config.ts", - "test:local": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test", + "test:local": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --retries 0", "test:local-aselo-webchat": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true ASELO_WEBCHAT_URL=http://localhost:3001 npm run test -- --headed --debug --retries 0 aseloWebchat", - "test:local:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --headed --debug --retries 0 offline", + "test:local:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true npm run test -- --headed --debug --retries 0", "test:development:as": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=as SKIP_DATA_UPDATE=true npm run test", "test:development:as:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=as SKIP_DATA_UPDATE=true npm run test -- --headed --retries 0", "test:development:e2e": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=e2e npm run test", "test:development:e2e:local": "cross-env LOAD_SSM_CONFIG=true HL_ENV=development HL=e2e npm run test -- --headed", - "test:development:e2e:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=e2e npm run test -- --headed --debug --retries 0 login", + "test:development:e2e:debug": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=development HL=e2e npm run test -- --headed --debug --retries 0", "test:staging:ca": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=staging HL=ca npm run test", - "test:staging:ca:headed": "cross-env LOAD_SSM_CONFIG=true HL_ENV=staging HL=ca npm test -- --headed --debug --retries 0 aseloWebchat", + "test:staging:ca:headed": "cross-env LOAD_SSM_CONFIG=true HL_ENV=staging HL=ca npm test -- --headed --debug --retries 0", "test:production:ca": "cross-env DEBUG=pw:api LOAD_SSM_CONFIG=true HL_ENV=production HL=ca npm run test", "lint": "eslint --ext ts .", "lint:fix": "npm run lint -- --fix .", diff --git a/e2e-tests/playwright.config.ts b/e2e-tests/playwright.config.ts index d4363de572..ddb8924a2b 100644 --- a/e2e-tests/playwright.config.ts +++ b/e2e-tests/playwright.config.ts @@ -21,6 +21,8 @@ import { getConfigValue } from './config'; const inLambda = getConfigValue('inLambda') as boolean; +const browserArgs = ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream']; + const playwrightConfig: PlaywrightTestConfig = { globalSetup: require.resolve('./global-setup'), use: { @@ -40,6 +42,7 @@ const playwrightConfig: PlaywrightTestConfig = { * of chromium/Playwright. We use the `TEST_NAME` environment variable to set * a unique target for each test that runs in lambdas to avoid this issue. */ + ...browserArgs, '--single-process', '--autoplay-policy=user-gesture-required', '--disable-background-networking', @@ -81,11 +84,11 @@ const playwrightConfig: PlaywrightTestConfig = { '--disable-gpu', '--use-gl=swiftshader', '--autoplay-policy=no-user-gesture-required', - '--use-fake-ui-for-media-stream', - '--use-fake-device-for-media-stream', ], } - : {}, + : { + args: browserArgs, + }, }, testDir: './tests', retries: inLambda ? 0 : 1, diff --git a/e2e-tests/tests/aseloWebchat.spec.ts b/e2e-tests/tests/aseloWebchat.spec.ts index ecda6bd8ca..78b2b6d400 100644 --- a/e2e-tests/tests/aseloWebchat.spec.ts +++ b/e2e-tests/tests/aseloWebchat.spec.ts @@ -23,7 +23,7 @@ import { getWebchatScript } from '../chatScripts'; import { flexChat } from '../flexChat'; import { skipTestIfNotTargeted } from '../skipTest'; import { tasks } from '../tasks'; -import { Categories, contactForm, ContactFormTab } from '../contactForm'; +import { contactForm } from '../contactForm'; import { deleteAllTasksInQueue } from '../twilio/tasks'; import { notificationBar } from '../notificationBar'; import { clickThroughTwilioPasteModals } from '../agent-desktop'; @@ -102,26 +102,7 @@ test.describe.serial('Aselo web chat caller', () => { throw new Error(`No form contents configured for helplineShortCode="${String(helpline)}"`); } const form = contactForm(pluginPage); - await form.fill([ - { - id: 'childInformation', - label: 'TabbedForms-AddChildInfoTab', - fill: form.fillStandardTab, - items: formContent.childInformation, - }, - >{ - id: 'categories', - label: 'TabbedForms-CategoriesTab', - fill: form.fillCategoriesTab, - items: formContent.categories, - }, - { - id: 'caseInformation', - label: 'TabbedForms-AddCaseInfoTab', - fill: form.fillStandardTab, - items: formContent.caseInformation, - }, - ]); + await form.fillWithContent(formContent); console.info('Saving form'); await form.save(); diff --git a/e2e-tests/tests/offlineContact.spec.ts b/e2e-tests/tests/offlineContact.spec.ts index 1af1d559ea..fd27dead94 100644 --- a/e2e-tests/tests/offlineContact.spec.ts +++ b/e2e-tests/tests/offlineContact.spec.ts @@ -15,7 +15,7 @@ */ import { expect, Page, request, test } from '@playwright/test'; -import { Categories, contactForm, ContactFormTab } from '../contactForm'; +import { contactForm, ContactFormTab } from '../contactForm'; import { caseHome } from '../case'; import { agentDesktop, navigateToAgentDesktop } from '../agent-desktop'; import { skipTestIfDataUpdateDisabled, skipTestIfNotTargeted } from '../skipTest'; @@ -23,6 +23,11 @@ import { notificationBar } from '../notificationBar'; import { closePage, setupContextAndPage } from '../browser'; import { apiHrmRequest } from '../hrm/hrmRequest'; import { clearOfflineTask } from '../hrm/clearOfflineTask'; +import { getConfigValue } from '../config'; +import { + formContentsByHelpline, + formContentsByHelplineForEmptyForm, +} from '../formContentsByHelpline'; test.describe.serial('Offline Contact (with Case)', () => { skipTestIfNotTargeted(); @@ -56,6 +61,8 @@ test.describe.serial('Offline Contact (with Case)', () => { await agentDesktopPage.addOfflineContact(); console.log('Starting filling form'); + const helpline = getConfigValue('helplineShortCode') as keyof typeof formContentsByHelpline; + const formContent = formContentsByHelplineForEmptyForm[helpline]; const form = contactForm(pluginPage); await form.selectChildCallType(); @@ -70,38 +77,8 @@ test.describe.serial('Offline Contact (with Case)', () => { helpline: 'Childline', }, }, - { - id: 'childInformation', - label: 'TabbedForms-AddChildInfoTab', - fill: form.fillStandardTab, - items: { - firstName: 'E2E', - lastName: 'OFFLINE CONTACT', - gender: 'Unknown', - age: 'Unknown', - phone1: '1234512345', - province: 'Northern', - district: 'District A', - }, - }, - >{ - id: 'categories', - label: 'TabbedForms-CategoriesTab', - fill: form.fillCategoriesTab, - items: { - Accessibility: ['Education'], - }, - }, - { - id: 'caseInformation', - label: 'TabbedForms-AddCaseInfoTab', - fill: form.fillStandardTab, - items: { - callSummary: 'E2E OFFLINE CONTACT', - }, - }, ]); - + await form.fillWithContent(formContent); const beforeDate = new Date(); // Capture date here since we'll create case inmediately after saving contact // if (getConfigValue('skipDataUpdate') as boolean) { diff --git a/e2e-tests/tests/sms.spec.ts b/e2e-tests/tests/sms.spec.ts new file mode 100644 index 0000000000..510e017a4a --- /dev/null +++ b/e2e-tests/tests/sms.spec.ts @@ -0,0 +1,110 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { Page, request, test } from '@playwright/test'; +import { statusIndicator } from '../workerStatus'; +import { ChatStatement, ChatStatementOrigin } from '../chatModel'; +import { getSmsScript } from '../chatScripts'; +import { flexChat } from '../flexChat'; +import { skipTestIfNotTargeted } from '../skipTest'; +import { tasks } from '../tasks'; +import { contactForm } from '../contactForm'; +import { deleteAllTasksInQueue } from '../twilio/tasks'; +import { notificationBar } from '../notificationBar'; +import { clickThroughTwilioPasteModals } from '../agent-desktop'; +import { setupContextAndPage, closePage } from '../browser'; +import { clearOfflineTask } from '../hrm/clearOfflineTask'; +import { apiHrmRequest } from '../hrm/hrmRequest'; +import { formContentsByHelpline } from '../formContentsByHelpline'; +import { getConfigValue } from '../config'; +import { smsChat } from '../twilio/sms'; +import { deleteSmsConversations } from '../twilio/channels'; + +test.describe.serial('SMS caller', () => { + skipTestIfNotTargeted(); + + let pluginPage: Page; + + test.beforeAll(async ({ browser }) => { + test.setTimeout(180000); + await deleteSmsConversations(); + ({ page: pluginPage } = await setupContextAndPage(browser)); + + await clearOfflineTask( + apiHrmRequest(await request.newContext(), process.env.FLEX_TOKEN!), + process.env.LOGGED_IN_WORKER_SID!, + ); + console.info('SMS E2E test - plugin page launched.'); + + await clickThroughTwilioPasteModals(pluginPage); + console.info('Plugin page visited.'); + }); + + test.afterAll(async () => { + await statusIndicator(pluginPage)?.setStatus('OFFLINE'); + if (pluginPage) { + await notificationBar(pluginPage).dismissAllNotifications(); + } + await closePage(pluginPage); + await deleteAllTasksInQueue(); + }); + + test.afterEach(async () => { + await deleteAllTasksInQueue(); + }); + + test('Chat', async () => { + test.setTimeout(180000); + + const chatScript = getSmsScript(); + + // smsChat handles the client (caller) side via the Twilio Messages API. + // flexChat handles the counselor side via the Flex browser UI. + // Both iterate the same shared script, yielding control when they hit a + // statement the other side needs to handle — the same pattern used by the + // Aselo webchat test. + const smsChatProgress = smsChat(chatScript); + const flexChatProgress: AsyncIterator = flexChat(pluginPage).chat(chatScript); + + for await (const expectedCounselorStatement of smsChatProgress) { + console.info('Statement for flex chat to process', expectedCounselorStatement); + if (expectedCounselorStatement) { + switch (expectedCounselorStatement.origin) { + case ChatStatementOrigin.COUNSELOR_AUTO: + await statusIndicator(pluginPage).setStatus('AVAILABLE'); + await tasks(pluginPage).acceptNextTask(); + await flexChatProgress.next(); + break; + default: + await flexChatProgress.next(); + break; + } + } + } + + console.info('Starting filling form'); + const helpline = getConfigValue('helplineShortCode') as keyof typeof formContentsByHelpline; + const formContent = formContentsByHelpline[helpline]; + if (!formContent) { + throw new Error(`No form contents configured for helplineShortCode="${String(helpline)}"`); + } + const form = contactForm(pluginPage); + await form.fillWithContent(formContent); + + console.info('Saving form'); + await form.save(); + }); +}); diff --git a/e2e-tests/tests/voice.spec.ts b/e2e-tests/tests/voice.spec.ts new file mode 100644 index 0000000000..8e88a64e0d --- /dev/null +++ b/e2e-tests/tests/voice.spec.ts @@ -0,0 +1,87 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { Page, request, test } from '@playwright/test'; +import { statusIndicator } from '../workerStatus'; +import { skipTestIfNotTargeted } from '../skipTest'; +import { contactForm } from '../contactForm'; +import { deleteAllTasksInQueue } from '../twilio/tasks'; +import { notificationBar } from '../notificationBar'; +import { clickThroughTwilioPasteModals } from '../agent-desktop'; +import { setupContextAndPage, closePage } from '../browser'; +import { clearOfflineTask } from '../hrm/clearOfflineTask'; +import { apiHrmRequest } from '../hrm/hrmRequest'; +import { + formContentsByHelpline, + formContentsByHelplineForEmptyForm, +} from '../formContentsByHelpline'; +import { getConfigValue } from '../config'; +import { makeCallToService } from '../twilio/voice'; +import { tasks } from '../tasks'; + +test.describe.serial('Voice caller', () => { + skipTestIfNotTargeted(); + + let pluginPage: Page; + + test.beforeAll(async ({ browser }) => { + test.setTimeout(180000); + ({ page: pluginPage } = await setupContextAndPage(browser)); + + await clearOfflineTask( + apiHrmRequest(await request.newContext(), process.env.FLEX_TOKEN!), + process.env.LOGGED_IN_WORKER_SID!, + ); + console.info('Voice E2E test - plugin page launched.'); + + await clickThroughTwilioPasteModals(pluginPage); + console.info('Plugin page visited.'); + }); + + test.afterAll(async () => { + await statusIndicator(pluginPage)?.setStatus('OFFLINE'); + if (pluginPage) { + await notificationBar(pluginPage).dismissAllNotifications(); + } + await closePage(pluginPage); + await deleteAllTasksInQueue(); + }); + + test.afterEach(async () => { + await deleteAllTasksInQueue(); + }); + + test('Call', async () => { + test.setTimeout(180000); + await makeCallToService(); + await statusIndicator(pluginPage).setStatus('AVAILABLE'); + await tasks(pluginPage).acceptNextTask(); + + console.info('Starting filling form'); + const helpline = getConfigValue('helplineShortCode') as keyof typeof formContentsByHelpline; + const formContent = formContentsByHelplineForEmptyForm[helpline]; + if (!formContent) { + throw new Error(`No form contents configured for helplineShortCode="${String(helpline)}"`); + } + const form = contactForm(pluginPage); + + await form.selectChildCallType(); + await form.fillWithContent(formContent); + + console.info('Saving form'); + await form.save(); + }); +}); diff --git a/e2e-tests/twilio/channels.ts b/e2e-tests/twilio/channels.ts index 005290357b..da543ce7ab 100644 --- a/e2e-tests/twilio/channels.ts +++ b/e2e-tests/twilio/channels.ts @@ -30,7 +30,66 @@ const encodeEmailToUnicode = (email: string) => { .join(''); }; -export const deleteChatChannels = async (): Promise => { +const deleteSmsConversationFromOneEnd = async ( + accountSid: string, + authToken: string, + fromNumber: string, + toNumber: string, +) => { + const client = twilio(accountSid, authToken); + const activeConversations = await client.conversations.v1.conversations.list({ + state: 'active', + }); + console.info(`${activeConversations.length} active conversations found.`); + await Promise.all( + activeConversations.map(async (conversation) => { + const participants = await conversation.participants().list(); + + if ( + // eslint-disable-next-line @typescript-eslint/no-loop-func + participants.some((participant) => { + return ( + participant.messagingBinding?.address === fromNumber && + participant.messagingBinding?.proxy_address === toNumber + ); + }) + ) { + console.info( + `Found a participant with the from SMS number address (${fromNumber}) and to SMS number proxy address (${toNumber}), attempting to close conversation ${conversation.sid} from ${accountSid}`, + ); + await conversation.update({ state: 'closed' }); + } + }), + ); +}; + +export const deleteSmsConversations = async (): Promise => { + const serviceAccountSid = getConfigValue('twilioAccountSid') as string; + const serviceAuthToken = getConfigValue('twilioAuthToken') as string; + const serviceSmsNumber = getConfigValue('smsPhoneNumber') as string; + + const senderAccountSid = getConfigValue('clientTwilioAccountSid') as string; + const senderAuthToken = getConfigValue('clientTwilioAuthToken') as string; + const senderSmsNumber = getConfigValue('clientSmsPhoneNumber') as string; + + // Delete conversations from service Twilio account + await deleteSmsConversationFromOneEnd( + serviceAccountSid, + serviceAuthToken, + senderSmsNumber, + serviceSmsNumber, + ); + + // Delete conversations from sender Twilio account + await deleteSmsConversationFromOneEnd( + senderAccountSid, + senderAuthToken, + serviceSmsNumber, + senderSmsNumber, + ); +}; + +export const deleteChatConversations = async (): Promise => { const accountSid = getConfigValue('twilioAccountSid') as string; const authToken = getConfigValue('twilioAuthToken') as string; const email = getConfigValue('oktaUsername') as string; @@ -38,42 +97,36 @@ export const deleteChatChannels = async (): Promise => { const client = twilio(accountSid, authToken); - // List all chat services - const services = await client.chat.v2.services.list(); - - for (const service of services) { - // List all users in this chat service - const users = await client.chat.v2.services(service.sid).users.list(); - console.log(`Found ${users.length} users in service ${service.sid}`); - const matchingUser = users.find((user) => user.identity === encodedEmail); + // List all users in this chat service + const users = await client.conversations.v1.users.list(); + console.debug(`Found ${users.length} users in conversations`); + const matchingUser = users.find((user) => user.identity === encodedEmail); - if (!matchingUser) { - continue; - } + if (!matchingUser) { + return; + } - console.log(`Found user ${email} in service ${service.sid}`); + console.info(`Found user ${email} in conversations`); - // List all channels the matching user is a part of - const userChannels = await client.chat.v2 - .services(service.sid) - .users(matchingUser.sid) - .userChannels.list(); + // List all channels the matching user is a part of + const userConversations = await client.conversations.v1.users + .get(matchingUser.sid) + .userConversations.list(); - console.log( - `Found ${userChannels.length} chat channels for user ${email} in service ${service.sid}`, - ); + console.debug(`Found ${userConversations.length} chat channels for user ${email}`); - for (const userChannel of userChannels) { - console.log(`Removing chat channel ${userChannel.channelSid} from service ${service.sid}`); - await client.chat.v2.services(service.sid).channels(userChannel.channelSid).remove(); - } + for (const { conversationSid } of userConversations) { + console.debug(`Removing chat channel ${conversationSid}`); + await client.conversations.v1.conversations.get(conversationSid).remove(); } }; // Handle exit signals process.on('SIGINT', () => { - deleteChatChannels().catch((err) => console.error(err)); + deleteChatConversations().catch((err) => console.error(err)); + deleteSmsConversations().catch((err) => console.error(err)); }); process.on('SIGTERM', () => { - deleteChatChannels().catch((err) => console.error(err)); + deleteChatConversations().catch((err) => console.error(err)); + deleteSmsConversations().catch((err) => console.error(err)); }); diff --git a/e2e-tests/twilio/sms.ts b/e2e-tests/twilio/sms.ts new file mode 100644 index 0000000000..e4a01853e9 --- /dev/null +++ b/e2e-tests/twilio/sms.ts @@ -0,0 +1,129 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { getConfigValue } from '../config'; +// eslint-disable-next-line import/no-extraneous-dependencies +import twilio from 'twilio'; +import { AssertionError } from 'node:assert'; +import { ChatStatement, ChatStatementOrigin } from '../chatModel'; + +// Tracks the start of the current SMS test session so we only check messages received after this time +let sessionStartTime: Date | undefined; + +let clientConversationSid: string; + +export const sendSmsToService = async (messageText: string) => { + if (!sessionStartTime) { + sessionStartTime = new Date(); + } + const clientAccountSid = getConfigValue('clientTwilioAccountSid') as string; + const authToken = getConfigValue('clientTwilioAuthToken') as string; + const from = getConfigValue('clientSmsPhoneNumber') as string; + const serviceAccountSid = getConfigValue('twilioAccountSid') as string; + const to = getConfigValue('smsPhoneNumber') as string; + + const client = twilio(clientAccountSid, authToken); + if (!clientConversationSid) { + const clientConversation = await client.conversations.v1.conversations.create({ + friendlyName: `E2E test conversation with ${serviceAccountSid}, ${new Date().toISOString()}`, + }); + await clientConversation.participants().create({ + 'messagingBinding.address': to, + 'messagingBinding.proxyAddress': from, + 'messagingBinding.type': 'sms', + } as any); + clientConversationSid = clientConversation.sid; + } + await client.conversations.v1.conversations + .get(clientConversationSid) + .messages.create({ author: from, body: messageText }); + console.debug(`Sent SMS message to service: '${messageText}'`); +}; + +const MAX_CHECKS = 10; + +/** + * Checks whether the given message text was received by the SMS client (i.e., sent from the + * service to the client phone number) at any point since the current session started. + * Uses the service Twilio account to list outbound messages to the client number. + */ +export const checkForMessageOnClient = async (messageText: string): Promise => { + if (!clientConversationSid) { + throw new AssertionError({ + message: "You cannot verify incoming messages until you've sent one and started a session", + }); + } + const accountSid = getConfigValue('clientTwilioAccountSid') as string; + const authToken = getConfigValue('clientTwilioAuthToken') as string; + const to = getConfigValue('clientSmsPhoneNumber') as string; + + const client = twilio(accountSid, authToken); + const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + for (let i = 0; i < MAX_CHECKS; i++) { + const messages = await client.conversations.v1.conversations + .get(clientConversationSid) + .messages.list(); + //client.conversations.v1.roles.l + if (messages.find((m) => m.body === messageText && m.author !== to)) { + return true; + } + await delay(1000); + } + return false; +}; + +/** + * Asserts that the given message text was received by the SMS client within the polling window. + * Throws if the message is not found. + */ +const assertMessageReceivedOnClient = async (messageText: string): Promise => { + const received = await checkForMessageOnClient(messageText); + if (!received) { + throw new AssertionError({ + message: `SMS message not received on client: '${messageText}'`, + }); + } +}; + +/** + * Runs the 'client side' of an SMS conversation using the Twilio Messages API. + * It loops through a list of chat statements, sending caller SMS messages via the API and + * polling for expected bot/counselor messages on the client number. + * As soon as it hits a counselor statement (COUNSELOR or COUNSELOR_AUTO), it yields execution + * back to the calling code so it can action those statements in Flex. + * + * A similar function exists in flexChat.ts to handle the counselor side of the conversation. + * Both iterate the same shared ChatStatement list, yielding control when they hit a statement + * the other side needs to handle. + * @param statements - a unified list of all the chat statements in a conversation + */ +export async function* smsChat(statements: ChatStatement[]): AsyncGenerator { + for (const statementItem of statements) { + const { text, origin } = statementItem; + switch (origin) { + case ChatStatementOrigin.CALLER: + await sendSmsToService(text); + break; + case ChatStatementOrigin.BOT: { + await assertMessageReceivedOnClient(text); + break; + } + default: + yield statementItem; + await assertMessageReceivedOnClient(text); + } + } +} diff --git a/e2e-tests/twilio/voice.ts b/e2e-tests/twilio/voice.ts new file mode 100644 index 0000000000..ec016f6c85 --- /dev/null +++ b/e2e-tests/twilio/voice.ts @@ -0,0 +1,44 @@ +/** + * Copyright (C) 2021-2026 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { getConfigValue } from '../config'; +// eslint-disable-next-line import/no-extraneous-dependencies +import twilio from 'twilio'; +import VoiceResponse = twilio.twiml.VoiceResponse; + +// The callSid on the caller's side +// let callerCallSid: string; + +export const makeCallToService = async () => { + const clientAccountSid = getConfigValue('clientTwilioAccountSid') as string; + const authToken = getConfigValue('clientTwilioAuthToken') as string; + const from = getConfigValue('clientSmsPhoneNumber') as string; + // const serviceAccountSid = getConfigValue('twilioAccountSid') as string; + const to = getConfigValue('voicePhoneNumber') as string; + + const response = new VoiceResponse(); + response.say({ loop: 100 }, "Hello, I'm an end to end test"); + + const client = twilio(clientAccountSid, authToken); + //const call = + await client.calls.create({ + method: 'GET', + twiml: response, + from, + to, + }); + //callerCallSid = call.sid; +}; diff --git a/e2e-tests/twilio/worker.ts b/e2e-tests/twilio/worker.ts index f93f22931a..bef984e6dd 100644 --- a/e2e-tests/twilio/worker.ts +++ b/e2e-tests/twilio/worker.ts @@ -36,10 +36,3 @@ export const getSidForWorker = async (friendlyName: string): Promise => { - return page.evaluate(() => { - const manager = (window as any).Twilio.Flex.Manager.getInstance(); - return manager.workerClient.sid; - }); -}; diff --git a/e2e-tests/workerStatus.ts b/e2e-tests/workerStatus.ts index f04030f167..b18a3f62b9 100644 --- a/e2e-tests/workerStatus.ts +++ b/e2e-tests/workerStatus.ts @@ -15,7 +15,7 @@ */ // eslint-disable-next-line import/no-extraneous-dependencies -import { Locator, Page } from '@playwright/test'; +import { expect, Locator, Page } from '@playwright/test'; export const WORKER_STATUS = { AVAILABLE: ['Available', 'Ready'], @@ -45,12 +45,13 @@ export function statusIndicator(page: Page) { return { setStatus: async function (status: WorkerStatus) { await selectors.userActivityDropdownButton.click(); - console.log('Worker status dropdown should be open'); + console.debug('Worker status dropdown should be open'); await selectors.activityMenu.waitFor({ state: 'visible' }); const statusSelector = await getFirstMatchingStatus(page, WORKER_STATUS[status]); - console.log('Worker status option spotted'); + console.debug('Worker status option spotted'); await statusSelector.click(); - console.log('Worker status option clicked'); + console.debug('Worker status option clicked'); + await expect(statusSelector).toContainText(new RegExp(WORKER_STATUS[status].join('|'))); }, }; } diff --git a/lambdas/account-scoped/package.json b/lambdas/account-scoped/package.json index 83505e9f2d..be2e9930c5 100644 --- a/lambdas/account-scoped/package.json +++ b/lambdas/account-scoped/package.json @@ -28,6 +28,7 @@ "@aws-sdk/client-lex-runtime-v2": "^3.1045.0", "@aws-sdk/client-ssm": "^3.1045.0", "@tech-matters/configuration": "^1.0.0", + "@tech-matters/s3": "^1.0.0", "@tech-matters/hrm-form-definitions": "^1.0.0", "@tech-matters/hrm-types": "^1.0.0", "@tech-matters/ssm-cache": "^1.0.0", diff --git a/lambdas/account-scoped/src/configuration/getAseloTwilioConfiguration.ts b/lambdas/account-scoped/src/configuration/getAseloTwilioConfiguration.ts new file mode 100644 index 0000000000..d613e3b313 --- /dev/null +++ b/lambdas/account-scoped/src/configuration/getAseloTwilioConfiguration.ts @@ -0,0 +1,39 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import type { AccountSID } from '@tech-matters/twilio-types'; +import { getDocsBucketName } from '@tech-matters/twilio-configuration'; +import { getS3Object } from '@tech-matters/s3'; +import { newErr, newOk } from '../Result'; +import { AccountScopedHandler } from '../httpTypes'; + +const ASELO_TWILIO_CONFIGURATION_KEY = 'configuration/twilio.json'; + +export const getAseloTwilioConfigurationHandler: AccountScopedHandler = async ( + _event, + accountSid: AccountSID, +) => { + try { + const bucket = await getDocsBucketName(accountSid); + const content = await getS3Object(bucket, ASELO_TWILIO_CONFIGURATION_KEY); + return newOk(JSON.parse(content)); + } catch (err: any) { + if (err?.name === 'NoSuchKey') { + return newOk({}); + } + return newErr({ message: err.message, error: { statusCode: 500, cause: err } }); + } +}; diff --git a/lambdas/account-scoped/src/router.ts b/lambdas/account-scoped/src/router.ts index 31583afadc..ccf50c3f48 100644 --- a/lambdas/account-scoped/src/router.ts +++ b/lambdas/account-scoped/src/router.ts @@ -85,6 +85,7 @@ import { randomOptionSelectorHandler } from './randomOptionSelector'; import { isSkilledWorkerAvailableHandler } from './worker/isSkilledWorkerAvailable'; import { filterCountryOrVoIPHandler } from './voice/filterCountryOrVoIP'; import { recordingCompleteCallback } from './voicemail/recordingCompleteCallback'; +import { getAseloTwilioConfigurationHandler } from './configuration/getAseloTwilioConfiguration'; /** * Super simple router sufficient for directly ported Twilio Serverless functions @@ -414,6 +415,13 @@ const ACCOUNTSID_ROUTES: Record< requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: filterCountryOrVoIPHandler, }), + 'configuration/twilio': newRoute({ + requestPipeline: [ + validateRequestMethod('GET'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], + handler: getAseloTwilioConfigurationHandler, + }), }; const ENV_SHORTCODE_ROUTES: Record = { diff --git a/lambdas/account-scoped/src/validation/flexToken.ts b/lambdas/account-scoped/src/validation/flexToken.ts index dbc876bb96..e26464f654 100644 --- a/lambdas/account-scoped/src/validation/flexToken.ts +++ b/lambdas/account-scoped/src/validation/flexToken.ts @@ -46,9 +46,16 @@ export const validateFlexTokenRequest: ({ }) => HttpRequestPipelineStep = ({ tokenMode }: { tokenMode: 'supervisor' | 'agent' | 'guest' }) => async (request, { accountSid }) => { - const { Token: token } = request.body; + let token: string; + if (request.headers?.authorization?.startsWith('Bearer ')) { + token = request.headers?.authorization.slice('Bearer '.length); + } else { + token = request.body?.Token; + } if (!token) { - return newMissingParameterResult('Token'); + return newMissingParameterResult( + 'Bearer authorization header or Token body parameter', + ); } try { const tokenResult: TokenValidatorResponse = (await validator( diff --git a/lambdas/account-scoped/tests/unit/configuration/getAseloTwilioConfiguration.test.ts b/lambdas/account-scoped/tests/unit/configuration/getAseloTwilioConfiguration.test.ts new file mode 100644 index 0000000000..c420cb46eb --- /dev/null +++ b/lambdas/account-scoped/tests/unit/configuration/getAseloTwilioConfiguration.test.ts @@ -0,0 +1,119 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { getAseloTwilioConfigurationHandler } from '../../../src/configuration/getAseloTwilioConfiguration'; +import { getDocsBucketName } from '@tech-matters/twilio-configuration'; +import { getS3Object } from '@tech-matters/s3'; +import { isErr, isOk } from '../../../src/Result'; +import { HttpRequest } from '../../../src/httpTypes'; +import { TEST_ACCOUNT_SID } from '../../testTwilioValues'; + +jest.mock('@tech-matters/twilio-configuration', () => ({ + getDocsBucketName: jest.fn(), +})); + +jest.mock('@tech-matters/s3', () => ({ + getS3Object: jest.fn(), +})); + +const mockGetDocsBucketName = getDocsBucketName as jest.MockedFunction< + typeof getDocsBucketName +>; +const mockGetS3Object = getS3Object as jest.MockedFunction; + +const TEST_BUCKET = 'test-docs-bucket'; + +const createMockRequest = (): HttpRequest => ({ + method: 'GET', + headers: {}, + path: '/test', + query: {}, + body: {}, +}); + +describe('getAseloTwilioConfigurationHandler', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetDocsBucketName.mockResolvedValue(TEST_BUCKET); + }); + + it('should return parsed JSON content when configuration file exists', async () => { + const config = { someKey: 'someValue', nested: { flag: true } }; + mockGetS3Object.mockResolvedValue(JSON.stringify(config)); + + const result = await getAseloTwilioConfigurationHandler( + createMockRequest(), + TEST_ACCOUNT_SID, + ); + + expect(isOk(result)).toBe(true); + if (isOk(result)) { + expect(result.data).toEqual(config); + } + expect(mockGetDocsBucketName).toHaveBeenCalledWith(TEST_ACCOUNT_SID); + expect(mockGetS3Object).toHaveBeenCalledWith( + TEST_BUCKET, + 'configuration/twilio.json', + ); + }); + + it('should return an empty object when configuration file does not exist (NoSuchKey)', async () => { + const noSuchKeyError = Object.assign(new Error('The specified key does not exist.'), { + name: 'NoSuchKey', + }); + mockGetS3Object.mockRejectedValue(noSuchKeyError); + + const result = await getAseloTwilioConfigurationHandler( + createMockRequest(), + TEST_ACCOUNT_SID, + ); + + expect(isOk(result)).toBe(true); + if (isOk(result)) { + expect(result.data).toEqual({}); + } + }); + + it('should return 500 on unexpected S3 error', async () => { + mockGetS3Object.mockRejectedValue(new Error('S3 service unavailable')); + + const result = await getAseloTwilioConfigurationHandler( + createMockRequest(), + TEST_ACCOUNT_SID, + ); + + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.message).toBe('S3 service unavailable'); + expect(result.error.statusCode).toBe(500); + } + }); + + it('should return 500 when getDocsBucketName fails', async () => { + mockGetDocsBucketName.mockRejectedValue(new Error('SSM parameter not found')); + + const result = await getAseloTwilioConfigurationHandler( + createMockRequest(), + TEST_ACCOUNT_SID, + ); + + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.message).toBe('SSM parameter not found'); + expect(result.error.statusCode).toBe(500); + } + }); +}); diff --git a/lambdas/account-scoped/tests/unit/validation/flexToken.test.ts b/lambdas/account-scoped/tests/unit/validation/flexToken.test.ts new file mode 100644 index 0000000000..6d89022f78 --- /dev/null +++ b/lambdas/account-scoped/tests/unit/validation/flexToken.test.ts @@ -0,0 +1,118 @@ +/** + * Copyright (C) 2021-2026 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { validator } from 'twilio-flex-token-validator'; +import { getAccountAuthToken } from '@tech-matters/twilio-configuration'; +import { isErr, isOk } from '../../../src/Result'; +import { AccountScopedRoute, HttpRequest } from '../../../src/httpTypes'; +import { validateFlexTokenRequest } from '../../../src/validation/flexToken'; +import { TEST_ACCOUNT_SID } from '../../testTwilioValues'; + +jest.mock('twilio-flex-token-validator', () => ({ + validator: jest.fn(), +})); + +jest.mock('@tech-matters/twilio-configuration', () => ({ + getAccountAuthToken: jest.fn(), +})); + +const mockValidator = validator as jest.MockedFunction; +const mockGetAccountAuthToken = getAccountAuthToken as jest.MockedFunction< + typeof getAccountAuthToken +>; + +const baseRequest: HttpRequest = { + method: 'GET', + headers: {}, + path: '/configuration/twilio', + query: {}, + body: {}, +}; + +const routeContext = { + accountSid: TEST_ACCOUNT_SID, +} as AccountScopedRoute; + +describe('validateFlexTokenRequest', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetAccountAuthToken.mockResolvedValue('account-auth-token'); + mockValidator.mockResolvedValue({ + worker_sid: 'WK123', + roles: ['agent'], + } as any); + }); + + test('accepts bearer token from authorization header', async () => { + const request = { + ...baseRequest, + headers: { + authorization: ['Bearer', 'from-header-token'].join(' '), + }, + body: {}, + }; + + const result = await validateFlexTokenRequest({ tokenMode: 'agent' })( + request, + routeContext, + ); + + expect(isOk(result)).toBe(true); + if (isOk(result) && 'tokenResult' in result.data) { + expect(result.data.tokenResult.worker_sid).toBe('WK123'); + } + expect(mockValidator).toHaveBeenCalledWith( + 'from-header-token', + TEST_ACCOUNT_SID, + 'account-auth-token', + ); + }); + + test('falls back to Token from body when no authorization header is present', async () => { + const request = { + ...baseRequest, + body: { + Token: 'from-body-token', + }, + }; + + const result = await validateFlexTokenRequest({ tokenMode: 'agent' })( + request, + routeContext, + ); + + expect(isOk(result)).toBe(true); + expect(mockValidator).toHaveBeenCalledWith( + 'from-body-token', + TEST_ACCOUNT_SID, + 'account-auth-token', + ); + }); + + test('returns missing-parameter error when no token is provided', async () => { + const result = await validateFlexTokenRequest({ tokenMode: 'agent' })( + baseRequest, + routeContext, + ); + + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.error.statusCode).toBe(400); + expect(result.message).toContain('Token body parameter missing'); + } + expect(mockValidator).not.toHaveBeenCalled(); + }); +}); diff --git a/lambdas/account-scoped/tsconfig.build.json b/lambdas/account-scoped/tsconfig.build.json index 04856c6c97..b1fa4e36f8 100644 --- a/lambdas/account-scoped/tsconfig.build.json +++ b/lambdas/account-scoped/tsconfig.build.json @@ -3,12 +3,13 @@ "extends": "./tsconfig.base.json", "files": [], "references": [ + { "path": "packages/s3" }, { "path": "packages/ssm-cache" }, { "path": "packages/configuration" }, { "path": "packages/hrm-types" }, { "path": "packages/hrm-form-definitions" }, { "path": "packages/twilio-types" }, { "path": "packages/twilio-configuration" }, - { "path": "account-scoped" }, + { "path": "account-scoped" } ] } diff --git a/lambdas/packages/hrm-form-definitions/form-definitions/as/v1/customStrings/Substitutions.json b/lambdas/packages/hrm-form-definitions/form-definitions/as/v1/customStrings/Substitutions.json index 94f4cab013..58561cfa5e 100644 --- a/lambdas/packages/hrm-form-definitions/form-definitions/as/v1/customStrings/Substitutions.json +++ b/lambdas/packages/hrm-form-definitions/form-definitions/as/v1/customStrings/Substitutions.json @@ -4,7 +4,9 @@ "Switchboard-NoQueuesSwitchboarded": "No queues are currently being switchboarded", "Admin": "Translated Admin", "Chat Queue Test": "Quat Teue Chest", - "CustomLink-Label-ResourceMap": "Resource Map" + "CustomLink-Label-ResourceMap": "Resource Map", + "Conference-PhoneInputDialog-QuickDialItem/988-English": "988 (English)", + "Conference-PhoneInputDialog-QuickDialItem/988-Spanish": "988 (Spanish)" }, "es": { "HelplineSubstitution": "Substitución de la Línea de Ayuda", diff --git a/plugin-hrm-form/src/HrmFormPlugin.tsx b/plugin-hrm-form/src/HrmFormPlugin.tsx index 4f239aa9fc..91b56f3b4d 100644 --- a/plugin-hrm-form/src/HrmFormPlugin.tsx +++ b/plugin-hrm-form/src/HrmFormPlugin.tsx @@ -49,6 +49,8 @@ import { setUpFullStory } from './fullStory/setUp'; import { getPathFromUrl } from './states/routing/reducer'; import { setUpCustomSideLinks } from './components/customSideLinks/setUpCustomSideLinks'; import { setUpVoicemailComponents } from './voicemail/setUpVoicemailComponents'; +import { newLoadAseloTwilioConfigurationAsyncAction } from './states/configuration/loadAseloTwilioConfiguration'; +import asyncDispatch from './states/asyncDispatch'; const PLUGIN_NAME = 'HrmFormPlugin'; @@ -234,7 +236,12 @@ export default class HrmFormPlugin extends FlexPlugin { }, }; manager.updateConfig(managerConfiguration); - + // The 'private' configuration is ok to store in plain text in memory on the client, it doesn't need to be treated as sensitive for security purposes so can be kept in redux + try { + await asyncDispatch(manager.store.dispatch)(newLoadAseloTwilioConfigurationAsyncAction()); + } catch (error) { + console.warn('Failed to load private configuration, using default', error); + } // TODO(nick): Eventually remove this log line or set to debug. Should we fail hard here? const { hrmBaseUrl } = config; console.info(`HRM URL: ${hrmBaseUrl}`); diff --git a/plugin-hrm-form/src/___tests__/services/configurationService.test.ts b/plugin-hrm-form/src/___tests__/services/configurationService.test.ts new file mode 100644 index 0000000000..d1ed9325cb --- /dev/null +++ b/plugin-hrm-form/src/___tests__/services/configurationService.test.ts @@ -0,0 +1,42 @@ +/** + * Copyright (C) 2021-2026 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { getAseloTwilioConfiguration } from '../../services/configurationService'; +import { getFromAccountScopedLambda } from '../../services/fetchAccountScopedLambdaApi'; + +jest.mock('../../services/fetchAccountScopedLambdaApi', () => ({ + getFromAccountScopedLambda: jest.fn(), +})); + +const mockGetFromAccountScopedLambda = getFromAccountScopedLambda as jest.MockedFunction< + typeof getFromAccountScopedLambda +>; + +describe('configurationService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('getAseloTwilioConfiguration fetches twilio configuration from account-scoped lambda', async () => { + const response = { + quickDialOptions: [{ labelKey: 'foo', phoneNumber: '+1234567890' }], + }; + mockGetFromAccountScopedLambda.mockResolvedValue(response); + + await expect(getAseloTwilioConfiguration()).resolves.toStrictEqual(response); + expect(mockGetFromAccountScopedLambda).toHaveBeenCalledWith('configuration/twilio'); + }); +}); diff --git a/plugin-hrm-form/src/___tests__/services/fetchAccountScopedLambdaApi.test.ts b/plugin-hrm-form/src/___tests__/services/fetchAccountScopedLambdaApi.test.ts new file mode 100644 index 0000000000..a8ab16ad3c --- /dev/null +++ b/plugin-hrm-form/src/___tests__/services/fetchAccountScopedLambdaApi.test.ts @@ -0,0 +1,98 @@ +/** + * Copyright (C) 2021-2026 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import fetchProtectedApi from '../../services/fetchProtectedApi'; +import { getHrmConfig } from '../../hrmConfig'; +import { getValidToken } from '../../authentication'; +import { ApiError, fetchApi } from '../../services/fetchApi'; +import { getFromAccountScopedLambda, postToAccountScopedLambda } from '../../services/fetchAccountScopedLambdaApi'; + +jest.mock('../../services/fetchProtectedApi'); +jest.mock('../../hrmConfig', () => ({ + getHrmConfig: jest.fn(), +})); +jest.mock('../../authentication', () => ({ + getValidToken: jest.fn(), +})); +jest.mock('../../services/fetchApi', () => { + const actual = jest.requireActual('../../services/fetchApi'); + return { + ...actual, + fetchApi: jest.fn(), + }; +}); + +const mockFetchProtectedApi = fetchProtectedApi as jest.MockedFunction; +const mockGetHrmConfig = getHrmConfig as jest.MockedFunction; +const mockGetValidToken = getValidToken as jest.MockedFunction; +const mockFetchApi = fetchApi as jest.MockedFunction; + +describe('fetchAccountScopedLambdaApi', () => { + beforeEach(() => { + jest.resetAllMocks(); + mockGetHrmConfig.mockReturnValue({ + accountScopedLambdaBaseUrl: 'https://account-scoped.example.com', + } as ReturnType); + mockGetValidToken.mockReturnValue('valid-token'); + }); + + test('postToAccountScopedLambda delegates to fetchProtectedApi with useTwilioLambda enabled', async () => { + const body = { test: 'value' }; + const fetchOptions = { useJsonEncode: true }; + + await postToAccountScopedLambda('configuration/twilio', body, fetchOptions); + + expect(mockFetchProtectedApi).toHaveBeenCalledWith('configuration/twilio', body, { + useJsonEncode: true, + useTwilioLambda: true, + }); + }); + + test('getFromAccountScopedLambda uses GET and bearer authorization header', async () => { + const response = { quickDialOptions: [] }; + mockFetchApi.mockResolvedValue(response); + + await expect( + getFromAccountScopedLambda('configuration/twilio', { + headers: { 'X-Test-Header': 'true' }, + }), + ).resolves.toStrictEqual(response); + + expect(mockFetchApi).toHaveBeenCalledWith( + new URL('https://account-scoped.example.com'), + 'configuration/twilio', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: expect.any(String), + 'X-Test-Header': 'true', + }), + }), + ); + const [, , fetchOptions] = mockFetchApi.mock.calls[0]; + expect((fetchOptions.headers as Record).Authorization).toContain('valid-token'); + }); + + test('getFromAccountScopedLambda throws ApiError when token is unavailable', async () => { + const tokenError = new Error('token missing'); + mockGetValidToken.mockReturnValue(tokenError); + + await expect(getFromAccountScopedLambda('configuration/twilio')).rejects.toEqual( + new ApiError('Aborting request due to token issue: token missing', {}, tokenError), + ); + expect(mockFetchApi).not.toHaveBeenCalled(); + }); +}); diff --git a/plugin-hrm-form/src/___tests__/services/fetchProtectedApi.test.ts b/plugin-hrm-form/src/___tests__/services/fetchProtectedApi.test.ts index a54d6aa945..68a8f05c64 100644 --- a/plugin-hrm-form/src/___tests__/services/fetchProtectedApi.test.ts +++ b/plugin-hrm-form/src/___tests__/services/fetchProtectedApi.test.ts @@ -75,7 +75,12 @@ describe('fetchProtectedApi', () => { expect(response).toStrictEqual(responseBody); const { body, headers }: { body: URLSearchParams; headers: Record } = mockFetch.mock.calls[0][1]; expect(body.toString()).toBe(new URLSearchParams({ ...requestBody, Token: 'of my appreciation' }).toString()); - expect(headers).toStrictEqual({ 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }); + expect(headers).toEqual( + expect.objectContaining({ + Authorization: expect.stringContaining('of my appreciation'), + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + }), + ); }); }); test('403 error response - throws ProtectedApiError with specific error message', async () => { diff --git a/plugin-hrm-form/src/___tests__/states/configuration/loadAseloTwilioConfiguration.test.ts b/plugin-hrm-form/src/___tests__/states/configuration/loadAseloTwilioConfiguration.test.ts new file mode 100644 index 0000000000..900cb953b7 --- /dev/null +++ b/plugin-hrm-form/src/___tests__/states/configuration/loadAseloTwilioConfiguration.test.ts @@ -0,0 +1,49 @@ +/** + * Copyright (C) 2021-2026 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import '../../mockFlexUi'; +import '../../mockGetConfig'; +import { initialState, reduce } from '../../../states/configuration/reducer'; +import { newLoadAseloTwilioConfigurationAsyncAction } from '../../../states/configuration/loadAseloTwilioConfiguration'; + +describe('loadAseloTwilioConfigurationReducer', () => { + test('stores fetched twilio configuration on fulfilled action', () => { + const payload = { + quickDialOptions: [{ labelKey: 'label', phoneNumber: '+123' }], + }; + + const result = reduce(initialState, newLoadAseloTwilioConfigurationAsyncAction.fulfilled(payload)); + + expect(result.aseloTwilioConfiguration).toStrictEqual(payload); + }); + + test('keeps current state on rejected action', () => { + const state = { + ...initialState, + aseloTwilioConfiguration: { + quickDialOptions: [{ labelKey: 'existing', phoneNumber: '+456' }], + }, + }; + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const result = reduce(state, newLoadAseloTwilioConfigurationAsyncAction.rejected(new Error('Failed to load'))); + + expect(result).toStrictEqual(state); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); +}); diff --git a/plugin-hrm-form/src/___tests__/states/configuration/reducer.test.ts b/plugin-hrm-form/src/___tests__/states/configuration/reducer.test.ts index f00885a0bb..90f3ebf146 100644 --- a/plugin-hrm-form/src/___tests__/states/configuration/reducer.test.ts +++ b/plugin-hrm-form/src/___tests__/states/configuration/reducer.test.ts @@ -47,6 +47,7 @@ describe('test reducer', () => { chatChannelCapacity: 0, }, definitionVersions: {}, + aseloTwilioConfiguration: {}, }; const result = reduce(state, {} as ConfigurationActionType); diff --git a/plugin-hrm-form/src/___tests__/states/configuration/selectQuickDialOptions.test.ts b/plugin-hrm-form/src/___tests__/states/configuration/selectQuickDialOptions.test.ts new file mode 100644 index 0000000000..8f2497ba2c --- /dev/null +++ b/plugin-hrm-form/src/___tests__/states/configuration/selectQuickDialOptions.test.ts @@ -0,0 +1,47 @@ +/** + * Copyright (C) 2021-2026 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { RootState } from '../../../states'; +import { namespace } from '../../../states/storeNamespaces'; +import { RecursivePartial } from '../../RecursivePartial'; +import { selectQuickDialOptions } from '../../../states/configuration/selectQuickDialOptions'; + +describe('selectQuickDialOptions', () => { + test('returns quick dial options from state when present', () => { + const quickDialOptions = [{ labelKey: 'foo', phoneNumber: '+123' }]; + const partialState: RecursivePartial = { + [namespace]: { + configuration: { + aseloTwilioConfiguration: { quickDialOptions }, + }, + }, + }; + + expect(selectQuickDialOptions(partialState as RootState)).toStrictEqual(quickDialOptions); + }); + + test('returns empty array when quick dial options are missing', () => { + const partialState: RecursivePartial = { + [namespace]: { + configuration: { + aseloTwilioConfiguration: {}, + }, + }, + }; + + expect(selectQuickDialOptions(partialState as RootState)).toStrictEqual([]); + }); +}); diff --git a/plugin-hrm-form/src/components/Conference/ConferenceActions/PhoneInputDialog.tsx b/plugin-hrm-form/src/components/Conference/ConferenceActions/PhoneInputDialog.tsx index 841da969bc..ca4e1c4b0b 100644 --- a/plugin-hrm-form/src/components/Conference/ConferenceActions/PhoneInputDialog.tsx +++ b/plugin-hrm-form/src/components/Conference/ConferenceActions/PhoneInputDialog.tsx @@ -17,9 +17,12 @@ import React from 'react'; import { Template, Manager } from '@twilio/flex-ui'; import { CallEnd as CallEndIcon } from '@material-ui/icons'; import { CircularProgress } from '@material-ui/core'; +import { useSelector } from 'react-redux'; import { Row, Bold, CloseButton, SecondaryButton } from '../../../styles'; import { PhoneDialogWrapper, DialogArrow } from './styles'; +import { selectQuickDialOptions } from '../../../states/configuration/selectQuickDialOptions'; +import { getHrmConfig } from '../../../hrmConfig'; type PhoneDialogProps = { targetNumber: string; @@ -32,18 +35,6 @@ type PhoneDialogProps = { const ENTER_NUMBER_KEY = 'Conference-EnterPhoneNumber'; -type QuickDialItem = { - labelKey: string; - phoneNumber: string; -}; - -const TEMPORARY_HARDCODED_QUICKDIAL: QuickDialItem[] = [ - { labelKey: 'Conference-PhoneInputDialog-QuickDialItem/988-English', phoneNumber: '+35314482861' }, - { labelKey: 'Conference-PhoneInputDialog-QuickDialItem/988-Spanish', phoneNumber: '+35317712424 ' }, -]; - -const ALLOW_MANUAL_DIAL: boolean = true; - const PhoneInputDialog: React.FC = ({ targetNumber, setTargetNumber, @@ -52,6 +43,9 @@ const PhoneInputDialog: React.FC = ({ setIsDialogOpen, isLoading, }) => { + const quickDialOptions = useSelector(selectQuickDialOptions); + const { allowManualDialOutForConferencing } = getHrmConfig(); + const dialButton = (handleClick: () => void) => { return ( @@ -79,22 +73,22 @@ const PhoneInputDialog: React.FC = ({ setIsDialogOpen(false)} aria-label="CloseButton" style={{ marginLeft: 'auto' }} /> - {TEMPORARY_HARDCODED_QUICKDIAL.length && ( + {Boolean(quickDialOptions.length) && (