From 26cbc76fe0902fc11ead49b27d2ea4d800b91de2 Mon Sep 17 00:00:00 2001 From: Kerry Archibald Date: Tue, 13 Sep 2022 16:59:29 +0200 Subject: [PATCH 1/7] trigger verification of other devices --- .../views/settings/devices/DeviceDetails.tsx | 11 ++++++++-- .../settings/devices/FilteredDeviceList.tsx | 11 +++++++++- .../views/settings/devices/useOwnDevices.ts | 21 +++++++++++++++++- .../settings/tabs/user/SessionManagerTab.tsx | 22 ++++++++++++++++++- test/test-utils/client.ts | 3 ++- 5 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/components/views/settings/devices/DeviceDetails.tsx b/src/components/views/settings/devices/DeviceDetails.tsx index 5a58efaa887..779e29b8d15 100644 --- a/src/components/views/settings/devices/DeviceDetails.tsx +++ b/src/components/views/settings/devices/DeviceDetails.tsx @@ -24,6 +24,7 @@ import { DeviceWithVerification } from './types'; interface Props { device: DeviceWithVerification; + onVerifyDevice?: () => void; } interface MetadataTable { @@ -31,7 +32,10 @@ interface MetadataTable { values: { label: string, value?: string | React.ReactNode }[]; } -const DeviceDetails: React.FC = ({ device }) => { +const DeviceDetails: React.FC = ({ + device, + onVerifyDevice, +}) => { const metadata: MetadataTable[] = [ { values: [ @@ -52,7 +56,10 @@ const DeviceDetails: React.FC = ({ device }) => { return
{ device.display_name ?? device.device_id } - +

{ _t('Session details') }

diff --git a/src/components/views/settings/devices/FilteredDeviceList.tsx b/src/components/views/settings/devices/FilteredDeviceList.tsx index 0c88a507c6d..4ce3e6e7da1 100644 --- a/src/components/views/settings/devices/FilteredDeviceList.tsx +++ b/src/components/views/settings/devices/FilteredDeviceList.tsx @@ -39,6 +39,7 @@ interface Props { filter?: DeviceSecurityVariation; onFilterChange: (filter: DeviceSecurityVariation | undefined) => void; onDeviceExpandToggle: (deviceId: DeviceWithVerification['device_id']) => void; + onRequestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => void; } // devices without timestamp metadata should be sorted last @@ -132,8 +133,10 @@ const DeviceListItem: React.FC<{ device: DeviceWithVerification; isExpanded: boolean; onDeviceExpandToggle: () => void; + onRequestDeviceVerification?: () => void; }> = ({ device, isExpanded, onDeviceExpandToggle, + onRequestDeviceVerification, }) =>
  • - { isExpanded && } + { isExpanded && }
  • ; /** @@ -157,6 +160,7 @@ export const FilteredDeviceList = expandedDeviceIds, onFilterChange, onDeviceExpandToggle, + onRequestDeviceVerification, }: Props, ref: ForwardedRef) => { const sortedDevices = getFilteredSortedDevices(devices, filter); @@ -210,6 +214,11 @@ export const FilteredDeviceList = device={device} isExpanded={expandedDeviceIds.includes(device.device_id)} onDeviceExpandToggle={() => onDeviceExpandToggle(device.device_id)} + onRequestDeviceVerification={ + onRequestDeviceVerification + ? () => onRequestDeviceVerification(device.device_id) + : undefined + } />, ) } diff --git a/src/components/views/settings/devices/useOwnDevices.ts b/src/components/views/settings/devices/useOwnDevices.ts index 7252b053b35..8bf474f61c6 100644 --- a/src/components/views/settings/devices/useOwnDevices.ts +++ b/src/components/views/settings/devices/useOwnDevices.ts @@ -17,10 +17,12 @@ limitations under the License. import { useCallback, useContext, useEffect, useState } from "react"; import { IMyDevice, MatrixClient } from "matrix-js-sdk/src/matrix"; import { CrossSigningInfo } from "matrix-js-sdk/src/crypto/CrossSigning"; +import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; +import { User } from "matrix-js-sdk/src/models/user"; import { logger } from "matrix-js-sdk/src/logger"; import MatrixClientContext from "../../../../contexts/MatrixClientContext"; -import { DevicesDictionary } from "./types"; +import { DevicesDictionary, DeviceWithVerification } from "./types"; const isDeviceVerified = ( matrixClient: MatrixClient, @@ -63,7 +65,11 @@ export enum OwnDevicesError { type DevicesState = { devices: DevicesDictionary; currentDeviceId: string; + currentUserMember?: User; + isCurrentDeviceVerified: boolean; isLoading: boolean; + // not provided when current device is not verified + requestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => Promise; refreshDevices: () => Promise; error?: OwnDevicesError; }; @@ -98,9 +104,22 @@ export const useOwnDevices = (): DevicesState => { refreshDevices(); }, [refreshDevices]); + const isCurrentDeviceVerified = !!devices[currentDeviceId]?.isVerified; + + const requestDeviceVerification = isCurrentDeviceVerified ? async (deviceId) => { + const userId = matrixClient.getUserId(); + return await matrixClient.requestVerification( + userId, + [deviceId], + ); + } : undefined; + return { devices, currentDeviceId, + currentUserMember: matrixClient.getUser(matrixClient.getUserId()), + isCurrentDeviceVerified, + requestDeviceVerification, refreshDevices, isLoading, error, diff --git a/src/components/views/settings/tabs/user/SessionManagerTab.tsx b/src/components/views/settings/tabs/user/SessionManagerTab.tsx index 3b6cefc15b7..462465ca297 100644 --- a/src/components/views/settings/tabs/user/SessionManagerTab.tsx +++ b/src/components/views/settings/tabs/user/SessionManagerTab.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { _t } from "../../../../../languageHandler"; import { useOwnDevices } from '../../devices/useOwnDevices'; @@ -26,12 +26,15 @@ import { DeviceSecurityVariation, DeviceWithVerification } from '../../devices/t import SettingsTab from '../SettingsTab'; import Modal from '../../../../../Modal'; import SetupEncryptionDialog from '../../../dialogs/security/SetupEncryptionDialog'; +import VerificationRequestDialog from '../../../dialogs/VerificationRequestDialog'; const SessionManagerTab: React.FC = () => { const { devices, currentDeviceId, + currentUserMember, isLoading, + requestDeviceVerification, refreshDevices, } = useOwnDevices(); const [filter, setFilter] = useState(); @@ -74,6 +77,22 @@ const SessionManagerTab: React.FC = () => { ); }; + const onTriggerDeviceVerification = useCallback((deviceId: DeviceWithVerification['device_id']) => { + if (!requestDeviceVerification) { + return; + } + const verificationRequestPromise = requestDeviceVerification(deviceId); + Modal.createDialog(VerificationRequestDialog, { + verificationRequestPromise, + member: currentUserMember, + onFinished: async () => { + const request = await verificationRequestPromise; + request.cancel(); + await refreshDevices(); + }, + }); + }, [requestDeviceVerification, refreshDevices, currentUserMember]); + useEffect(() => () => { clearTimeout(scrollIntoViewTimeoutRef.current); }, [scrollIntoViewTimeoutRef]); @@ -105,6 +124,7 @@ const SessionManagerTab: React.FC = () => { expandedDeviceIds={expandedDeviceIds} onFilterChange={setFilter} onDeviceExpandToggle={onDeviceExpandToggle} + onRequestDeviceVerification={requestDeviceVerification ? onTriggerDeviceVerification : undefined} ref={filteredDeviceListRef} /> diff --git a/test/test-utils/client.ts b/test/test-utils/client.ts index 453856eb26e..4a5b3184913 100644 --- a/test/test-utils/client.ts +++ b/test/test-utils/client.ts @@ -16,7 +16,7 @@ limitations under the License. import EventEmitter from "events"; import { MethodKeysOf, mocked, MockedObject } from "jest-mock"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { MatrixClient, User } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "../../src/MatrixClientPeg"; @@ -65,6 +65,7 @@ export const unmockClientPeg = () => jest.spyOn(MatrixClientPeg, 'get').mockRest */ export const mockClientMethodsUser = (userId = '@alice:domain') => ({ getUserId: jest.fn().mockReturnValue(userId), + getUser: jest.fn().mockReturnValue(new User(userId)), isGuest: jest.fn().mockReturnValue(false), mxcUrlToHttp: jest.fn().mockReturnValue('mock-mxcUrlToHttp'), credentials: { userId }, From dee480278c30df2f98baaa896cfd14a8c2725f5f Mon Sep 17 00:00:00 2001 From: Kerry Archibald Date: Tue, 13 Sep 2022 23:00:51 +0200 Subject: [PATCH 2/7] tests --- .../views/settings/devices/useOwnDevices.ts | 4 +- .../tabs/user/SessionManagerTab-test.tsx | 55 +++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/components/views/settings/devices/useOwnDevices.ts b/src/components/views/settings/devices/useOwnDevices.ts index 8bf474f61c6..b48bc4435d9 100644 --- a/src/components/views/settings/devices/useOwnDevices.ts +++ b/src/components/views/settings/devices/useOwnDevices.ts @@ -66,9 +66,8 @@ type DevicesState = { devices: DevicesDictionary; currentDeviceId: string; currentUserMember?: User; - isCurrentDeviceVerified: boolean; isLoading: boolean; - // not provided when current device is not verified + // not provided when current session cannot request verification requestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => Promise; refreshDevices: () => Promise; error?: OwnDevicesError; @@ -118,7 +117,6 @@ export const useOwnDevices = (): DevicesState => { devices, currentDeviceId, currentUserMember: matrixClient.getUser(matrixClient.getUserId()), - isCurrentDeviceVerified, requestDeviceVerification, refreshDevices, isLoading, diff --git a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx index 183aba540df..2c6d5f42fb3 100644 --- a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx +++ b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx @@ -20,6 +20,7 @@ import { act } from 'react-dom/test-utils'; import { DeviceInfo } from 'matrix-js-sdk/src/crypto/deviceinfo'; import { logger } from 'matrix-js-sdk/src/logger'; import { DeviceTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning'; +import { VerificationRequest } from 'matrix-js-sdk/src/crypto/verification/request/VerificationRequest'; import SessionManagerTab from '../../../../../../src/components/views/settings/tabs/user/SessionManagerTab'; import MatrixClientContext from '../../../../../../src/contexts/MatrixClientContext'; @@ -52,12 +53,14 @@ describe('', () => { const mockCrossSigningInfo = { checkDeviceTrust: jest.fn(), }; + const mockVerificationRequest = { cancel: jest.fn() } as unknown as VerificationRequest; const mockClient = getMockClientWithEventEmitter({ ...mockClientMethodsUser(aliceId), getStoredCrossSigningForUser: jest.fn().mockReturnValue(mockCrossSigningInfo), getDevices: jest.fn(), getStoredDevice: jest.fn(), getDeviceId: jest.fn().mockReturnValue(deviceId), + requestVerification: jest.fn().mockResolvedValue(mockVerificationRequest), }); const defaultProps = {}; @@ -278,4 +281,56 @@ describe('', () => { expect(getByTestId(`device-detail-${alicesOlderMobileDevice.device_id}`)).toBeTruthy(); }); }); + + describe('Device verification', () => { + it('does not render device verification cta when current session is not verified', async () => { + mockClient.getDevices.mockResolvedValue({ + devices: [alicesDevice, alicesOlderMobileDevice, alicesMobileDevice], + }); + const { getByTestId, queryByTestId } = render(getComponent()); + + await act(async () => { + await flushPromisesWithFakeTimers(); + }); + + const tile1 = getByTestId(`device-tile-${alicesOlderMobileDevice.device_id}`); + const toggle1 = tile1.querySelector('[aria-label="Toggle device details"]') as Element; + fireEvent.click(toggle1); + + // verify device button is not rendered + expect(queryByTestId(`verification-status-button-${alicesOlderMobileDevice.device_id}`)).toBeFalsy(); + }); + + it('does not render device verification cta when current session is not verified', async () => { + const modalSpy = jest.spyOn(Modal, 'createDialog'); + + // make the current device verified + mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice] }); + mockClient.getStoredDevice.mockImplementation((_userId, deviceId) => new DeviceInfo(deviceId)); + mockCrossSigningInfo.checkDeviceTrust + .mockImplementation((_userId, { deviceId }) => { + console.log('hhh', deviceId); + if (deviceId === alicesDevice.device_id) { + return new DeviceTrustLevel(true, true, false, false); + } + throw new Error('everything else unverified'); + }); + + const { getByTestId } = render(getComponent()); + + await act(async () => { + await flushPromisesWithFakeTimers(); + }); + + const tile1 = getByTestId(`device-tile-${alicesMobileDevice.device_id}`); + const toggle1 = tile1.querySelector('[aria-label="Toggle device details"]') as Element; + fireEvent.click(toggle1); + + // click verify button from current session section + fireEvent.click(getByTestId(`verification-status-button-${alicesMobileDevice.device_id}`)); + + expect(mockClient.requestVerification).toHaveBeenCalledWith(aliceId, [alicesMobileDevice.device_id]); + expect(modalSpy).toHaveBeenCalled(); + }); + }); }); From ee373898c2176f98feaa68a15550b22662c1be92 Mon Sep 17 00:00:00 2001 From: Kerry Archibald Date: Wed, 14 Sep 2022 09:37:47 +0200 Subject: [PATCH 3/7] fix strict errors --- .../views/settings/devices/useOwnDevices.ts | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/src/components/views/settings/devices/useOwnDevices.ts b/src/components/views/settings/devices/useOwnDevices.ts index b48bc4435d9..5b49e6ffcdb 100644 --- a/src/components/views/settings/devices/useOwnDevices.ts +++ b/src/components/views/settings/devices/useOwnDevices.ts @@ -19,6 +19,7 @@ import { IMyDevice, MatrixClient } from "matrix-js-sdk/src/matrix"; import { CrossSigningInfo } from "matrix-js-sdk/src/crypto/CrossSigning"; import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import { User } from "matrix-js-sdk/src/models/user"; +import { MatrixError } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import MatrixClientContext from "../../../../contexts/MatrixClientContext"; @@ -30,7 +31,14 @@ const isDeviceVerified = ( device: IMyDevice, ): boolean | null => { try { - const deviceInfo = matrixClient.getStoredDevice(matrixClient.getUserId(), device.device_id); + const userId = matrixClient.getUserId(); + if (!userId) { + throw new Error('No user id'); + } + const deviceInfo = matrixClient.getStoredDevice(userId, device.device_id); + if (!deviceInfo) { + throw new Error('No device info available'); + } return crossSigningInfo.checkDeviceTrust( crossSigningInfo, deviceInfo, @@ -43,9 +51,13 @@ const isDeviceVerified = ( } }; -const fetchDevicesWithVerification = async (matrixClient: MatrixClient): Promise => { +const fetchDevicesWithVerification = async ( + matrixClient: MatrixClient, + userId: string, +): Promise => { const { devices } = await matrixClient.getDevices(); - const crossSigningInfo = matrixClient.getStoredCrossSigningForUser(matrixClient.getUserId()); + + const crossSigningInfo = matrixClient.getStoredCrossSigningForUser(userId); const devicesDict = devices.reduce((acc, device: IMyDevice) => ({ ...acc, @@ -76,6 +88,7 @@ export const useOwnDevices = (): DevicesState => { const matrixClient = useContext(MatrixClientContext); const currentDeviceId = matrixClient.getDeviceId(); + const userId = matrixClient.getUserId(); const [devices, setDevices] = useState({}); const [isLoading, setIsLoading] = useState(true); @@ -84,11 +97,16 @@ export const useOwnDevices = (): DevicesState => { const refreshDevices = useCallback(async () => { setIsLoading(true); try { - const devices = await fetchDevicesWithVerification(matrixClient); + // realistically we should never hit this + // but it satisfies types + if (!userId) { + throw new Error('Cannot fetch devices without user id'); + } + const devices = await fetchDevicesWithVerification(matrixClient, userId); setDevices(devices); setIsLoading(false); } catch (error) { - if (error.httpStatus == 404) { + if ((error as MatrixError).httpStatus == 404) { // 404 probably means the HS doesn't yet support the API. setError(OwnDevicesError.Unsupported); } else { @@ -97,7 +115,7 @@ export const useOwnDevices = (): DevicesState => { } setIsLoading(false); } - }, [matrixClient]); + }, [matrixClient, userId]); useEffect(() => { refreshDevices(); @@ -105,8 +123,7 @@ export const useOwnDevices = (): DevicesState => { const isCurrentDeviceVerified = !!devices[currentDeviceId]?.isVerified; - const requestDeviceVerification = isCurrentDeviceVerified ? async (deviceId) => { - const userId = matrixClient.getUserId(); + const requestDeviceVerification = isCurrentDeviceVerified && userId ? async (deviceId) => { return await matrixClient.requestVerification( userId, [deviceId], @@ -116,7 +133,7 @@ export const useOwnDevices = (): DevicesState => { return { devices, currentDeviceId, - currentUserMember: matrixClient.getUser(matrixClient.getUserId()), + currentUserMember: userId && matrixClient.getUser(userId) || undefined, requestDeviceVerification, refreshDevices, isLoading, From b52bdf7ea0a89913a20cea1f101a861108b6980a Mon Sep 17 00:00:00 2001 From: Kerry Archibald Date: Wed, 14 Sep 2022 10:31:14 +0200 Subject: [PATCH 4/7] add types --- .../views/settings/devices/useOwnDevices.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/components/views/settings/devices/useOwnDevices.ts b/src/components/views/settings/devices/useOwnDevices.ts index 5b49e6ffcdb..3f1beecae78 100644 --- a/src/components/views/settings/devices/useOwnDevices.ts +++ b/src/components/views/settings/devices/useOwnDevices.ts @@ -123,12 +123,14 @@ export const useOwnDevices = (): DevicesState => { const isCurrentDeviceVerified = !!devices[currentDeviceId]?.isVerified; - const requestDeviceVerification = isCurrentDeviceVerified && userId ? async (deviceId) => { - return await matrixClient.requestVerification( - userId, - [deviceId], - ); - } : undefined; + const requestDeviceVerification = isCurrentDeviceVerified && userId + ? async (deviceId: DeviceWithVerification['device_id']) => { + return await matrixClient.requestVerification( + userId, + [deviceId], + ); + } + : undefined; return { devices, From abac3105245889fa0abf56272432b0fb2141d0eb Mon Sep 17 00:00:00 2001 From: Kerry Archibald Date: Wed, 14 Sep 2022 11:04:24 +0200 Subject: [PATCH 5/7] unit test for modal closing --- .../tabs/user/SessionManagerTab-test.tsx | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx index 2c6d5f42fb3..36b7951a272 100644 --- a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx +++ b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx @@ -301,7 +301,7 @@ describe('', () => { expect(queryByTestId(`verification-status-button-${alicesOlderMobileDevice.device_id}`)).toBeFalsy(); }); - it('does not render device verification cta when current session is not verified', async () => { + it('renders device verification cta on other sessions when current session is verified', async () => { const modalSpy = jest.spyOn(Modal, 'createDialog'); // make the current device verified @@ -332,5 +332,46 @@ describe('', () => { expect(mockClient.requestVerification).toHaveBeenCalledWith(aliceId, [alicesMobileDevice.device_id]); expect(modalSpy).toHaveBeenCalled(); }); + + it('refreshes devices after verifying other device', async () => { + const modalSpy = jest.spyOn(Modal, 'createDialog'); + + // make the current device verified + mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice] }); + mockClient.getStoredDevice.mockImplementation((_userId, deviceId) => new DeviceInfo(deviceId)); + mockCrossSigningInfo.checkDeviceTrust + .mockImplementation((_userId, { deviceId }) => { + console.log('hhh', deviceId); + if (deviceId === alicesDevice.device_id) { + return new DeviceTrustLevel(true, true, false, false); + } + throw new Error('everything else unverified'); + }); + + const { getByTestId } = render(getComponent()); + + await act(async () => { + await flushPromisesWithFakeTimers(); + }); + + const tile1 = getByTestId(`device-tile-${alicesMobileDevice.device_id}`); + const toggle1 = tile1.querySelector('[aria-label="Toggle device details"]') as Element; + fireEvent.click(toggle1); + + // reset mock counter before triggering verification + mockClient.getDevices.mockClear(); + + // click verify button from current session section + fireEvent.click(getByTestId(`verification-status-button-${alicesMobileDevice.device_id}`)); + + const { onFinished: modalOnFinished } = modalSpy.mock.calls[0][1]; + // simulate modal completing process + await modalOnFinished(); + + // cancelled in case it was a failure exit from modal + expect(mockVerificationRequest.cancel).toHaveBeenCalled(); + // devices refreshed + expect(mockClient.getDevices).toHaveBeenCalled(); + }); }); }); From c6dba5e892771c08fa98db267006ec31004dc284 Mon Sep 17 00:00:00 2001 From: Kerry Archibald Date: Wed, 14 Sep 2022 11:05:24 +0200 Subject: [PATCH 6/7] remove unnecessary check --- src/components/views/settings/tabs/user/SessionManagerTab.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/components/views/settings/tabs/user/SessionManagerTab.tsx b/src/components/views/settings/tabs/user/SessionManagerTab.tsx index 462465ca297..024b71c5310 100644 --- a/src/components/views/settings/tabs/user/SessionManagerTab.tsx +++ b/src/components/views/settings/tabs/user/SessionManagerTab.tsx @@ -68,9 +68,6 @@ const SessionManagerTab: React.FC = () => { const shouldShowOtherSessions = Object.keys(otherDevices).length > 0; const onVerifyCurrentDevice = () => { - if (!currentDevice) { - return; - } Modal.createDialog( SetupEncryptionDialog as unknown as React.ComponentType, { onFinished: refreshDevices }, From dfb5af5769856a748763f99ee4facc154694ce18 Mon Sep 17 00:00:00 2001 From: Kerry Archibald Date: Wed, 14 Sep 2022 11:22:49 +0200 Subject: [PATCH 7/7] strict error --- .../views/settings/tabs/user/SessionManagerTab-test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx index 36b7951a272..bea02207e24 100644 --- a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx +++ b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx @@ -364,7 +364,7 @@ describe('', () => { // click verify button from current session section fireEvent.click(getByTestId(`verification-status-button-${alicesMobileDevice.device_id}`)); - const { onFinished: modalOnFinished } = modalSpy.mock.calls[0][1]; + const { onFinished: modalOnFinished } = modalSpy.mock.calls[0][1] as any; // simulate modal completing process await modalOnFinished();