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
44 changes: 18 additions & 26 deletions apps/web/src/components/views/auth/LoginWithQR.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@
import React from "react";
import {
ClientRendezvousFailureReason,
linkNewDeviceByGeneratingQR,
MSC4108FailureReason,
MSC4108RendezvousSession,
MSC4108SecureChannel,
MSC4108SignInWithQR,
RendezvousError,
type RendezvousFailureReason,
Expand Down Expand Up @@ -55,6 +54,7 @@
*/
export default class LoginWithQR extends React.Component<IProps, IState> {
private finished = false;
private abortController?: AbortController;

public constructor(props: IProps) {
super(props);
Expand All @@ -69,35 +69,31 @@
}

public componentDidMount(): void {
this.updateMode(this.props.mode).then(() => {});
void this.updateMode(this.props.mode);
}

public componentDidUpdate(prevProps: Readonly<IProps>): void {
if (prevProps.mode !== this.props.mode) {
this.updateMode(this.props.mode).then(() => {});
void this.updateMode(this.props.mode);
}
}

private async updateMode(mode: Mode, showLoading = true): Promise<void> {
if (this.state.rendezvous) {
const rendezvous = this.state.rendezvous;
rendezvous.onFailure = undefined;
this.setState({ rendezvous: undefined });
}
this.abortController?.abort();
this.abortController = new AbortController();
this.setState({ rendezvous: undefined });
if (showLoading) {
this.setState({ phase: Phase.Loading });
}

if (mode === Mode.Show) {
await this.generateAndShowCode();
await this.generateAndShowCode(this.abortController);
}
}

public componentWillUnmount(): void {
if (this.state.rendezvous && !this.finished) {
// eslint-disable-next-line react/no-direct-mutation-state
this.state.rendezvous.onFailure = undefined;
// calling cancel will call close() as well to clean up the resources
this.state.rendezvous.cancel(MSC4108FailureReason.UserCancelled);
if (!this.finished) {
this.abortController?.abort();
}
}

Expand All @@ -106,24 +102,18 @@
this.props.onFinished(success);
}

private generateAndShowCode = async (): Promise<void> => {
private generateAndShowCode = async (abortController: AbortController): Promise<void> => {

Check warning on line 105 in apps/web/src/components/views/auth/LoginWithQR.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member 'generateAndShowCode' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZ3TbaE3fL7yzqghF7Cq&open=AZ3TbaE3fL7yzqghF7Cq&pullRequest=33309
let rendezvous: MSC4108SignInWithQR;
try {
const transport = new MSC4108RendezvousSession({
onFailure: this.onFailure,
client: this.props.client,
});
await transport.send("");
const channel = new MSC4108SecureChannel(transport, undefined, this.onFailure);
rendezvous = new MSC4108SignInWithQR(channel, false, this.props.client, this.onFailure);

await rendezvous.generateCode();
rendezvous = await linkNewDeviceByGeneratingQR(this.props.client, this.onFailure, abortController.signal);
if (abortController.signal.aborted) return;
this.setState({
phase: Phase.ShowingQR,
rendezvous,
failureReason: undefined,
});
} catch (e) {
if (abortController.signal.aborted) return;
logger.error("Error whilst generating QR code", e);
this.setState({ phase: Phase.Error, failureReason: ClientRendezvousFailureReason.HomeserverLacksSupport });
return;
Expand All @@ -142,8 +132,9 @@

// we ask the user to confirm that the channel is secure
} catch (e: RendezvousError | unknown) {
if (abortController.signal.aborted) return;
logger.error("Error whilst approving login", e);
await rendezvous?.cancel(
await rendezvous.cancel(
e instanceof RendezvousError ? (e.code as MSC4108FailureReason) : ClientRendezvousFailureReason.Unknown,
);
}
Expand Down Expand Up @@ -210,6 +201,7 @@
};

public reset(): void {
this.abortController?.abort();
this.setState({
rendezvous: undefined,
verificationUri: undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,21 @@ Please see LICENSE files in the repository root for full details.

import { cleanup, render, waitFor } from "jest-matrix-react";
import { mocked, type MockedObject } from "jest-mock";
import React from "react";
import React, { createRef, type RefObject } from "react";
import {
ClientRendezvousFailureReason,
MSC4108FailureReason,
MSC4108SignInWithQR,
RendezvousError,
} from "matrix-js-sdk/src/rendezvous";
import { HTTPError, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { HTTPError, type MatrixClient, MatrixHttpApi } from "matrix-js-sdk/src/matrix";

import LoginWithQR, { LoginWithQRFailureReason } from "../../../../../../src/components/views/auth/LoginWithQR";
import { Click, Mode, Phase } from "../../../../../../src/components/views/auth/LoginWithQR-types";

jest.mock("matrix-js-sdk/src/rendezvous");
jest.mock("matrix-js-sdk/src/rendezvous/transports");
jest.mock("matrix-js-sdk/src/rendezvous/channels");
jest.mock("matrix-js-sdk/src/rendezvous/channels/MSC4108SecureChannel.ts");

const mockedFlow = jest.fn();

Expand All @@ -32,7 +32,7 @@ jest.mock("../../../../../../src/components/views/auth/LoginWithQRFlow", () => (
});

function makeClient() {
return mocked({
const cli = mocked({
getUser: jest.fn(),
isGuest: jest.fn().mockReturnValue(false),
isUserIgnored: jest.fn(),
Expand All @@ -49,7 +49,16 @@ function makeClient() {
},
getClientWellKnown: jest.fn().mockReturnValue({}),
getCrypto: jest.fn().mockReturnValue({}),
getDomain: jest.fn(),
} as unknown as MatrixClient);

cli.http = new MatrixHttpApi(cli, {
baseUrl: "https://server/",
prefix: "prefix",
onlyData: true,
}) as any;

return cli;
}

function unresolvedPromise<T>(): Promise<T> {
Expand All @@ -62,13 +71,12 @@ describe("<LoginWithQR />", () => {
legacy: true,
mode: Mode.Show,
onFinished: jest.fn(),
};
} as const;

beforeEach(() => {
mockedFlow.mockReset();
jest.resetAllMocks();
client = makeClient();
jest.useFakeTimers();
});

afterEach(() => {
Expand All @@ -79,14 +87,20 @@ describe("<LoginWithQR />", () => {
});

describe("MSC4108", () => {
const getComponent = (props: { client: MatrixClient; onFinished?: () => void }) => (
<LoginWithQR {...defaultProps} {...props} />
);
const getComponent = (props: {
client: MatrixClient;
onFinished?: () => void;
ref?: RefObject<LoginWithQR | null>;
}) => <LoginWithQR {...defaultProps} {...props} />;

test("render QR then back", async () => {
const onFinished = jest.fn();
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockReturnValue(unresolvedPromise());
render(getComponent({ client, onFinished }));
jest.spyOn(MSC4108SignInWithQR.prototype, "generateCode");
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols");
jest.spyOn(MSC4108SignInWithQR.prototype, "cancel");
const ref = createRef<LoginWithQR>();
render(getComponent({ client, onFinished, ref }));

await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
Expand All @@ -95,7 +109,7 @@ describe("<LoginWithQR />", () => {
}),
);

const rendezvous = mocked(MSC4108SignInWithQR).mock.instances[0];
const rendezvous = ref.current!.state.rendezvous!;
expect(rendezvous.generateCode).toHaveBeenCalled();
expect(rendezvous.negotiateProtocols).toHaveBeenCalled();

Expand All @@ -109,7 +123,8 @@ describe("<LoginWithQR />", () => {
test("should open a new channel if expires before qr scan", async () => {
const onFinished = jest.fn();
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockReturnValue(unresolvedPromise());
render(getComponent({ client, onFinished }));
const ref = createRef<LoginWithQR>();
render(getComponent({ client, onFinished, ref }));

await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
Expand All @@ -118,15 +133,15 @@ describe("<LoginWithQR />", () => {
}),
);

const rendezvous = mocked(MSC4108SignInWithQR).mock.instances[0];
const rendezvous = ref.current!.state.rendezvous!;
expect(rendezvous.generateCode).toHaveBeenCalled();
expect(rendezvous.negotiateProtocols).toHaveBeenCalled();

// Expire the channel
const onFailure = mocked(MSC4108SignInWithQR).mock.calls[0][3];
onFailure!(ClientRendezvousFailureReason.Expired);
rendezvous.onFailure!(ClientRendezvousFailureReason.Expired);
await jest.runAllTimersAsync();
await waitFor(() => expect(mocked(MSC4108SignInWithQR).mock.instances).toHaveLength(2));
await waitFor(() => expect(ref.current!.state.rendezvous).toBeDefined());
expect(ref.current!.state.rendezvous).not.toBe(rendezvous);
});

test("failed to connect", async () => {
Expand Down Expand Up @@ -168,9 +183,11 @@ describe("<LoginWithQR />", () => {
});

test("reciprocates login", async () => {
const ref = createRef<LoginWithQR>();
jest.spyOn(global.window, "open");

render(getComponent({ client }));
render(getComponent({ client, ref }));
jest.spyOn(MSC4108SignInWithQR.prototype, "shareSecrets").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({
verificationUri: "mock-verification-uri",
Expand All @@ -193,10 +210,14 @@ describe("<LoginWithQR />", () => {
}),
);
expect(global.window.open).toHaveBeenCalledWith("mock-verification-uri", "_blank");

const rendezvous = ref.current!.state.rendezvous!;
expect(rendezvous.shareSecrets).toHaveBeenCalled();
});

test("handles errors during protocol negotiation", async () => {
render(getComponent({ client }));
const ref = createRef<LoginWithQR>();
render(getComponent({ client, ref }));
jest.spyOn(MSC4108SignInWithQR.prototype, "cancel").mockResolvedValue();
const err = new RendezvousError("Unknown Failure", MSC4108FailureReason.UnsupportedProtocol);
// @ts-ignore work-around for lazy mocks
Expand All @@ -211,7 +232,7 @@ describe("<LoginWithQR />", () => {
);

await waitFor(() => {
const rendezvous = mocked(MSC4108SignInWithQR).mock.instances[0];
const rendezvous = ref.current!.state.rendezvous!;
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UnsupportedProtocol);
});
});
Expand Down Expand Up @@ -244,7 +265,8 @@ describe("<LoginWithQR />", () => {
});

test("handles user cancelling during reciprocation", async () => {
render(getComponent({ client }));
const ref = createRef<LoginWithQR>();
render(getComponent({ client, ref }));
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({});
Expand All @@ -259,7 +281,7 @@ describe("<LoginWithQR />", () => {
const onClick = mockedFlow.mock.calls[0][0].onClick;
await onClick(Click.Cancel);

const rendezvous = mocked(MSC4108SignInWithQR).mock.instances[0];
const rendezvous = ref.current!.state.rendezvous!;
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UserCancelled);
});
});
Expand Down
8 changes: 4 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading