diff --git a/test/DeviceListener-test.ts b/test/DeviceListener-test.ts index 8d0dd48570e..3e76af5bb79 100644 --- a/test/DeviceListener-test.ts +++ b/test/DeviceListener-test.ts @@ -15,10 +15,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventEmitter } from "events"; -import { mocked } from "jest-mock"; -import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import { Mocked, mocked } from "jest-mock"; +import { MatrixEvent, Room, MatrixClient } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; +import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo"; +import { CrossSigningInfo, DeviceTrustLevel } from "matrix-js-sdk/src/crypto/CrossSigning"; +import { CryptoEvent } from "matrix-js-sdk/src/crypto"; +import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; import DeviceListener from "../src/DeviceListener"; import { MatrixClientPeg } from "../src/MatrixClientPeg"; @@ -30,7 +33,7 @@ import dis from "../src/dispatcher/dispatcher"; import { Action } from "../src/dispatcher/actions"; import SettingsStore from "../src/settings/SettingsStore"; import { SettingLevel } from "../src/settings/SettingLevel"; -import { mockPlatformPeg } from "./test-utils"; +import { getMockClientWithEventEmitter, mockPlatformPeg } from "./test-utils"; // don't litter test console with logs jest.mock("matrix-js-sdk/src/logger"); @@ -44,35 +47,13 @@ jest.mock("../src/SecurityManager", () => ({ isSecretStorageBeingAccessed: jest.fn(), accessSecretStorage: jest.fn(), })); +const userId = '@user:server'; const deviceId = 'my-device-id'; - -class MockClient extends EventEmitter { - isGuest = jest.fn(); - getUserId = jest.fn(); - getKeyBackupVersion = jest.fn().mockResolvedValue(undefined); - getRooms = jest.fn().mockReturnValue([]); - doesServerSupportUnstableFeature = jest.fn().mockResolvedValue(true); - isCrossSigningReady = jest.fn().mockResolvedValue(true); - isSecretStorageReady = jest.fn().mockResolvedValue(true); - isCryptoEnabled = jest.fn().mockReturnValue(true); - isInitialSyncComplete = jest.fn().mockReturnValue(true); - getKeyBackupEnabled = jest.fn(); - getStoredDevicesForUser = jest.fn().mockReturnValue([]); - getCrossSigningId = jest.fn(); - getStoredCrossSigningForUser = jest.fn(); - waitForClientWellKnown = jest.fn(); - downloadKeys = jest.fn(); - isRoomEncrypted = jest.fn(); - getClientWellKnown = jest.fn(); - getDeviceId = jest.fn().mockReturnValue(deviceId); - setAccountData = jest.fn(); - getAccountData = jest.fn(); -} const mockDispatcher = mocked(dis); const flushPromises = async () => await new Promise(process.nextTick); describe('DeviceListener', () => { - let mockClient; + let mockClient: Mocked | undefined; // spy on various toasts' hide and show functions // easier than mocking @@ -88,7 +69,29 @@ describe('DeviceListener', () => { mockPlatformPeg({ getAppVersion: jest.fn().mockResolvedValue('1.2.3'), }); - mockClient = new MockClient(); + mockClient = getMockClientWithEventEmitter({ + isGuest: jest.fn(), + getUserId: jest.fn().mockReturnValue(userId), + getKeyBackupVersion: jest.fn().mockResolvedValue(undefined), + getRooms: jest.fn().mockReturnValue([]), + doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(true), + isCrossSigningReady: jest.fn().mockResolvedValue(true), + isSecretStorageReady: jest.fn().mockResolvedValue(true), + isCryptoEnabled: jest.fn().mockReturnValue(true), + isInitialSyncComplete: jest.fn().mockReturnValue(true), + getKeyBackupEnabled: jest.fn(), + getStoredDevicesForUser: jest.fn().mockReturnValue([]), + getCrossSigningId: jest.fn(), + getStoredCrossSigningForUser: jest.fn(), + waitForClientWellKnown: jest.fn(), + downloadKeys: jest.fn(), + isRoomEncrypted: jest.fn(), + getClientWellKnown: jest.fn(), + getDeviceId: jest.fn().mockReturnValue(deviceId), + setAccountData: jest.fn(), + getAccountData: jest.fn(), + checkDeviceTrust: jest.fn().mockReturnValue(new DeviceTrustLevel(false, false, false, false)), + }); jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient); jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false); }); @@ -124,7 +127,7 @@ describe('DeviceListener', () => { it('saves client information on start', async () => { await createAndStart(); - expect(mockClient.setAccountData).toHaveBeenCalledWith( + expect(mockClient!.setAccountData).toHaveBeenCalledWith( `io.element.matrix_client_information.${deviceId}`, { name: 'Element', url: 'localhost', version: '1.2.3' }, ); @@ -133,7 +136,7 @@ describe('DeviceListener', () => { it('catches error and logs when saving client information fails', async () => { const errorLogSpy = jest.spyOn(logger, 'error'); const error = new Error('oups'); - mockClient.setAccountData.mockRejectedValue(error); + mockClient!.setAccountData.mockRejectedValue(error); // doesn't throw await createAndStart(); @@ -147,14 +150,14 @@ describe('DeviceListener', () => { it('saves client information on logged in action', async () => { const instance = await createAndStart(); - mockClient.setAccountData.mockClear(); + mockClient!.setAccountData.mockClear(); // @ts-ignore calling private function instance.onAction({ action: Action.OnLoggedIn }); await flushPromises(); - expect(mockClient.setAccountData).toHaveBeenCalledWith( + expect(mockClient!.setAccountData).toHaveBeenCalledWith( `io.element.matrix_client_information.${deviceId}`, { name: 'Element', url: 'localhost', version: '1.2.3' }, ); @@ -169,30 +172,30 @@ describe('DeviceListener', () => { beforeEach(() => { jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false); - mockClient.getAccountData.mockReturnValue(undefined); + mockClient!.getAccountData.mockReturnValue(undefined); }); it('does not save client information on start', async () => { await createAndStart(); - expect(mockClient.setAccountData).not.toHaveBeenCalled(); + expect(mockClient!.setAccountData).not.toHaveBeenCalled(); }); it('removes client information on start if it exists', async () => { - mockClient.getAccountData.mockReturnValue(clientInfoEvent); + mockClient!.getAccountData.mockReturnValue(clientInfoEvent); await createAndStart(); - expect(mockClient.setAccountData).toHaveBeenCalledWith( + expect(mockClient!.setAccountData).toHaveBeenCalledWith( `io.element.matrix_client_information.${deviceId}`, {}, ); }); it('does not try to remove client info event that are already empty', async () => { - mockClient.getAccountData.mockReturnValue(emptyClientInfoEvent); + mockClient!.getAccountData.mockReturnValue(emptyClientInfoEvent); await createAndStart(); - expect(mockClient.setAccountData).not.toHaveBeenCalled(); + expect(mockClient!.setAccountData).not.toHaveBeenCalled(); }); it('does not save client information on logged in action', async () => { @@ -203,7 +206,7 @@ describe('DeviceListener', () => { await flushPromises(); - expect(mockClient.setAccountData).not.toHaveBeenCalled(); + expect(mockClient!.setAccountData).not.toHaveBeenCalled(); }); it('saves client information after setting is enabled', async () => { @@ -218,7 +221,7 @@ describe('DeviceListener', () => { await flushPromises(); - expect(mockClient.setAccountData).toHaveBeenCalledWith( + expect(mockClient!.setAccountData).toHaveBeenCalledWith( `io.element.matrix_client_information.${deviceId}`, { name: 'Element', url: 'localhost', version: '1.2.3' }, ); @@ -228,22 +231,22 @@ describe('DeviceListener', () => { describe('recheck', () => { it('does nothing when cross signing feature is not supported', async () => { - mockClient.doesServerSupportUnstableFeature.mockResolvedValue(false); + mockClient!.doesServerSupportUnstableFeature.mockResolvedValue(false); await createAndStart(); - expect(mockClient.isCrossSigningReady).not.toHaveBeenCalled(); + expect(mockClient!.isCrossSigningReady).not.toHaveBeenCalled(); }); it('does nothing when crypto is not enabled', async () => { - mockClient.isCryptoEnabled.mockReturnValue(false); + mockClient!.isCryptoEnabled.mockReturnValue(false); await createAndStart(); - expect(mockClient.isCrossSigningReady).not.toHaveBeenCalled(); + expect(mockClient!.isCrossSigningReady).not.toHaveBeenCalled(); }); it('does nothing when initial sync is not complete', async () => { - mockClient.isInitialSyncComplete.mockReturnValue(false); + mockClient!.isInitialSyncComplete.mockReturnValue(false); await createAndStart(); - expect(mockClient.isCrossSigningReady).not.toHaveBeenCalled(); + expect(mockClient!.isCrossSigningReady).not.toHaveBeenCalled(); }); describe('set up encryption', () => { @@ -253,15 +256,15 @@ describe('DeviceListener', () => { ] as unknown as Room[]; beforeEach(() => { - mockClient.isCrossSigningReady.mockResolvedValue(false); - mockClient.isSecretStorageReady.mockResolvedValue(false); - mockClient.getRooms.mockReturnValue(rooms); - mockClient.isRoomEncrypted.mockReturnValue(true); + mockClient!.isCrossSigningReady.mockResolvedValue(false); + mockClient!.isSecretStorageReady.mockResolvedValue(false); + mockClient!.getRooms.mockReturnValue(rooms); + mockClient!.isRoomEncrypted.mockReturnValue(true); }); it('hides setup encryption toast when cross signing and secret storage are ready', async () => { - mockClient.isCrossSigningReady.mockResolvedValue(true); - mockClient.isSecretStorageReady.mockResolvedValue(true); + mockClient!.isCrossSigningReady.mockResolvedValue(true); + mockClient!.isSecretStorageReady.mockResolvedValue(true); await createAndStart(); expect(SetupEncryptionToast.hideToast).toHaveBeenCalled(); }); @@ -277,49 +280,49 @@ describe('DeviceListener', () => { mocked(isSecretStorageBeingAccessed).mockReturnValue(true); await createAndStart(); - expect(mockClient.downloadKeys).not.toHaveBeenCalled(); + expect(mockClient!.downloadKeys).not.toHaveBeenCalled(); expect(SetupEncryptionToast.showToast).not.toHaveBeenCalled(); }); it('does not do any checks or show any toasts when no rooms are encrypted', async () => { - mockClient.isRoomEncrypted.mockReturnValue(false); + mockClient!.isRoomEncrypted.mockReturnValue(false); await createAndStart(); - expect(mockClient.downloadKeys).not.toHaveBeenCalled(); + expect(mockClient!.downloadKeys).not.toHaveBeenCalled(); expect(SetupEncryptionToast.showToast).not.toHaveBeenCalled(); }); describe('when user does not have a cross signing id on this device', () => { beforeEach(() => { - mockClient.getCrossSigningId.mockReturnValue(undefined); + mockClient!.getCrossSigningId.mockReturnValue(null); }); it('shows verify session toast when account has cross signing', async () => { - mockClient.getStoredCrossSigningForUser.mockReturnValue(true); + mockClient!.getStoredCrossSigningForUser.mockReturnValue(new CrossSigningInfo(userId)); await createAndStart(); - expect(mockClient.downloadKeys).toHaveBeenCalled(); + expect(mockClient!.downloadKeys).toHaveBeenCalled(); expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( SetupEncryptionToast.Kind.VERIFY_THIS_SESSION); }); it('checks key backup status when when account has cross signing', async () => { - mockClient.getCrossSigningId.mockReturnValue(undefined); - mockClient.getStoredCrossSigningForUser.mockReturnValue(true); + mockClient!.getCrossSigningId.mockReturnValue(null); + mockClient!.getStoredCrossSigningForUser.mockReturnValue(new CrossSigningInfo(userId)); await createAndStart(); - expect(mockClient.getKeyBackupEnabled).toHaveBeenCalled(); + expect(mockClient!.getKeyBackupEnabled).toHaveBeenCalled(); }); }); describe('when user does have a cross signing id on this device', () => { beforeEach(() => { - mockClient.getCrossSigningId.mockReturnValue('abc'); + mockClient!.getCrossSigningId.mockReturnValue('abc'); }); it('shows upgrade encryption toast when user has a key backup available', async () => { // non falsy response - mockClient.getKeyBackupVersion.mockResolvedValue({}); + mockClient!.getKeyBackupVersion.mockResolvedValue({} as unknown as IKeyBackupInfo); await createAndStart(); expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( @@ -332,51 +335,167 @@ describe('DeviceListener', () => { it('checks keybackup status when cross signing and secret storage are ready', async () => { // default mocks set cross signing and secret storage to ready await createAndStart(); - expect(mockClient.getKeyBackupEnabled).toHaveBeenCalled(); + expect(mockClient!.getKeyBackupEnabled).toHaveBeenCalled(); expect(mockDispatcher.dispatch).not.toHaveBeenCalled(); }); it('checks keybackup status when setup encryption toast has been dismissed', async () => { - mockClient.isCrossSigningReady.mockResolvedValue(false); + mockClient!.isCrossSigningReady.mockResolvedValue(false); const instance = await createAndStart(); instance.dismissEncryptionSetup(); await flushPromises(); - expect(mockClient.getKeyBackupEnabled).toHaveBeenCalled(); + expect(mockClient!.getKeyBackupEnabled).toHaveBeenCalled(); }); it('does not dispatch keybackup event when key backup check is not finished', async () => { // returns null when key backup status hasn't finished being checked - mockClient.getKeyBackupEnabled.mockReturnValue(null); + mockClient!.getKeyBackupEnabled.mockReturnValue(null); await createAndStart(); expect(mockDispatcher.dispatch).not.toHaveBeenCalled(); }); it('dispatches keybackup event when key backup is not enabled', async () => { - mockClient.getKeyBackupEnabled.mockReturnValue(false); + mockClient!.getKeyBackupEnabled.mockReturnValue(false); await createAndStart(); expect(mockDispatcher.dispatch).toHaveBeenCalledWith({ action: Action.ReportKeyBackupNotEnabled }); }); it('does not check key backup status again after check is complete', async () => { - mockClient.getKeyBackupEnabled.mockReturnValue(null); + mockClient!.getKeyBackupEnabled.mockReturnValue(null); const instance = await createAndStart(); - expect(mockClient.getKeyBackupEnabled).toHaveBeenCalled(); + expect(mockClient!.getKeyBackupEnabled).toHaveBeenCalled(); // keyback check now complete - mockClient.getKeyBackupEnabled.mockReturnValue(true); + mockClient!.getKeyBackupEnabled.mockReturnValue(true); // trigger a recheck instance.dismissEncryptionSetup(); await flushPromises(); - expect(mockClient.getKeyBackupEnabled).toHaveBeenCalledTimes(2); + expect(mockClient!.getKeyBackupEnabled).toHaveBeenCalledTimes(2); // trigger another recheck instance.dismissEncryptionSetup(); await flushPromises(); // not called again, check was complete last time - expect(mockClient.getKeyBackupEnabled).toHaveBeenCalledTimes(2); + expect(mockClient!.getKeyBackupEnabled).toHaveBeenCalledTimes(2); + }); + }); + + describe('unverified sessions toasts', () => { + const currentDevice = new DeviceInfo(deviceId); + const device2 = new DeviceInfo('d2'); + const device3 = new DeviceInfo('d3'); + + const deviceTrustVerified = new DeviceTrustLevel(true, false, false, false); + const deviceTrustUnverified = new DeviceTrustLevel(false, false, false, false); + + beforeEach(() => { + mockClient!.isCrossSigningReady.mockResolvedValue(true); + mockClient!.getStoredDevicesForUser.mockReturnValue([ + currentDevice, device2, device3, + ]); + // all devices verified by default + mockClient!.checkDeviceTrust.mockReturnValue(deviceTrustVerified); + mockClient!.deviceId = currentDevice.deviceId; + }); + describe('bulk unverified sessions toasts', () => { + it('hides toast when cross signing is not ready', async () => { + mockClient!.isCrossSigningReady.mockResolvedValue(false); + await createAndStart(); + expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalled(); + expect(BulkUnverifiedSessionsToast.showToast).not.toHaveBeenCalled(); + }); + + it('hides toast when all devices at app start are verified', async () => { + await createAndStart(); + expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalled(); + expect(BulkUnverifiedSessionsToast.showToast).not.toHaveBeenCalled(); + }); + + it('hides toast when only unverified device is the current device', async () => { + mockClient!.getStoredDevicesForUser.mockReturnValue([ + currentDevice, + ]); + mockClient!.checkDeviceTrust.mockReturnValue(deviceTrustUnverified); + await createAndStart(); + expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalled(); + expect(BulkUnverifiedSessionsToast.showToast).not.toHaveBeenCalled(); + }); + + it('shows toast with unverified devices at app start', async () => { + // currentDevice, device2 are verified, device3 is unverified + mockClient!.checkDeviceTrust.mockImplementation((_userId, deviceId) => { + switch (deviceId) { + case currentDevice.deviceId: + case device2.deviceId: + return deviceTrustVerified; + default: + return deviceTrustUnverified; + } + }); + await createAndStart(); + expect(BulkUnverifiedSessionsToast.showToast).toHaveBeenCalledWith( + new Set([device3.deviceId]), + ); + expect(BulkUnverifiedSessionsToast.hideToast).not.toHaveBeenCalled(); + }); + + it('hides toast when unverified sessions at app start have been dismissed', async () => { + // currentDevice, device2 are verified, device3 is unverified + mockClient!.checkDeviceTrust.mockImplementation((_userId, deviceId) => { + switch (deviceId) { + case currentDevice.deviceId: + case device2.deviceId: + return deviceTrustVerified; + default: + return deviceTrustUnverified; + } + }); + const instance = await createAndStart(); + expect(BulkUnverifiedSessionsToast.showToast).toHaveBeenCalledWith( + new Set([device3.deviceId]), + ); + + await instance.dismissUnverifiedSessions([device3.deviceId]); + await flushPromises(); + + expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalled(); + }); + + it('hides toast when unverified sessions are added after app start', async () => { + // currentDevice, device2 are verified, device3 is unverified + mockClient!.checkDeviceTrust.mockImplementation((_userId, deviceId) => { + switch (deviceId) { + case currentDevice.deviceId: + case device2.deviceId: + return deviceTrustVerified; + default: + return deviceTrustUnverified; + } + }); + mockClient!.getStoredDevicesForUser.mockReturnValue([ + currentDevice, device2, + ]); + await createAndStart(); + + expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalled(); + + // add an unverified device + mockClient!.getStoredDevicesForUser.mockReturnValue([ + currentDevice, device2, device3, + ]); + // trigger a recheck + mockClient!.emit(CryptoEvent.DevicesUpdated, [userId], false); + await flushPromises(); + + // bulk unverified sessions toast only shown for devices that were + // there at app start + // individual nags are shown for new unverified devices + expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalledTimes(2); + expect(BulkUnverifiedSessionsToast.showToast).not.toHaveBeenCalled(); + }); }); }); });