From f05cc5d8f3ac37dc19ba8d3aecca914a0ce785c0 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 2 Nov 2022 11:51:20 +0100 Subject: [PATCH] Refactor + improve test coverage for QR login (#9525) --- src/components/views/auth/LoginWithQR.tsx | 242 +---- src/components/views/auth/LoginWithQRFlow.tsx | 227 +++++ .../settings/devices/LoginWithQRSection.tsx | 2 +- .../components/views/elements/QRCode-test.tsx | 36 + .../__snapshots__/QRCode-test.tsx.snap | 29 + .../settings/devices/LoginWithQR-test.tsx | 399 ++++---- .../settings/devices/LoginWithQRFlow-test.tsx | 116 +++ .../devices/LoginWithQRSection-test.tsx | 2 +- .../__snapshots__/LoginWithQR-test.tsx.snap | 377 ------- .../LoginWithQRFlow-test.tsx.snap | 948 ++++++++++++++++++ 10 files changed, 1604 insertions(+), 774 deletions(-) create mode 100644 src/components/views/auth/LoginWithQRFlow.tsx create mode 100644 test/components/views/elements/QRCode-test.tsx create mode 100644 test/components/views/elements/__snapshots__/QRCode-test.tsx.snap create mode 100644 test/components/views/settings/devices/LoginWithQRFlow-test.tsx delete mode 100644 test/components/views/settings/devices/__snapshots__/LoginWithQR-test.tsx.snap create mode 100644 test/components/views/settings/devices/__snapshots__/LoginWithQRFlow-test.tsx.snap diff --git a/src/components/views/auth/LoginWithQR.tsx b/src/components/views/auth/LoginWithQR.tsx index 3d3f76be95..4283b61b22 100644 --- a/src/components/views/auth/LoginWithQR.tsx +++ b/src/components/views/auth/LoginWithQR.tsx @@ -22,14 +22,8 @@ import { logger } from 'matrix-js-sdk/src/logger'; import { MatrixClient } from 'matrix-js-sdk/src/client'; import { _t } from "../../../languageHandler"; -import AccessibleButton from '../elements/AccessibleButton'; -import QRCode from '../elements/QRCode'; -import Spinner from '../elements/Spinner'; -import { Icon as BackButtonIcon } from "../../../../res/img/element-icons/back.svg"; -import { Icon as DevicesIcon } from "../../../../res/img/element-icons/devices.svg"; -import { Icon as WarningBadge } from "../../../../res/img/element-icons/warning-badge.svg"; -import { Icon as InfoIcon } from "../../../../res/img/element-icons/i.svg"; import { wrapRequestWithDialog } from '../../../utils/UserInteractiveAuth'; +import LoginWithQRFlow from './LoginWithQRFlow'; /** * The intention of this enum is to have a mode that scans a QR code instead of generating one. @@ -41,7 +35,7 @@ export enum Mode { Show = "show", } -enum Phase { +export enum Phase { Loading, ShowingQR, Connecting, @@ -51,6 +45,14 @@ enum Phase { Error, } +export enum Click { + Cancel, + Decline, + Approve, + TryAgain, + Back, +} + interface IProps { client: MatrixClient; mode: Mode; @@ -68,7 +70,7 @@ interface IState { /** * A component that allows sign in and E2EE set up with a QR code. * - * It implements both `login.start` and `login-reciprocate` capabilities as well as both scanning and showing QR codes. + * It implements `login.reciprocate` capabilities and showing QR codes. * * This uses the unstable feature of MSC3906: https://github.com/matrix-org/matrix-spec-proposals/pull/3906 */ @@ -138,6 +140,7 @@ export default class LoginWithQR extends React.Component { this.props.onFinished(true); return; } + this.setState({ phase: Phase.Verifying }); await this.state.rendezvous.verifyNewDeviceOnExistingDevice(); this.props.onFinished(true); } catch (e) { @@ -197,200 +200,41 @@ export default class LoginWithQR extends React.Component { }); } - private cancelClicked = async (e: React.FormEvent) => { - e.preventDefault(); - await this.state.rendezvous?.cancel(RendezvousFailureReason.UserCancelled); - this.reset(); - this.props.onFinished(false); - }; - - private declineClicked = async (e: React.FormEvent) => { - e.preventDefault(); - await this.state.rendezvous?.declineLoginOnExistingDevice(); - this.reset(); - this.props.onFinished(false); - }; - - private tryAgainClicked = async (e: React.FormEvent) => { - e.preventDefault(); - this.reset(); - await this.updateMode(this.props.mode); - }; - - private onBackClick = async () => { - await this.state.rendezvous?.cancel(RendezvousFailureReason.UserCancelled); - - this.props.onFinished(false); - }; - - private cancelButton = () => - { _t("Cancel") } - ; - - private simpleSpinner = (description?: string): JSX.Element => { - return
-
- - { description &&

{ description }

} -
-
; + private onClick = async (type: Click) => { + switch (type) { + case Click.Cancel: + await this.state.rendezvous?.cancel(RendezvousFailureReason.UserCancelled); + this.reset(); + this.props.onFinished(false); + break; + case Click.Approve: + await this.approveLogin(); + break; + case Click.Decline: + await this.state.rendezvous?.declineLoginOnExistingDevice(); + this.reset(); + this.props.onFinished(false); + break; + case Click.TryAgain: + this.reset(); + await this.updateMode(this.props.mode); + break; + case Click.Back: + await this.state.rendezvous?.cancel(RendezvousFailureReason.UserCancelled); + this.props.onFinished(false); + break; + } }; public render() { - let title: string; - let titleIcon: JSX.Element | undefined; - let main: JSX.Element | undefined; - let buttons: JSX.Element | undefined; - let backButton = true; - let cancellationMessage: string | undefined; - let centreTitle = false; - - switch (this.state.phase) { - case Phase.Error: - switch (this.state.failureReason) { - case RendezvousFailureReason.Expired: - cancellationMessage = _t("The linking wasn't completed in the required time."); - break; - case RendezvousFailureReason.InvalidCode: - cancellationMessage = _t("The scanned code is invalid."); - break; - case RendezvousFailureReason.UnsupportedAlgorithm: - cancellationMessage = _t("Linking with this device is not supported."); - break; - case RendezvousFailureReason.UserDeclined: - cancellationMessage = _t("The request was declined on the other device."); - break; - case RendezvousFailureReason.OtherDeviceAlreadySignedIn: - cancellationMessage = _t("The other device is already signed in."); - break; - case RendezvousFailureReason.OtherDeviceNotSignedIn: - cancellationMessage = _t("The other device isn't signed in."); - break; - case RendezvousFailureReason.UserCancelled: - cancellationMessage = _t("The request was cancelled."); - break; - case RendezvousFailureReason.Unknown: - cancellationMessage = _t("An unexpected error occurred."); - break; - case RendezvousFailureReason.HomeserverLacksSupport: - cancellationMessage = _t("The homeserver doesn't support signing in another device."); - break; - default: - cancellationMessage = _t("The request was cancelled."); - break; - } - title = _t("Connection failed"); - centreTitle = true; - titleIcon = ; - backButton = false; - main =

{ cancellationMessage }

; - buttons = <> - - { _t("Try again") } - - { this.cancelButton() } - ; - break; - case Phase.Connected: - title = _t("Devices connected"); - titleIcon = ; - backButton = false; - main = <> -

{ _t("Check that the code below matches with your other device:") }

-
- { this.state.confirmationDigits } -
-
-
- -
-
{ _t("By approving access for this device, it will have full access to your account.") }
-
- ; - - buttons = <> - - { _t("Cancel") } - - - { _t("Approve") } - - ; - break; - case Phase.ShowingQR: - title =_t("Sign in with QR code"); - if (this.state.rendezvous) { - const code =
- -
; - main = <> -

{ _t("Scan the QR code below with your device that's signed out.") }

-
    -
  1. { _t("Start at the sign in screen") }
  2. -
  3. { _t("Select 'Scan QR code'") }
  4. -
  5. { _t("Review and approve the sign in") }
  6. -
- { code } - ; - } else { - main = this.simpleSpinner(); - buttons = this.cancelButton(); - } - break; - case Phase.Loading: - main = this.simpleSpinner(); - break; - case Phase.Connecting: - main = this.simpleSpinner(_t("Connecting...")); - buttons = this.cancelButton(); - break; - case Phase.WaitingForDevice: - main = this.simpleSpinner(_t("Waiting for device to sign in")); - buttons = this.cancelButton(); - break; - case Phase.Verifying: - title = _t("Success"); - centreTitle = true; - main = this.simpleSpinner(_t("Completing set up of your new device")); - break; - } - return ( -
-
- { backButton ? - - - - : null } -

{ titleIcon }{ title }

-
-
- { main } -
-
- { buttons } -
-
+ ); } } diff --git a/src/components/views/auth/LoginWithQRFlow.tsx b/src/components/views/auth/LoginWithQRFlow.tsx new file mode 100644 index 0000000000..a63184cc1e --- /dev/null +++ b/src/components/views/auth/LoginWithQRFlow.tsx @@ -0,0 +1,227 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import { RendezvousFailureReason } from 'matrix-js-sdk/src/rendezvous'; + +import { _t } from "../../../languageHandler"; +import AccessibleButton from '../elements/AccessibleButton'; +import QRCode from '../elements/QRCode'; +import Spinner from '../elements/Spinner'; +import { Icon as BackButtonIcon } from "../../../../res/img/element-icons/back.svg"; +import { Icon as DevicesIcon } from "../../../../res/img/element-icons/devices.svg"; +import { Icon as WarningBadge } from "../../../../res/img/element-icons/warning-badge.svg"; +import { Icon as InfoIcon } from "../../../../res/img/element-icons/i.svg"; +import { Click, Phase } from './LoginWithQR'; + +interface IProps { + phase: Phase; + code?: string; + onClick(type: Click): Promise; + failureReason?: RendezvousFailureReason; + confirmationDigits?: string; +} + +/** + * A component that implements the UI for sign in and E2EE set up with a QR code. + * + * This uses the unstable feature of MSC3906: https://github.com/matrix-org/matrix-spec-proposals/pull/3906 + */ +export default class LoginWithQRFlow extends React.Component { + public constructor(props) { + super(props); + } + + private handleClick = (type: Click) => { + return async (e: React.FormEvent) => { + e.preventDefault(); + await this.props.onClick(type); + }; + }; + + private cancelButton = () => + { _t("Cancel") } + ; + + private simpleSpinner = (description?: string): JSX.Element => { + return
+
+ + { description &&

{ description }

} +
+
; + }; + + public render() { + let title = ''; + let titleIcon: JSX.Element | undefined; + let main: JSX.Element | undefined; + let buttons: JSX.Element | undefined; + let backButton = true; + let cancellationMessage: string | undefined; + let centreTitle = false; + + switch (this.props.phase) { + case Phase.Error: + switch (this.props.failureReason) { + case RendezvousFailureReason.Expired: + cancellationMessage = _t("The linking wasn't completed in the required time."); + break; + case RendezvousFailureReason.InvalidCode: + cancellationMessage = _t("The scanned code is invalid."); + break; + case RendezvousFailureReason.UnsupportedAlgorithm: + cancellationMessage = _t("Linking with this device is not supported."); + break; + case RendezvousFailureReason.UserDeclined: + cancellationMessage = _t("The request was declined on the other device."); + break; + case RendezvousFailureReason.OtherDeviceAlreadySignedIn: + cancellationMessage = _t("The other device is already signed in."); + break; + case RendezvousFailureReason.OtherDeviceNotSignedIn: + cancellationMessage = _t("The other device isn't signed in."); + break; + case RendezvousFailureReason.UserCancelled: + cancellationMessage = _t("The request was cancelled."); + break; + case RendezvousFailureReason.Unknown: + cancellationMessage = _t("An unexpected error occurred."); + break; + case RendezvousFailureReason.HomeserverLacksSupport: + cancellationMessage = _t("The homeserver doesn't support signing in another device."); + break; + default: + cancellationMessage = _t("The request was cancelled."); + break; + } + title = _t("Connection failed"); + centreTitle = true; + titleIcon = ; + backButton = false; + main =

{ cancellationMessage }

; + buttons = <> + + { _t("Try again") } + + { this.cancelButton() } + ; + break; + case Phase.Connected: + title = _t("Devices connected"); + titleIcon = ; + backButton = false; + main = <> +

{ _t("Check that the code below matches with your other device:") }

+
+ { this.props.confirmationDigits } +
+
+
+ +
+
{ _t("By approving access for this device, it will have full access to your account.") }
+
+ ; + + buttons = <> + + { _t("Cancel") } + + + { _t("Approve") } + + ; + break; + case Phase.ShowingQR: + title =_t("Sign in with QR code"); + if (this.props.code) { + const code =
+ +
; + main = <> +

{ _t("Scan the QR code below with your device that's signed out.") }

+
    +
  1. { _t("Start at the sign in screen") }
  2. +
  3. { _t("Select 'Scan QR code'") }
  4. +
  5. { _t("Review and approve the sign in") }
  6. +
+ { code } + ; + } else { + main = this.simpleSpinner(); + buttons = this.cancelButton(); + } + break; + case Phase.Loading: + main = this.simpleSpinner(); + break; + case Phase.Connecting: + main = this.simpleSpinner(_t("Connecting...")); + buttons = this.cancelButton(); + break; + case Phase.WaitingForDevice: + main = this.simpleSpinner(_t("Waiting for device to sign in")); + buttons = this.cancelButton(); + break; + case Phase.Verifying: + title = _t("Success"); + centreTitle = true; + main = this.simpleSpinner(_t("Completing set up of your new device")); + break; + } + + return ( +
+
+ { backButton ? + + + + : null } +

{ titleIcon }{ title }

+
+
+ { main } +
+
+ { buttons } +
+
+ ); + } +} diff --git a/src/components/views/settings/devices/LoginWithQRSection.tsx b/src/components/views/settings/devices/LoginWithQRSection.tsx index 20cdb37902..46fe78f785 100644 --- a/src/components/views/settings/devices/LoginWithQRSection.tsx +++ b/src/components/views/settings/devices/LoginWithQRSection.tsx @@ -32,7 +32,7 @@ export default class LoginWithQRSection extends React.Component { super(props); } - public render(): JSX.Element { + public render(): JSX.Element | null { const msc3882Supported = !!this.props.versions?.unstable_features?.['org.matrix.msc3882']; const msc3886Supported = !!this.props.versions?.unstable_features?.['org.matrix.msc3886']; diff --git a/test/components/views/elements/QRCode-test.tsx b/test/components/views/elements/QRCode-test.tsx new file mode 100644 index 0000000000..dbd240aa3d --- /dev/null +++ b/test/components/views/elements/QRCode-test.tsx @@ -0,0 +1,36 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { render, waitFor, cleanup } from "@testing-library/react"; +import React from "react"; + +import QRCode from "../../../../src/components/views/elements/QRCode"; + +describe("", () => { + afterEach(() => { + cleanup(); + }); + + it("renders a QR with defaults", async () => { + const { container, getAllByAltText } = render(); + await waitFor(() => getAllByAltText('QR Code').length === 1); + expect(container).toMatchSnapshot(); + }); + + it("renders a QR with high error correction level", async () => { + const { container, getAllByAltText } = render(); + await waitFor(() => getAllByAltText('QR Code').length === 1); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/test/components/views/elements/__snapshots__/QRCode-test.tsx.snap b/test/components/views/elements/__snapshots__/QRCode-test.tsx.snap new file mode 100644 index 0000000000..eb82ed1b0d --- /dev/null +++ b/test/components/views/elements/__snapshots__/QRCode-test.tsx.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders a QR with defaults 1`] = ` +
+
+ QR Code +
+
+`; + +exports[` renders a QR with high error correction level 1`] = ` +
+
+ QR Code +
+
+`; diff --git a/test/components/views/settings/devices/LoginWithQR-test.tsx b/test/components/views/settings/devices/LoginWithQR-test.tsx index c106b2f9a8..a3871e34c4 100644 --- a/test/components/views/settings/devices/LoginWithQR-test.tsx +++ b/test/components/views/settings/devices/LoginWithQR-test.tsx @@ -14,22 +14,25 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { cleanup, render, waitFor } from '@testing-library/react'; import { mocked } from 'jest-mock'; import React from 'react'; -import { MSC3886SimpleHttpRendezvousTransport } from 'matrix-js-sdk/src/rendezvous/transports'; import { MSC3906Rendezvous, RendezvousFailureReason } from 'matrix-js-sdk/src/rendezvous'; -import LoginWithQR, { Mode } from '../../../../../src/components/views/auth/LoginWithQR'; +import LoginWithQR, { Click, Mode, Phase } from '../../../../../src/components/views/auth/LoginWithQR'; import type { MatrixClient } from 'matrix-js-sdk/src/matrix'; -import { flushPromisesWithFakeTimers } from '../../../../test-utils'; - -jest.useFakeTimers(); jest.mock('matrix-js-sdk/src/rendezvous'); jest.mock('matrix-js-sdk/src/rendezvous/transports'); jest.mock('matrix-js-sdk/src/rendezvous/channels'); +const mockedFlow = jest.fn(); + +jest.mock('../../../../../src/components/views/auth/LoginWithQRFlow', () => (props) => { + mockedFlow(props); + return
; +}); + function makeClient() { return mocked({ getUser: jest.fn(), @@ -50,248 +53,252 @@ function makeClient() { } as unknown as MatrixClient); } +function unresolvedPromise(): Promise { + return new Promise(() => {}); +} + describe('', () => { - const client = makeClient(); + let client = makeClient(); const defaultProps = { mode: Mode.Show, onFinished: jest.fn(), }; const mockConfirmationDigits = 'mock-confirmation-digits'; + const mockRendezvousCode = 'mock-rendezvous-code'; const newDeviceId = 'new-device-id'; const getComponent = (props: { client: MatrixClient, onFinished?: () => void }) => - (); + (); beforeEach(() => { - jest.clearAllMocks(); - jest.spyOn(MSC3906Rendezvous.prototype, 'generateCode').mockRestore(); + mockedFlow.mockReset(); + jest.resetAllMocks(); + jest.spyOn(MSC3906Rendezvous.prototype, 'generateCode').mockResolvedValue(); + // @ts-ignore + // workaround for https://github.com/facebook/jest/issues/9675 + MSC3906Rendezvous.prototype.code = mockRendezvousCode; jest.spyOn(MSC3906Rendezvous.prototype, 'cancel').mockResolvedValue(); - jest.spyOn(MSC3906Rendezvous.prototype, 'declineLoginOnExistingDevice').mockResolvedValue(); jest.spyOn(MSC3906Rendezvous.prototype, 'startAfterShowingCode').mockResolvedValue(mockConfirmationDigits); + jest.spyOn(MSC3906Rendezvous.prototype, 'declineLoginOnExistingDevice').mockResolvedValue(); jest.spyOn(MSC3906Rendezvous.prototype, 'approveLoginOnExistingDevice').mockResolvedValue(newDeviceId); + jest.spyOn(MSC3906Rendezvous.prototype, 'verifyNewDeviceOnExistingDevice').mockResolvedValue(undefined); client.requestLoginToken.mockResolvedValue({ login_token: 'token', expires_in: 1000, }); - // @ts-ignore - client.crypto = undefined; }); - it('no content in case of no support', async () => { + afterEach(() => { + client = makeClient(); + jest.clearAllMocks(); + jest.useRealTimers(); + cleanup(); + }); + + test('no homeserver support', async () => { // simulate no support jest.spyOn(MSC3906Rendezvous.prototype, 'generateCode').mockRejectedValue(''); - const { container } = render(getComponent({ client })); - await waitFor(() => screen.getAllByTestId('cancellation-message').length === 1); - expect(container).toMatchSnapshot(); - }); - - it('renders spinner while generating code', async () => { - const { container } = render(getComponent({ client })); - expect(container).toMatchSnapshot(); - }); - - it('cancels rendezvous after user goes back', async () => { - const { getByTestId } = render(getComponent({ client })); + render(getComponent({ client })); + await waitFor(() => + expect(mockedFlow).toHaveBeenLastCalledWith({ + phase: Phase.Error, + failureReason: RendezvousFailureReason.HomeserverLacksSupport, + onClick: expect.any(Function), + }), + ); const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; - // @ts-ignore assign to private prop - rendezvous.code = 'rendezvous-code'; + expect(rendezvous.generateCode).toHaveBeenCalled(); + }); - // flush generate code promise - await flushPromisesWithFakeTimers(); + test('failed to connect', async () => { + jest.spyOn(MSC3906Rendezvous.prototype, 'startAfterShowingCode').mockRejectedValue(''); + render(getComponent({ client })); + await waitFor(() => + expect(mockedFlow).toHaveBeenLastCalledWith({ + phase: Phase.Error, + failureReason: RendezvousFailureReason.Unknown, + onClick: expect.any(Function), + }), + ); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + expect(rendezvous.generateCode).toHaveBeenCalled(); + expect(rendezvous.startAfterShowingCode).toHaveBeenCalled(); + }); - fireEvent.click(getByTestId('back-button')); + test('render QR then cancel and try again', async () => { + const onFinished = jest.fn(); + jest.spyOn(MSC3906Rendezvous.prototype, 'startAfterShowingCode').mockImplementation(() => unresolvedPromise()); + render(getComponent({ client, onFinished })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; - // wait for cancel - await flushPromisesWithFakeTimers(); + await waitFor(() => expect(mockedFlow).toHaveBeenLastCalledWith(expect.objectContaining({ + phase: Phase.ShowingQR, + }))); + // display QR code + expect(mockedFlow).toHaveBeenLastCalledWith({ + phase: Phase.ShowingQR, + code: mockRendezvousCode, + onClick: expect.any(Function), + }); + expect(rendezvous.generateCode).toHaveBeenCalled(); + expect(rendezvous.startAfterShowingCode).toHaveBeenCalled(); + // cancel + const onClick = mockedFlow.mock.calls[0][0].onClick; + await onClick(Click.Cancel); + expect(onFinished).toHaveBeenCalledWith(false); + expect(rendezvous.cancel).toHaveBeenCalledWith(RendezvousFailureReason.UserCancelled); + + // try again + onClick(Click.TryAgain); + await waitFor(() => expect(mockedFlow).toHaveBeenLastCalledWith(expect.objectContaining({ + phase: Phase.ShowingQR, + }))); + // display QR code + expect(mockedFlow).toHaveBeenLastCalledWith({ + phase: Phase.ShowingQR, + code: mockRendezvousCode, + onClick: expect.any(Function), + }); + }); + + test('render QR then back', async () => { + const onFinished = jest.fn(); + jest.spyOn(MSC3906Rendezvous.prototype, 'startAfterShowingCode').mockReturnValue(unresolvedPromise()); + render(getComponent({ client, onFinished })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + + await waitFor(() => expect(mockedFlow).toHaveBeenLastCalledWith(expect.objectContaining({ + phase: Phase.ShowingQR, + }))); + // display QR code + expect(mockedFlow).toHaveBeenLastCalledWith({ + phase: Phase.ShowingQR, + code: mockRendezvousCode, + onClick: expect.any(Function), + }); + expect(rendezvous.generateCode).toHaveBeenCalled(); + expect(rendezvous.startAfterShowingCode).toHaveBeenCalled(); + + // back + const onClick = mockedFlow.mock.calls[0][0].onClick; + await onClick(Click.Back); + expect(onFinished).toHaveBeenCalledWith(false); expect(rendezvous.cancel).toHaveBeenCalledWith(RendezvousFailureReason.UserCancelled); }); - it('displays qr code after it is created', async () => { - const { container, getByText } = render(getComponent({ client })); + test('render QR then decline', async () => { + const onFinished = jest.fn(); + render(getComponent({ client, onFinished })); const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; - // @ts-ignore assign to private prop - rendezvous.code = 'rendezvous-code'; - await flushPromisesWithFakeTimers(); + await waitFor(() => expect(mockedFlow).toHaveBeenLastCalledWith(expect.objectContaining({ + phase: Phase.Connected, + }))); + expect(mockedFlow).toHaveBeenLastCalledWith({ + phase: Phase.Connected, + confirmationDigits: mockConfirmationDigits, + onClick: expect.any(Function), + }); + + // decline + const onClick = mockedFlow.mock.calls[0][0].onClick; + await onClick(Click.Decline); + expect(onFinished).toHaveBeenCalledWith(false); expect(rendezvous.generateCode).toHaveBeenCalled(); - expect(getByText('Sign in with QR code')).toBeTruthy(); - expect(container).toMatchSnapshot(); - }); - - it('displays confirmation digits after connected to rendezvous', async () => { - const { container, getByText } = render(getComponent({ client })); - const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; - // @ts-ignore assign to private prop - rendezvous.code = 'rendezvous-code'; - - // flush generate code promise - await flushPromisesWithFakeTimers(); - // flush waiting for connection promise - await flushPromisesWithFakeTimers(); - - expect(container).toMatchSnapshot(); - expect(getByText(mockConfirmationDigits)).toBeTruthy(); - }); - - it('displays unknown error if connection to rendezvous fails', async () => { - const { container } = render(getComponent({ client })); - expect(MSC3886SimpleHttpRendezvousTransport).toHaveBeenCalledWith({ - onFailure: expect.any(Function), - client, - }); - const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; - // @ts-ignore assign to private prop - rendezvous.code = 'rendezvous-code'; - mocked(rendezvous).startAfterShowingCode.mockRejectedValue('oups'); - - // flush generate code promise - await flushPromisesWithFakeTimers(); - // flush waiting for connection promise - await flushPromisesWithFakeTimers(); - - expect(container).toMatchSnapshot(); - }); - - it('declines login', async () => { - const { getByTestId } = render(getComponent({ client })); - const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; - // @ts-ignore assign to private prop - rendezvous.code = 'rendezvous-code'; - - // flush generate code promise - await flushPromisesWithFakeTimers(); - // flush waiting for connection promise - await flushPromisesWithFakeTimers(); - - fireEvent.click(getByTestId('decline-login-button')); - + expect(rendezvous.startAfterShowingCode).toHaveBeenCalled(); expect(rendezvous.declineLoginOnExistingDevice).toHaveBeenCalled(); }); - it('displays error when approving login fails', async () => { - const { container, getByTestId } = render(getComponent({ client })); - const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; - // @ts-ignore assign to private prop - rendezvous.code = 'rendezvous-code'; - client.requestLoginToken.mockRejectedValue('oups'); - - // flush generate code promise - await flushPromisesWithFakeTimers(); - // flush waiting for connection promise - await flushPromisesWithFakeTimers(); - - fireEvent.click(getByTestId('approve-login-button')); - - expect(client.requestLoginToken).toHaveBeenCalled(); - // flush token request promise - await flushPromisesWithFakeTimers(); - await flushPromisesWithFakeTimers(); - - expect(container).toMatchSnapshot(); - }); - - it('approves login and waits for new device', async () => { - const { container, getByTestId, getByText } = render(getComponent({ client })); - const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; - // @ts-ignore assign to private prop - rendezvous.code = 'rendezvous-code'; - - // flush generate code promise - await flushPromisesWithFakeTimers(); - // flush waiting for connection promise - await flushPromisesWithFakeTimers(); - - fireEvent.click(getByTestId('approve-login-button')); - - expect(client.requestLoginToken).toHaveBeenCalled(); - // flush token request promise - await flushPromisesWithFakeTimers(); - await flushPromisesWithFakeTimers(); - - expect(getByText('Waiting for device to sign in')).toBeTruthy(); - expect(container).toMatchSnapshot(); - }); - - it('does not continue with verification when user denies login', async () => { + test('approve - no crypto', async () => { + // @ts-ignore + client.crypto = undefined; const onFinished = jest.fn(); - const { getByTestId } = render(getComponent({ client, onFinished })); + // jest.spyOn(MSC3906Rendezvous.prototype, 'approveLoginOnExistingDevice').mockReturnValue(unresolvedPromise()); + render(getComponent({ client, onFinished })); const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; - // @ts-ignore assign to private prop - rendezvous.code = 'rendezvous-code'; - // no device id returned => user denied - mocked(rendezvous).approveLoginOnExistingDevice.mockReturnValue(undefined); - // flush generate code promise - await flushPromisesWithFakeTimers(); - // flush waiting for connection promise - await flushPromisesWithFakeTimers(); + await waitFor(() => expect(mockedFlow).toHaveBeenLastCalledWith(expect.objectContaining({ + phase: Phase.Connected, + }))); + expect(mockedFlow).toHaveBeenLastCalledWith({ + phase: Phase.Connected, + confirmationDigits: mockConfirmationDigits, + onClick: expect.any(Function), + }); + expect(rendezvous.generateCode).toHaveBeenCalled(); + expect(rendezvous.startAfterShowingCode).toHaveBeenCalled(); - fireEvent.click(getByTestId('approve-login-button')); + // approve + const onClick = mockedFlow.mock.calls[0][0].onClick; + await onClick(Click.Approve); - // flush token request promise - await flushPromisesWithFakeTimers(); - await flushPromisesWithFakeTimers(); + await waitFor(() => expect(mockedFlow).toHaveBeenLastCalledWith(expect.objectContaining({ + phase: Phase.WaitingForDevice, + }))); - expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalled(); + expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalledWith('token'); - await flushPromisesWithFakeTimers(); - expect(onFinished).not.toHaveBeenCalled(); - expect(rendezvous.verifyNewDeviceOnExistingDevice).not.toHaveBeenCalled(); + expect(onFinished).toHaveBeenCalledWith(true); }); - it('waits for device approval on existing device and finishes when crypto is not setup', async () => { - const { getByTestId } = render(getComponent({ client })); - const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; - // @ts-ignore assign to private prop - rendezvous.code = 'rendezvous-code'; - - // flush generate code promise - await flushPromisesWithFakeTimers(); - // flush waiting for connection promise - await flushPromisesWithFakeTimers(); - - fireEvent.click(getByTestId('approve-login-button')); - - // flush token request promise - await flushPromisesWithFakeTimers(); - await flushPromisesWithFakeTimers(); - - expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalled(); - await flushPromisesWithFakeTimers(); - expect(defaultProps.onFinished).toHaveBeenCalledWith(true); - // didnt attempt verification - expect(rendezvous.verifyNewDeviceOnExistingDevice).not.toHaveBeenCalled(); - }); - - it('waits for device approval on existing device and verifies device', async () => { - const { getByTestId } = render(getComponent({ client })); - const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; - // @ts-ignore assign to private prop - rendezvous.code = 'rendezvous-code'; - // we just check for presence of crypto - // pretend it is set up + test('approve + verifying', async () => { + const onFinished = jest.fn(); // @ts-ignore client.crypto = {}; + jest.spyOn(MSC3906Rendezvous.prototype, 'verifyNewDeviceOnExistingDevice') + .mockImplementation(() => unresolvedPromise()); + render(getComponent({ client, onFinished })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; - // flush generate code promise - await flushPromisesWithFakeTimers(); - // flush waiting for connection promise - await flushPromisesWithFakeTimers(); + await waitFor(() => expect(mockedFlow).toHaveBeenLastCalledWith(expect.objectContaining({ + phase: Phase.Connected, + }))); + expect(mockedFlow).toHaveBeenLastCalledWith({ + phase: Phase.Connected, + confirmationDigits: mockConfirmationDigits, + onClick: expect.any(Function), + }); + expect(rendezvous.generateCode).toHaveBeenCalled(); + expect(rendezvous.startAfterShowingCode).toHaveBeenCalled(); - fireEvent.click(getByTestId('approve-login-button')); + // approve + const onClick = mockedFlow.mock.calls[0][0].onClick; + onClick(Click.Approve); - // flush token request promise - await flushPromisesWithFakeTimers(); - await flushPromisesWithFakeTimers(); + await waitFor(() => expect(mockedFlow).toHaveBeenLastCalledWith(expect.objectContaining({ + phase: Phase.Verifying, + }))); - expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalled(); - // flush login approval - await flushPromisesWithFakeTimers(); + expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalledWith('token'); expect(rendezvous.verifyNewDeviceOnExistingDevice).toHaveBeenCalled(); - // flush verification - await flushPromisesWithFakeTimers(); - expect(defaultProps.onFinished).toHaveBeenCalledWith(true); + // expect(onFinished).toHaveBeenCalledWith(true); + }); + + test('approve + verify', async () => { + const onFinished = jest.fn(); + // @ts-ignore + client.crypto = {}; + render(getComponent({ client, onFinished })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + + await waitFor(() => expect(mockedFlow).toHaveBeenLastCalledWith(expect.objectContaining({ + phase: Phase.Connected, + }))); + expect(mockedFlow).toHaveBeenLastCalledWith({ + phase: Phase.Connected, + confirmationDigits: mockConfirmationDigits, + onClick: expect.any(Function), + }); + expect(rendezvous.generateCode).toHaveBeenCalled(); + expect(rendezvous.startAfterShowingCode).toHaveBeenCalled(); + + // approve + const onClick = mockedFlow.mock.calls[0][0].onClick; + await onClick(Click.Approve); + expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalledWith('token'); + expect(rendezvous.verifyNewDeviceOnExistingDevice).toHaveBeenCalled(); + expect(onFinished).toHaveBeenCalledWith(true); }); }); diff --git a/test/components/views/settings/devices/LoginWithQRFlow-test.tsx b/test/components/views/settings/devices/LoginWithQRFlow-test.tsx new file mode 100644 index 0000000000..8b8abfa7bc --- /dev/null +++ b/test/components/views/settings/devices/LoginWithQRFlow-test.tsx @@ -0,0 +1,116 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { RendezvousFailureReason } from 'matrix-js-sdk/src/rendezvous'; + +import LoginWithQRFlow from '../../../../../src/components/views/auth/LoginWithQRFlow'; +import { Click, Phase } from '../../../../../src/components/views/auth/LoginWithQR'; + +describe('', () => { + const onClick = jest.fn(); + + const defaultProps = { + onClick, + }; + + const getComponent = (props: { + phase: Phase; + onClick?: () => Promise; + failureReason?: RendezvousFailureReason; + code?: string; + confirmationDigits?: string; + }) => + (); + + beforeEach(() => { + }); + + afterEach(() => { + onClick.mockReset(); + cleanup(); + }); + + it('renders spinner while loading', async () => { + const { container } = render(getComponent({ phase: Phase.Loading })); + expect(container).toMatchSnapshot(); + }); + + it('renders spinner whilst QR generating', async () => { + const { container } = render(getComponent({ phase: Phase.ShowingQR })); + expect(screen.getAllByTestId('cancel-button')).toHaveLength(1); + expect(container).toMatchSnapshot(); + fireEvent.click(screen.getByTestId('cancel-button')); + expect(onClick).toHaveBeenCalledWith(Click.Cancel); + }); + + it('renders QR code', async () => { + const { container } = render(getComponent({ phase: Phase.ShowingQR, code: 'mock-code' })); + // QR code is rendered async so we wait for it: + await waitFor(() => screen.getAllByAltText('QR Code').length === 1); + expect(container).toMatchSnapshot(); + }); + + it('renders spinner while connecting', async () => { + const { container } = render(getComponent({ phase: Phase.Connecting })); + expect(screen.getAllByTestId('cancel-button')).toHaveLength(1); + expect(container).toMatchSnapshot(); + fireEvent.click(screen.getByTestId('cancel-button')); + expect(onClick).toHaveBeenCalledWith(Click.Cancel); + }); + + it('renders code when connected', async () => { + const { container } = render(getComponent({ phase: Phase.Connected, confirmationDigits: 'mock-digits' })); + expect(screen.getAllByText('mock-digits')).toHaveLength(1); + expect(screen.getAllByTestId('decline-login-button')).toHaveLength(1); + expect(screen.getAllByTestId('approve-login-button')).toHaveLength(1); + expect(container).toMatchSnapshot(); + fireEvent.click(screen.getByTestId('decline-login-button')); + expect(onClick).toHaveBeenCalledWith(Click.Decline); + fireEvent.click(screen.getByTestId('approve-login-button')); + expect(onClick).toHaveBeenCalledWith(Click.Approve); + }); + + it('renders spinner while signing in', async () => { + const { container } = render(getComponent({ phase: Phase.WaitingForDevice })); + expect(screen.getAllByTestId('cancel-button')).toHaveLength(1); + expect(container).toMatchSnapshot(); + fireEvent.click(screen.getByTestId('cancel-button')); + expect(onClick).toHaveBeenCalledWith(Click.Cancel); + }); + + it('renders spinner while verifying', async () => { + const { container } = render(getComponent({ phase: Phase.Verifying })); + expect(container).toMatchSnapshot(); + }); + + describe('errors', () => { + for (const failureReason of Object.values(RendezvousFailureReason)) { + it(`renders ${failureReason}`, async () => { + const { container } = render(getComponent({ + phase: Phase.Error, + failureReason, + })); + expect(screen.getAllByTestId('cancellation-message')).toHaveLength(1); + expect(screen.getAllByTestId('try-again-button')).toHaveLength(1); + expect(container).toMatchSnapshot(); + fireEvent.click(screen.getByTestId('try-again-button')); + expect(onClick).toHaveBeenCalledWith(Click.TryAgain); + }); + } + }); +}); diff --git a/test/components/views/settings/devices/LoginWithQRSection-test.tsx b/test/components/views/settings/devices/LoginWithQRSection-test.tsx index 711f471035..0045fa1cfd 100644 --- a/test/components/views/settings/devices/LoginWithQRSection-test.tsx +++ b/test/components/views/settings/devices/LoginWithQRSection-test.tsx @@ -56,7 +56,7 @@ describe('', () => { const defaultProps = { onShowQr: () => {}, - versions: undefined, + versions: makeVersions({}), }; const getComponent = (props = {}) => diff --git a/test/components/views/settings/devices/__snapshots__/LoginWithQR-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/LoginWithQR-test.tsx.snap deleted file mode 100644 index d8d1cd99cb..0000000000 --- a/test/components/views/settings/devices/__snapshots__/LoginWithQR-test.tsx.snap +++ /dev/null @@ -1,377 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` approves login and waits for new device 1`] = ` -
-
-
-
-
-
-

-

-
-
-
-
-
-
-

- Waiting for device to sign in -

-
-
-
-
-
- Cancel -
-
-
-
-`; - -exports[` displays confirmation digits after connected to rendezvous 1`] = ` -
-
-
-

-
- Devices connected -

-
-
-

- Check that the code below matches with your other device: -

-
- mock-confirmation-digits -
-
-
-
-
-
- By approving access for this device, it will have full access to your account. -
-
-
-
-
- Cancel -
-
- Approve -
-
-
-
-`; - -exports[` displays error when approving login fails 1`] = ` -
-
-
-

-
- Connection failed -

-
-
-

- An unexpected error occurred. -

-
-
-
- Try again -
-
- Cancel -
-
-
-
-`; - -exports[` displays qr code after it is created 1`] = ` -
-
-
-
-
-
-

- Sign in with QR code -

-
-
-

- Scan the QR code below with your device that's signed out. -

-
    -
  1. - Start at the sign in screen -
  2. -
  3. - Select 'Scan QR code' -
  4. -
  5. - Review and approve the sign in -
  6. -
-
-
-
-
-
-
-
-
-
-
-
-`; - -exports[` displays unknown error if connection to rendezvous fails 1`] = ` -
-
-
-

-
- Connection failed -

-
-
-

- An unexpected error occurred. -

-
-
-
- Try again -
-
- Cancel -
-
-
-
-`; - -exports[` no content in case of no support 1`] = ` -
-
-
-

-
- Connection failed -

-
-
-

- The homeserver doesn't support signing in another device. -

-
-
-
- Try again -
-
- Cancel -
-
-
-
-`; - -exports[` renders spinner while generating code 1`] = ` -
-
-
-
-
-
-

-

-
-
-
-
-
-
-
-
-
-
-
-
-`; diff --git a/test/components/views/settings/devices/__snapshots__/LoginWithQRFlow-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/LoginWithQRFlow-test.tsx.snap new file mode 100644 index 0000000000..025b2dd195 --- /dev/null +++ b/test/components/views/settings/devices/__snapshots__/LoginWithQRFlow-test.tsx.snap @@ -0,0 +1,948 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` errors renders data_mismatch 1`] = ` +
+
+
+

+
+ Connection failed +

+
+
+

+ The request was cancelled. +

+
+
+
+ Try again +
+
+ Cancel +
+
+
+
+`; + +exports[` errors renders expired 1`] = ` +
+
+
+

+
+ Connection failed +

+
+
+

+ The linking wasn't completed in the required time. +

+
+
+
+ Try again +
+
+ Cancel +
+
+
+
+`; + +exports[` errors renders homeserver_lacks_support 1`] = ` +
+
+
+

+
+ Connection failed +

+
+
+

+ The homeserver doesn't support signing in another device. +

+
+
+
+ Try again +
+
+ Cancel +
+
+
+
+`; + +exports[` errors renders invalid_code 1`] = ` +
+
+
+

+
+ Connection failed +

+
+
+

+ The scanned code is invalid. +

+
+
+
+ Try again +
+
+ Cancel +
+
+
+
+`; + +exports[` errors renders other_device_already_signed_in 1`] = ` +
+
+
+

+
+ Connection failed +

+
+
+

+ The other device is already signed in. +

+
+
+
+ Try again +
+
+ Cancel +
+
+
+
+`; + +exports[` errors renders other_device_not_signed_in 1`] = ` +
+
+
+

+
+ Connection failed +

+
+
+

+ The other device isn't signed in. +

+
+
+
+ Try again +
+
+ Cancel +
+
+
+
+`; + +exports[` errors renders unknown 1`] = ` +
+
+
+

+
+ Connection failed +

+
+
+

+ An unexpected error occurred. +

+
+
+
+ Try again +
+
+ Cancel +
+
+
+
+`; + +exports[` errors renders unsupported_algorithm 1`] = ` +
+
+
+

+
+ Connection failed +

+
+
+

+ Linking with this device is not supported. +

+
+
+
+ Try again +
+
+ Cancel +
+
+
+
+`; + +exports[` errors renders unsupported_transport 1`] = ` +
+
+
+

+
+ Connection failed +

+
+
+

+ The request was cancelled. +

+
+
+
+ Try again +
+
+ Cancel +
+
+
+
+`; + +exports[` errors renders user_cancelled 1`] = ` +
+
+
+

+
+ Connection failed +

+
+
+

+ The request was cancelled. +

+
+
+
+ Try again +
+
+ Cancel +
+
+
+
+`; + +exports[` errors renders user_declined 1`] = ` +
+
+
+

+
+ Connection failed +

+
+
+

+ The request was declined on the other device. +

+
+
+
+ Try again +
+
+ Cancel +
+
+
+
+`; + +exports[` renders QR code 1`] = ` +
+
+
+
+
+
+

+ Sign in with QR code +

+
+
+

+ Scan the QR code below with your device that's signed out. +

+
    +
  1. + Start at the sign in screen +
  2. +
  3. + Select 'Scan QR code' +
  4. +
  5. + Review and approve the sign in +
  6. +
+
+
+ QR Code +
+
+
+
+
+
+`; + +exports[` renders code when connected 1`] = ` +
+
+
+

+
+ Devices connected +

+
+
+

+ Check that the code below matches with your other device: +

+
+ mock-digits +
+
+
+
+
+
+ By approving access for this device, it will have full access to your account. +
+
+
+
+
+ Cancel +
+
+ Approve +
+
+
+
+`; + +exports[` renders spinner while connecting 1`] = ` +
+
+
+
+
+
+

+ +

+
+
+
+
+
+
+
+

+ Connecting... +

+
+
+
+
+
+ Cancel +
+
+
+
+`; + +exports[` renders spinner while loading 1`] = ` +
+
+
+
+
+
+

+ +

+
+
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[` renders spinner while signing in 1`] = ` +
+
+
+
+
+
+

+ +

+
+
+
+
+
+
+
+

+ Waiting for device to sign in +

+
+
+
+
+
+ Cancel +
+
+
+
+`; + +exports[` renders spinner while verifying 1`] = ` +
+
+
+
+
+
+

+ Success +

+
+
+
+
+
+
+
+

+ Completing set up of your new device +

+
+
+
+
+
+
+`; + +exports[` renders spinner whilst QR generating 1`] = ` +
+
+
+
+
+
+

+ Sign in with QR code +

+
+
+
+
+
+
+
+
+
+
+
+
+ Cancel +
+
+
+
+`;