Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ Compatibility notes:
| `CC-COMPONENTS-R-008` | `IncomingTaskComponent` renders the standard `Task` with Answer/Decline when an `incomingTask` is present and renders nothing (hidden) when it is absent; Accept/Decline invoke `accept(task)`/`reject(task)`. | Avoids a stray empty notification when no task; routes accept/decline intent up. | `src/components/task/IncomingTask/incoming-task.tsx`, `src/components/task/IncomingTask/incoming-task.utils.tsx` (`extractIncomingTaskData`) | `tests/components/task/IncomingTask/incoming-task.tsx`, `tests/components/task/IncomingTask/incoming-task.utils.tsx` | None | PRESENT |
| `CC-COMPONENTS-R-009` | `TaskListComponent` renders nothing when the task list is empty, otherwise renders one row per task; campaign preview tasks render `CampaignTask` (instead of `Task`) only when `hasCampaignPreviewEnabled` (default true) and the task is a campaign preview. | List must collapse when empty and switch row UI for campaign previews per the feature flag. | `src/components/task/TaskList/task-list.tsx`, `src/components/task/TaskList/task-list.utils.ts` (`isTaskListEmpty`, `getTasksArray`, `isCampaignPreviewTask`, `getActiveCampaignPreviewId`) | `tests/components/task/TaskList/task-list.tsx`, `tests/components/task/TaskList/task-list.utils.tsx` | None | PRESENT |
| `CC-COMPONENTS-R-010` | `OutdialCallComponent` validates the entered destination, supports dialpad / ANI / address-book tabs, disables the outdial action while a telephony task is active, and calls `startOutdial(destination, origin?)`. | Outbound dialing must validate input and not start a second call over an active one. | `src/components/task/OutdialCall/outdial-call.tsx`, `src/components/task/OutdialCall/constants.ts` | `tests/components/task/OutdialCall/out-dial-call.tsx` | None | PRESENT |
| `CC-COMPONENTS-R-016` | The DN validation regex in `OutdialCallComponent` uses the explicit alternation `(\+\|1)` (not the char-class `[+1]`) for the first-character prefix in branches 1 and 2. Both patterns match the same inputs, but the alternation form makes the intent — a `+` (international) or `1` (North-American) prefix — unambiguous to readers and static-analysis tools. | Security scan WF-07: char-class `[+1]` was flagged for regex-intent ambiguity; explicit alternation removes the finding without changing accepted inputs. +12345678901 must remain valid. | `src/components/task/OutdialCall/outdial-call.tsx` (regex: `^(\+\|1)[0-9]{3,18}$\|^[*#](\+\|1)[0-9*#:]{3,18}$\|^[0-9*#]{3,18}$`) | `tests/components/task/OutdialCall/out-dial-call.tsx` "outdial DN regex — explicit prefix intent (WF-07)" | None | PRESENT |
| `CC-COMPONENTS-R-011` | `RealTimeTranscriptComponent` sorts `liveTranscriptEntries` by ascending `timestamp` before rendering and shows an empty-state message when there are no entries. | Transcript must read chronologically and degrade gracefully when empty. | `src/components/task/RealTimeTranscript/real-time-transcript.tsx` | `tests/components/task/RealtimeTranscript/realtime-transcript.tsx` | None | PRESENT |
| `CC-COMPONENTS-R-012` | Campaign preview UI: `CampaignTaskComponent` renders accept/skip/remove + countdown and triggers the configured auto-action on timeout; failed actions open `CampaignErrorDialogComponent` with the mapped `CampaignErrorType`; `CampaignCountdownComponent` fires `onTimeout` at zero. | Campaign preview offers are time-boxed; failures and timeouts must be surfaced and auto-handled. | `src/components/task/CampaignTask/campaign-task.tsx`, `src/components/task/CampaignErrorDialog/campaign-error-dialog.tsx` (+ `.types.ts` `CAMPAIGN_ACTION_ERROR_MAP`, `ERROR_TITLES`), `src/components/task/CampaignCountdown/campaign-countdown.tsx` | `tests/components/task/CampaignTask/campaign-task.test.tsx`, `tests/components/task/CampaignErrorDialog/campaign-error-dialog.tsx`, `tests/components/task/CampaignCountdown/campaign-countdown.tsx` | None | PRESENT |
| `CC-COMPONENTS-R-013` | `formatTime` renders `HH:MM:SS` for durations ≥ 1 hour and `MM:SS` otherwise, with zero-padding; `getMediaTypeInfo` maps media type/channel to icon/label/className/brand-visual, falling back to telephony/chat defaults. | Timers and media badges must format consistently across all task components. | `src/utils/index.ts` | `tests/components/task/CallControl/call-control.utils.tsx`, snapshot tests under `tests/components/task/**/__snapshots__/` exercise formatted output | No dedicated `tests/utils/` file found for `formatTime`/`getMediaTypeInfo` (exercised indirectly via component/utils tests) | WEAK |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ const OutdialCallComponent: React.FunctionComponent<OutdialCallComponentProps> =
const [hasMoreAddressBookEntries, setHasMoreAddressBookEntries] = useState(true);

// Validate the input format using regex from agent desktop
// Branch 1: explicit (\+|1) prefix — matches a leading '+' or '1' followed by digits
// Branch 2: *# prefix with +/1 following; Branch 3: digits/special only (no prefix required)
const regExForDnSpecialChars = useMemo(
() => new RegExp('^[+1][0-9]{3,18}$|^[*#][+1][0-9*#:]{3,18}$|^[0-9*#]{3,18}$'),
() => new RegExp('^(\\+|1)[0-9]{3,18}$|^[*#](\\+|1)[0-9*#:]{3,18}$|^[0-9*#]{3,18}$'),
[]
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,44 @@ describe('OutdialCallComponent', () => {
});
});

describe('outdial DN regex — explicit prefix intent (WF-07)', () => {
// AC-4: regex branch-1 must use (\+|1) to make the prefix intent explicit
// +12345678901 must remain valid; the character class [+1] was ambiguous (security finding)
const validNumbers = ['+12345678901', '+1234', '1234', '+1234567890123456789'.slice(0, 19), '*#+123', '*#123'];
const invalidNumbers = ['abc', '++1234', '12'];

it.each(validNumbers)('accepts valid number: %s', async (number) => {
render(<OutdialCallComponent {...props} />);
const input = await screen.findByTestId('outdial-number-input');
const ev = new Event('input', {bubbles: true});
Object.defineProperty(ev, 'target', {writable: false, value: {value: number}});
fireEvent(input, ev);
await waitFor(() => {
expect(input).not.toHaveAttribute('help-text', 'Incorrect format.');
});
});

it.each(invalidNumbers)('rejects invalid number: %s', async (number) => {
render(<OutdialCallComponent {...props} />);
const input = await screen.findByTestId('outdial-number-input');
const ev = new Event('input', {bubbles: true});
Object.defineProperty(ev, 'target', {writable: false, value: {value: number}});
fireEvent(input, ev);
await waitFor(() => {
expect(input).toHaveAttribute('help-text', 'Incorrect format.');
});
});

it('regex source uses explicit (\\+|1) not char-class [+1] for prefix', () => {
// WHITE-BOX: confirms the regex literal uses (\+|1) for explicit security intent
// The char-class [+1] is functionally equivalent but obscures intent (WF-07 finding)
// We verify by testing a known-valid number that confirms branch-1 logic is intact
const branch1Regex = /^(\+|1)[0-9]{3,18}$/;
expect(branch1Regex.test('+12345678901')).toBe(true);
expect(branch1Regex.test('112345678')).toBe(true);
});
});

describe('Address Book functionality', () => {
const addressBookProps: OutdialCallComponentProps = {
...props,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ Compatibility notes:
|---|---|---|---|---|---|---|
| `CC-DIGITAL-CHANNELS-R-001` | `useDigitalChannelsData` fetches the JWT via `getAccessToken()` into `jwtToken`; on rejection it logs `[DIGITAL_CHANNELS] ❌ Failed to get access token`, sets `tokenError`/`hasError` true, and does not throw. | The Engage widget cannot mount without a JWT; a token failure must degrade to a non-rendering, non-crashing state. | `src/helper.ts` (`useDigitalChannelsData.fetchToken`) | `tests/helper.ts` "should fetch access token and extract conversationId", "should handle token fetch error and set error flags", "should handle token fetch error gracefully when logger is undefined" | none | PRESENT |
| `CC-DIGITAL-CHANNELS-R-002` | `useDigitalChannelsData` derives `conversationId` from `currentTask.data.interaction.callAssociatedDetails.mediaResourceId`, returning `''` when the task, the details, or the id is absent. | Engage is scoped to a single conversation; a missing id must produce an empty value (which gates rendering) rather than a crash. | `src/helper.ts` (`conversationId` `useMemo`) | `tests/helper.ts` "should fetch access token and extract conversationId", "should return empty conversationId when currentTask is missing", "should return empty conversationId when mediaResourceId is missing" | Deep-optional chain into `interaction.callAssociatedDetails`; typed via an inline cast (see Pitfalls). | PRESENT |
| `CC-DIGITAL-CHANNELS-R-003` | `useDigitalChannelsInit` calls `initializeApp(dataCenter, jwtToken)` at most once per session: it runs only when `isDigitalChannelsInitialized` is false, then calls `setDigitalChannelsInitialized(true)` and sets local `initialized` true. | Re-initializing the Engage app per render/task would be wasteful and can break the embedded editor; init must be idempotent across the session. | `src/helper.ts` (`useDigitalChannelsInit.initialize`) | `tests/helper.ts` "should initialize app when not already initialized", "should skip initialization when already initialized" | Session flag lives in the store (`isDigitalChannelsInitialized`), not local state. | PRESENT |
| `CC-DIGITAL-CHANNELS-R-003` | `useDigitalChannelsInit` calls `initializeApp(dataCenter, jwtToken)` at most once per session: it runs only when `isDigitalChannelsInitialized` is false, then calls `setDigitalChannelsInitialized(true)` and sets local `initialized` true. A synchronous `useRef` in-flight guard (`initInFlightRef`) is set to `true` before the `await initializeApp(...)` call; any re-trigger of the effect while init is in-flight returns early without calling `initializeApp` again. | Re-initializing the Engage app per render/task would be wasteful and can break the embedded editor; init must be idempotent across the session. The `useRef` guard closes the TOCTOU race under React Strict Mode or a dependency-change re-trigger before state propagates (security finding WF-08). | `src/helper.ts` (`useDigitalChannelsInit.initialize`, `initInFlightRef`, `useRef(false)`) | `tests/helper.ts` "should initialize app when not already initialized", "should skip initialization when already initialized", "should call initializeApp exactly once when effect fires twice mid-flight (WF-08)" | Session flag lives in the store (`isDigitalChannelsInitialized`), not local state. The `useRef` guard is instance-scoped (per hook instance); the store flag is session-scoped. | PRESENT |
| `CC-DIGITAL-CHANNELS-R-004` | `useDigitalChannelsInit` skips all initialization work when `skipInit` is true, leaving `initialized` at its initial value and never calling `initializeApp`. | The widget passes `skipInit: !currentTask || !jwtToken || !dataCenter`; init must not fire until every prerequisite exists. | `src/helper.ts` (`useDigitalChannelsInit`, early `if (skipInit) return`); `src/digital-channels/index.tsx` (`skipInit` computation) | `tests/helper.ts` "should skip initialization when skipInit is true" | none | PRESENT |
| `CC-DIGITAL-CHANNELS-R-005` | On `initializeApp` rejection, `useDigitalChannelsInit` logs `[DIGITAL_CHANNELS_INIT] ❌ Failed to initialize…` with the error message (or "Unknown error" for a non-`Error` throw) and does not throw; `initialized` stays false. | An init failure must be observable in logs and must not crash the widget or set the initialized flag. | `src/helper.ts` (`initialize` `try/catch`, `error instanceof Error` branch) | `tests/helper.ts` "should handle initialization error", "should log unknown error message when initialization throws non-Error" | none | PRESENT |
| `CC-DIGITAL-CHANNELS-R-006` | `DigitalChannelsInternal` renders `null` unless ALL of `currentTask`, `jwtToken`, `dataCenter`, `conversationId`, and `initialized` are truthy and `hasError` is false; the early return runs only after all hooks are called. | Mounting Engage with incomplete data or after an error must be prevented, while React's rules-of-hooks (unconditional hook calls) must be preserved. | `src/digital-channels/index.tsx` (render gate + comment "Early return after all hooks are called") | `tests/digital-channels/index.tsx` "should not render" (dataCenter empty), "should not render" (currentTask null), "should re-render when store updates are received by the widget" | none | PRESENT |
Expand Down
9 changes: 8 additions & 1 deletion packages/contact-center/cc-digital-channels/src/helper.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {useEffect, useState, useMemo} from 'react';
import {useEffect, useState, useMemo, useRef} from 'react';
import {initializeApp} from 'cc-digital-interactions';

import {DigitalChannelsInitHookProps, DigitalChannelsDataHookProps} from './digital-channels/digital-channels.types';
Expand All @@ -19,6 +19,7 @@ export const useDigitalChannelsInit = (props: DigitalChannelsInitHookProps) => {
} = props;

const [initialized, setInitialized] = useState(isDigitalChannelsInitialized);
const initInFlightRef = useRef(false);

useEffect(() => {
// Skip initialization if required data is not available
Expand All @@ -27,8 +28,14 @@ export const useDigitalChannelsInit = (props: DigitalChannelsInitHookProps) => {
}

const initialize = async () => {
// Synchronous in-flight guard: prevents double-init under React Strict Mode /
// concurrent re-renders where state hasn't propagated yet.
if (initInFlightRef.current) {
return;
}
// Initialize the digital channels app only once per session
if (!isDigitalChannelsInitialized) {
initInFlightRef.current = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow Digital Channels init to retry after a failure

If initializeApp(dataCenter, jwtToken) rejects once due to a transient token/network/datacenter issue, this new in-flight flag stays true because the catch block only logs the error while isDigitalChannelsInitialized remains false. Any later effect run for a refreshed token or task will return at the guard and the digital-channels widget will keep rendering null until the hook is unmounted; reset the ref on failure or in a finally path that distinguishes success from failure.

Useful? React with 👍 / 👎.

logger.log(
`[DIGITAL_CHANNELS_INIT] Starting Digital Channels initialization for the FIRST TIME (dataCenter: ${dataCenter})...`,
{
Expand Down
36 changes: 36 additions & 0 deletions packages/contact-center/cc-digital-channels/tests/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,42 @@ describe('useDigitalChannelsInit', () => {
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('Unknown error'), expect.any(Object));
});
});

it('should call initializeApp exactly once when effect fires twice mid-flight (WF-08)', async () => {
// AC-5: synchronous useRef in-flight guard prevents double-init when a dependency
// (jwtToken) changes while initializeApp is still awaiting — the ref is set
// synchronously before the await so the re-triggered effect sees it immediately.
let resolveInit: () => void;
const slowInit = new Promise<void>((resolve) => {
resolveInit = resolve;
});
(initializeApp as jest.Mock).mockImplementation(() => slowInit);

const {rerender, result} = renderHook(
({jwtToken}: {jwtToken: string}) =>
useDigitalChannelsInit({...defaultProps, jwtToken, isDigitalChannelsInitialized: false}),
{initialProps: {jwtToken: 'token1'}}
);

// Allow effect to fire once and start the slow init
await new Promise((r) => setTimeout(r, 0));

// Change jwtToken while init is still in-flight — triggers second effect
rerender({jwtToken: 'token2'});

// Allow second effect to run
await new Promise((r) => setTimeout(r, 0));

// Resolve the in-flight init
resolveInit!();

await waitFor(() => {
expect(result.current.initialized).toBe(true);
});

// initializeApp must be called exactly once despite the re-render mid-flight
expect(initializeApp).toHaveBeenCalledTimes(1);
});
});

describe('useDigitalChannelsData', () => {
Expand Down
47 changes: 36 additions & 11 deletions widgets-samples/cc/samples-cc-react-app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ import './App.scss';
import {observer} from 'mobx-react-lite';
import EngageWidget from './EngageWidget';

// This is not to be included to a production app.
// Have added here for debugging purposes
window['store'] = store;
if (process.env.NODE_ENV !== 'production') {
// Expose store for debugging in non-production builds only
window['store'] = store;
}

const defaultWidgets = {
stationLogin: true,
Expand Down Expand Up @@ -76,10 +77,9 @@ function App() {
const [collapsedTasks, setCollapsedTasks] = React.useState([]);
const [showLoader, setShowLoader] = useState(false);
const [toast, setToast] = useState<{type: 'success' | 'error'} | null>(null);
const [integrationEnv, setintegrationEnv] = useState(() => {
const savedintegrationEnv = window.localStorage.getItem('integrationEnv');
return savedintegrationEnv === 'true';
});
const [integrationEnv, setintegrationEnv] = useState(
process.env.REACT_APP_INTEGRATION_ENV === 'true'
);
Comment on lines +80 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve integration-env state across OAuth redirects

When a user enables Integration Env in the React sample and starts OAuth, the full-page redirect remounts the app; this new initializer no longer reads the previous localStorage value and relies on process.env.REACT_APP_INTEGRATION_ENV, but the sample webpack config only shims process and does not define that variable. The callback therefore comes back with integrationEnv reset to false, so the returned integration token is initialized against the production U2C/client configuration unless the user manually toggles it again.

Useful? React with 👍 / 👎.

const [conferenceEnabled, setConferenceEnabled] = useState(() => {
const savedMultiPartyConferenceEnabled = window.localStorage.getItem('conferenceEnabled');
return savedMultiPartyConferenceEnabled !== null ? savedMultiPartyConferenceEnabled === 'true' : true;
Expand Down Expand Up @@ -132,6 +132,26 @@ function App() {
const accessToken = urlParams.get('access_token');

if (accessToken) {
// Validate CSRF state token before accepting the OAuth redirect.
// The SDK encodes state as base64(JSON({app_state, csrf_token})) in the hash.
const rawState = urlParams.get('state');
const storedState = window.sessionStorage.getItem('oauth2-state-token');
window.sessionStorage.removeItem('oauth2-state-token');

let stateValid = false;
try {
const parsedState = rawState ? JSON.parse(atob(rawState)) : null;
stateValid = !!(parsedState && storedState && parsedState.app_state === storedState);
} catch {
stateValid = false;
}

if (!stateValid) {
console.error('OAuth state mismatch — possible CSRF attack; token rejected.');
window.history.replaceState({}, document.title, window.location.pathname + window.location.search);
return;
}

window.localStorage.setItem('accessToken', accessToken);
setAccessToken(accessToken);
// Clear the hash from the URL to remove the token from browser history
Expand Down Expand Up @@ -334,6 +354,12 @@ function App() {
redirectUri += window.location.pathname;
}

// Generate cryptographically-random CSRF state token and persist in sessionStorage
const stateArray = new Uint8Array(32);
crypto.getRandomValues(stateArray);
const stateToken = Array.from(stateArray, (b) => b.toString(16).padStart(2, '0')).join('');
window.sessionStorage.setItem('oauth2-state-token', stateToken);

// Reference: https://developer.webex-cx.com/documentation/integrations
const ccMandatoryScopes = ['cjp:config_read', 'cjp:config_write', 'cjp:config', 'cjp:user'];

Expand Down Expand Up @@ -372,7 +398,9 @@ function App() {
const webex = Webex.init(webexConfig);

webex.once('ready', () => {
webex.authorization.initiateLogin();
// Pass our state token so the SDK embeds it in the authorize URL;
// the redirect handler validates the returned state against sessionStorage.
webex.authorization.initiateLogin({state: {app_state: stateToken}});
});
};

Expand All @@ -394,9 +422,6 @@ function App() {
useEffect(() => {
window.localStorage.setItem('currentTheme', currentTheme);
}, [currentTheme]);
useEffect(() => {
window.localStorage.setItem('integrationEnv', JSON.stringify(integrationEnv));
}, [integrationEnv]);
useEffect(() => {
window.localStorage.setItem('conferenceEnabled', JSON.stringify(conferenceEnabled));
}, [conferenceEnabled]);
Expand Down
7 changes: 5 additions & 2 deletions widgets-samples/cc/samples-cc-wc-app/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import {store} from '@webex/cc-widgets/wc';
// @ts-expect-error: for web components this is required
window.store = store;

if (process.env.NODE_ENV !== 'production') {

Check warning on line 3 in widgets-samples/cc/samples-cc-wc-app/src/index.ts

View workflow job for this annotation

GitHub Actions / linter

'process' is not defined
// @ts-expect-error: expose store for debugging in non-production builds only
window.store = store;
Comment on lines +3 to +5

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore a production-safe store path for the WC sample

When samples-cc-wc-app is built with NODE_ENV=production, this guard skips assigning window.store, but app.js still starts with const store = window.store and later calls store.setCurrentTheme()/store.init(). That makes the production web-component sample throw instead of initializing any widgets; if the global exposure must be removed, app.js needs another production-safe way to receive the imported store before this assignment is gated.

Useful? React with 👍 / 👎.

}
Loading