MSC4108 support OIDC QR code login (#12370)

Co-authored-by: Hugh Nimmo-Smith <hughns@matrix.org>
This commit is contained in:
Michael Telatynski 2024-06-06 09:57:28 +01:00 committed by GitHub
parent ca7760789b
commit 1677ed1be0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1558 additions and 733 deletions

View file

@ -17,7 +17,13 @@ limitations under the License.
import { cleanup, render, waitFor } from "@testing-library/react";
import { MockedObject, mocked } from "jest-mock";
import React from "react";
import { MSC3906Rendezvous, LegacyRendezvousFailureReason } from "matrix-js-sdk/src/rendezvous";
import {
MSC3906Rendezvous,
LegacyRendezvousFailureReason,
ClientRendezvousFailureReason,
MSC4108SignInWithQR,
MSC4108FailureReason,
} from "matrix-js-sdk/src/rendezvous";
import { HTTPError, LoginTokenPostResponse } from "matrix-js-sdk/src/matrix";
import LoginWithQR from "../../../../../src/components/views/auth/LoginWithQR";
@ -65,6 +71,7 @@ function unresolvedPromise<T>(): Promise<T> {
describe("<LoginWithQR />", () => {
let client!: MockedObject<MatrixClient>;
const defaultProps = {
legacy: true,
mode: Mode.Show,
onFinished: jest.fn(),
};
@ -72,29 +79,10 @@ describe("<LoginWithQR />", () => {
const mockRendezvousCode = "mock-rendezvous-code";
const newDeviceId = "new-device-id";
const getComponent = (props: { client: MatrixClient; onFinished?: () => void }) => (
<React.StrictMode>
<LoginWithQR {...defaultProps} {...props} />
</React.StrictMode>
);
beforeEach(() => {
mockedFlow.mockReset();
jest.resetAllMocks();
client = makeClient();
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, "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_ms: 1000 * 1000,
} as LoginTokenPostResponse); // we force the type here so that it works with versions of js-sdk that don't have r1 support yet
});
afterEach(() => {
@ -104,279 +92,374 @@ describe("<LoginWithQR />", () => {
cleanup();
});
test("no homeserver support", async () => {
// simulate no support
jest.spyOn(MSC3906Rendezvous.prototype, "generateCode").mockRejectedValue("");
render(getComponent({ client }));
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.Error,
failureReason: LegacyRendezvousFailureReason.HomeserverLacksSupport,
onClick: expect.any(Function),
}),
describe("MSC3906", () => {
const getComponent = (props: { client: MatrixClient; onFinished?: () => void }) => (
<React.StrictMode>
<LoginWithQR {...defaultProps} {...props} />
</React.StrictMode>
);
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
expect(rendezvous.generateCode).toHaveBeenCalled();
});
test("failed to connect", async () => {
jest.spyOn(MSC3906Rendezvous.prototype, "startAfterShowingCode").mockRejectedValue("");
render(getComponent({ client }));
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.Error,
failureReason: LegacyRendezvousFailureReason.Unknown,
onClick: expect.any(Function),
}),
);
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
expect(rendezvous.generateCode).toHaveBeenCalled();
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
});
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];
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(LegacyRendezvousFailureReason.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(LegacyRendezvousFailureReason.UserCancelled);
});
test("render QR then decline", async () => {
const onFinished = jest.fn();
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),
beforeEach(() => {
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, "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_ms: 1000 * 1000,
} as LoginTokenPostResponse); // we force the type here so that it works with versions of js-sdk that don't have r1 support yet
});
// decline
const onClick = mockedFlow.mock.calls[0][0].onClick;
await onClick(Click.Decline);
expect(onFinished).toHaveBeenCalledWith(false);
expect(rendezvous.generateCode).toHaveBeenCalled();
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
expect(rendezvous.declineLoginOnExistingDevice).toHaveBeenCalled();
});
test("approve - no crypto", async () => {
(client as any).crypto = undefined;
(client as any).getCrypto = () => undefined;
const onFinished = jest.fn();
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);
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith(
expect.objectContaining({
phase: Phase.WaitingForDevice,
}),
),
);
expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalledWith("token");
expect(onFinished).toHaveBeenCalledWith(true);
});
test("approve + verifying", async () => {
const onFinished = jest.fn();
jest.spyOn(MSC3906Rendezvous.prototype, "verifyNewDeviceOnExistingDevice").mockImplementation(() =>
unresolvedPromise(),
);
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;
onClick(Click.Approve);
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith(
expect.objectContaining({
phase: Phase.Verifying,
}),
),
);
expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalledWith("token");
expect(rendezvous.verifyNewDeviceOnExistingDevice).toHaveBeenCalled();
// expect(onFinished).toHaveBeenCalledWith(true);
});
test("approve + verify", async () => {
const onFinished = jest.fn();
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(rendezvous.close).toHaveBeenCalled();
expect(onFinished).toHaveBeenCalledWith(true);
});
test("approve - rate limited", async () => {
mocked(client.requestLoginToken).mockRejectedValue(new HTTPError("rate limit reached", 429));
const onFinished = jest.fn();
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);
// the 429 error should be handled and mapped
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith(
expect.objectContaining({
test("no homeserver support", async () => {
// simulate no support
jest.spyOn(MSC3906Rendezvous.prototype, "generateCode").mockRejectedValue("");
render(getComponent({ client }));
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.Error,
failureReason: "rate_limited",
failureReason: LegacyRendezvousFailureReason.HomeserverLacksSupport,
onClick: expect.any(Function),
}),
),
);
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
expect(rendezvous.generateCode).toHaveBeenCalled();
});
test("failed to connect", async () => {
jest.spyOn(MSC3906Rendezvous.prototype, "startAfterShowingCode").mockRejectedValue("");
render(getComponent({ client }));
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.Error,
failureReason: ClientRendezvousFailureReason.Unknown,
onClick: expect.any(Function),
}),
);
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
expect(rendezvous.generateCode).toHaveBeenCalled();
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
});
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(LegacyRendezvousFailureReason.UserCancelled);
});
test("render QR then decline", async () => {
const onFinished = jest.fn();
render(getComponent({ client, onFinished }));
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith(
expect.objectContaining({
phase: Phase.LegacyConnected,
}),
),
);
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.LegacyConnected,
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(rendezvous.startAfterShowingCode).toHaveBeenCalled();
expect(rendezvous.declineLoginOnExistingDevice).toHaveBeenCalled();
});
test("approve - no crypto", async () => {
(client as any).crypto = undefined;
(client as any).getCrypto = () => undefined;
const onFinished = jest.fn();
render(getComponent({ client, onFinished }));
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith(
expect.objectContaining({
phase: Phase.LegacyConnected,
}),
),
);
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.LegacyConnected,
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);
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith(
expect.objectContaining({
phase: Phase.WaitingForDevice,
}),
),
);
expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalledWith("token");
expect(onFinished).toHaveBeenCalledWith(true);
});
test("approve + verifying", async () => {
const onFinished = jest.fn();
jest.spyOn(MSC3906Rendezvous.prototype, "verifyNewDeviceOnExistingDevice").mockImplementation(() =>
unresolvedPromise(),
);
render(getComponent({ client, onFinished }));
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith(
expect.objectContaining({
phase: Phase.LegacyConnected,
}),
),
);
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.LegacyConnected,
confirmationDigits: mockConfirmationDigits,
onClick: expect.any(Function),
});
expect(rendezvous.generateCode).toHaveBeenCalled();
expect(rendezvous.startAfterShowingCode).toHaveBeenCalled();
// approve
const onClick = mockedFlow.mock.calls[0][0].onClick;
onClick(Click.Approve);
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith(
expect.objectContaining({
phase: Phase.Verifying,
}),
),
);
expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalledWith("token");
expect(rendezvous.verifyNewDeviceOnExistingDevice).toHaveBeenCalled();
// expect(onFinished).toHaveBeenCalledWith(true);
});
test("approve + verify", async () => {
const onFinished = jest.fn();
render(getComponent({ client, onFinished }));
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith(
expect.objectContaining({
phase: Phase.LegacyConnected,
}),
),
);
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.LegacyConnected,
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(rendezvous.close).toHaveBeenCalled();
expect(onFinished).toHaveBeenCalledWith(true);
});
test("approve - rate limited", async () => {
mocked(client.requestLoginToken).mockRejectedValue(new HTTPError("rate limit reached", 429));
const onFinished = jest.fn();
render(getComponent({ client, onFinished }));
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith(
expect.objectContaining({
phase: Phase.LegacyConnected,
}),
),
);
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.LegacyConnected,
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);
// the 429 error should be handled and mapped
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith(
expect.objectContaining({
phase: Phase.Error,
failureReason: "rate_limited",
}),
),
);
});
});
describe("MSC4108", () => {
const getComponent = (props: { client: MatrixClient; onFinished?: () => void }) => (
<React.StrictMode>
<LoginWithQR {...defaultProps} {...props} legacy={false} />
</React.StrictMode>
);
test("render QR then back", async () => {
const onFinished = jest.fn();
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockReturnValue(unresolvedPromise());
render(getComponent({ client, onFinished }));
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.ShowingQR,
onClick: expect.any(Function),
}),
);
const rendezvous = mocked(MSC4108SignInWithQR).mock.instances[0];
expect(rendezvous.generateCode).toHaveBeenCalled();
expect(rendezvous.negotiateProtocols).toHaveBeenCalled();
// back
const onClick = mockedFlow.mock.calls[0][0].onClick;
await onClick(Click.Back);
expect(onFinished).toHaveBeenCalledWith(false);
expect(rendezvous.cancel).toHaveBeenCalledWith(LegacyRendezvousFailureReason.UserCancelled);
});
test("failed to connect", async () => {
render(getComponent({ client }));
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockRejectedValue(
new HTTPError("Internal Server Error", 500),
);
const fn = jest.spyOn(MSC4108SignInWithQR.prototype, "cancel");
await waitFor(() => expect(fn).toHaveBeenLastCalledWith(ClientRendezvousFailureReason.Unknown));
});
test("reciprocates login", async () => {
jest.spyOn(global.window, "open");
render(getComponent({ client }));
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({
verificationUri: "mock-verification-uri",
});
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.OutOfBandConfirmation,
onClick: expect.any(Function),
}),
);
const onClick = mockedFlow.mock.calls[0][0].onClick;
await onClick(Click.Approve);
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.WaitingForDevice,
onClick: expect.any(Function),
}),
);
expect(global.window.open).toHaveBeenCalledWith("mock-verification-uri", "_blank");
});
test("handles errors during reciprocation", async () => {
render(getComponent({ client }));
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({});
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.OutOfBandConfirmation,
onClick: expect.any(Function),
}),
);
jest.spyOn(MSC4108SignInWithQR.prototype, "shareSecrets").mockRejectedValue(
new HTTPError("Internal Server Error", 500),
);
const onClick = mockedFlow.mock.calls[0][0].onClick;
await onClick(Click.Approve);
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith(
expect.objectContaining({
phase: Phase.Error,
failureReason: ClientRendezvousFailureReason.Unknown,
}),
),
);
});
test("handles user cancelling during reciprocation", async () => {
render(getComponent({ client }));
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({});
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({});
await waitFor(() =>
expect(mockedFlow).toHaveBeenLastCalledWith({
phase: Phase.OutOfBandConfirmation,
onClick: expect.any(Function),
}),
);
jest.spyOn(MSC4108SignInWithQR.prototype, "cancel").mockResolvedValue();
const onClick = mockedFlow.mock.calls[0][0].onClick;
await onClick(Click.Cancel);
const rendezvous = mocked(MSC4108SignInWithQR).mock.instances[0];
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UserCancelled);
});
});
});

View file

@ -16,7 +16,11 @@ limitations under the License.
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { LegacyRendezvousFailureReason } from "matrix-js-sdk/src/rendezvous";
import {
ClientRendezvousFailureReason,
LegacyRendezvousFailureReason,
MSC4108FailureReason,
} from "matrix-js-sdk/src/rendezvous";
import LoginWithQRFlow from "../../../../../src/components/views/auth/LoginWithQRFlow";
import { LoginWithQRFailureReason, FailureReason } from "../../../../../src/components/views/auth/LoginWithQR";
@ -54,7 +58,7 @@ describe("<LoginWithQRFlow />", () => {
expect(screen.getAllByTestId("cancel-button")).toHaveLength(1);
expect(container).toMatchSnapshot();
fireEvent.click(screen.getByTestId("cancel-button"));
expect(onClick).toHaveBeenCalledWith(Click.Cancel);
expect(onClick).toHaveBeenCalledWith(Click.Cancel, undefined);
});
it("renders QR code", async () => {
@ -64,24 +68,16 @@ describe("<LoginWithQRFlow />", () => {
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" }));
const { container } = render(getComponent({ phase: Phase.LegacyConnected, 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);
expect(onClick).toHaveBeenCalledWith(Click.Decline, undefined);
fireEvent.click(screen.getByTestId("approve-login-button"));
expect(onClick).toHaveBeenCalledWith(Click.Approve);
expect(onClick).toHaveBeenCalledWith(Click.Approve, undefined);
});
it("renders spinner while signing in", async () => {
@ -89,7 +85,7 @@ describe("<LoginWithQRFlow />", () => {
expect(screen.getAllByTestId("cancel-button")).toHaveLength(1);
expect(container).toMatchSnapshot();
fireEvent.click(screen.getByTestId("cancel-button"));
expect(onClick).toHaveBeenCalledWith(Click.Cancel);
expect(onClick).toHaveBeenCalledWith(Click.Cancel, undefined);
});
it("renders spinner while verifying", async () => {
@ -97,10 +93,17 @@ describe("<LoginWithQRFlow />", () => {
expect(container).toMatchSnapshot();
});
it("renders check code confirmation", async () => {
const { container } = render(getComponent({ phase: Phase.OutOfBandConfirmation }));
expect(container).toMatchSnapshot();
});
describe("errors", () => {
for (const failureReason of [
...Object.values(LegacyRendezvousFailureReason),
...Object.values(MSC4108FailureReason),
...Object.values(LoginWithQRFailureReason),
...Object.values(ClientRendezvousFailureReason),
]) {
it(`renders ${failureReason}`, async () => {
const { container } = render(
@ -110,10 +113,7 @@ describe("<LoginWithQRFlow />", () => {
}),
);
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);
});
}
});

View file

@ -18,11 +18,17 @@ import { render } from "@testing-library/react";
import { mocked } from "jest-mock";
import { IClientWellKnown, IServerVersions, MatrixClient, GET_LOGIN_TOKEN_CAPABILITY } from "matrix-js-sdk/src/matrix";
import React from "react";
import fetchMock from "fetch-mock-jest";
import LoginWithQRSection from "../../../../../src/components/views/settings/devices/LoginWithQRSection";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
function makeClient(wellKnown: IClientWellKnown) {
const crypto = mocked({
supportsSecretsForQrLogin: jest.fn().mockReturnValue(true),
isCrossSigningReady: jest.fn().mockReturnValue(true),
});
return mocked({
getUser: jest.fn(),
isGuest: jest.fn().mockReturnValue(false),
@ -38,6 +44,7 @@ function makeClient(wellKnown: IClientWellKnown) {
on: jest.fn(),
},
getClientWellKnown: jest.fn().mockReturnValue(wellKnown),
getCrypto: jest.fn().mockReturnValue(crypto),
} as unknown as MatrixClient);
}
@ -53,68 +60,105 @@ describe("<LoginWithQRSection />", () => {
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(makeClient({}));
});
const defaultProps = {
onShowQr: () => {},
versions: makeVersions({}),
wellKnown: {},
};
describe("MSC3906", () => {
const defaultProps = {
onShowQr: () => {},
versions: makeVersions({}),
wellKnown: {},
};
const getComponent = (props = {}) => <LoginWithQRSection {...defaultProps} {...props} />;
const getComponent = (props = {}) => <LoginWithQRSection {...defaultProps} {...props} />;
describe("should not render", () => {
it("no support at all", () => {
const { container } = render(getComponent());
expect(container).toMatchSnapshot();
describe("should not render", () => {
it("no support at all", () => {
const { container } = render(getComponent());
expect(container).toMatchSnapshot();
});
it("only get_login_token enabled", async () => {
const { container } = render(
getComponent({ capabilities: { [GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: true } } }),
);
expect(container).toMatchSnapshot();
});
it("MSC3886 + get_login_token disabled", async () => {
const { container } = render(
getComponent({
versions: makeVersions({ "org.matrix.msc3886": true }),
capabilities: { [GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: false } },
}),
);
expect(container).toMatchSnapshot();
});
});
it("only get_login_token enabled", async () => {
const { container } = render(
getComponent({ capabilities: { [GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: true } } }),
);
expect(container).toMatchSnapshot();
});
describe("should render panel", () => {
it("get_login_token + MSC3886", async () => {
const { container } = render(
getComponent({
versions: makeVersions({
"org.matrix.msc3886": true,
}),
capabilities: {
[GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: true },
},
}),
);
expect(container).toMatchSnapshot();
});
it("MSC3886 + get_login_token disabled", async () => {
const { container } = render(
getComponent({
versions: makeVersions({ "org.matrix.msc3886": true }),
capabilities: { [GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: false } },
}),
);
expect(container).toMatchSnapshot();
it("get_login_token + .well-known", async () => {
const wellKnown = {
"io.element.rendezvous": {
server: "https://rz.local",
},
};
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(makeClient(wellKnown));
const { container } = render(
getComponent({
versions: makeVersions({}),
capabilities: { [GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: true } },
wellKnown,
}),
);
expect(container).toMatchSnapshot();
});
});
});
describe("should render panel", () => {
it("get_login_token + MSC3886", async () => {
const { container } = render(
getComponent({
versions: makeVersions({
"org.matrix.msc3886": true,
}),
capabilities: {
[GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: true },
},
}),
);
expect(container).toMatchSnapshot();
});
it("get_login_token + .well-known", async () => {
const wellKnown = {
"io.element.rendezvous": {
server: "https://rz.local",
},
describe("MSC4108", () => {
describe("MSC4108", () => {
const defaultProps = {
onShowQr: () => {},
versions: makeVersions({ "org.matrix.msc4108": true }),
wellKnown: {},
};
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(makeClient(wellKnown));
const { container } = render(
getComponent({
versions: makeVersions({}),
capabilities: { [GET_LOGIN_TOKEN_CAPABILITY.name]: { enabled: true } },
wellKnown,
}),
);
expect(container).toMatchSnapshot();
const getComponent = (props = {}) => <LoginWithQRSection {...defaultProps} {...props} />;
let client: MatrixClient;
beforeEach(() => {
client = makeClient({});
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(client);
});
test("no homeserver support", async () => {
const { container } = render(getComponent({ versions: makeVersions({ "org.matrix.msc4108": false }) }));
expect(container.textContent).toBe(""); // show nothing
});
test("no support in crypto", async () => {
client.getCrypto()!.exportSecretsBundle = undefined;
const { container } = render(getComponent({ client }));
expect(container.textContent).toBe(""); // show nothing
});
test("failed to connect", async () => {
fetchMock.catch(500);
const { container } = render(getComponent({ client }));
expect(container.textContent).toBe(""); // show nothing
});
});
});
});

View file

@ -1,6 +1,40 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<LoginWithQRFlow /> errors renders data_mismatch 1`] = `
exports[`<LoginWithQRFlow /> errors renders authorization_expired 1`] = `
<div>
<div
class="mx_LoginWithQR mx_LoginWithQR_error"
data-testid="login-with-qr"
>
<div
class="mx_LoginWithQR_main"
>
<div
class="mx_LoginWithQR_icon mx_LoginWithQR_icon--critical"
>
<div
width="32px"
/>
</div>
<h1
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
>
The sign in was not completed in time
</h1>
<p
data-testid="cancellation-message"
>
Sign in expired. Please try again.
</p>
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;
exports[`<LoginWithQRFlow /> errors renders check_code_mismatch 1`] = `
<div>
<div
class="mx_LoginWithQR mx_LoginWithQR_error"
@ -29,24 +63,109 @@ exports[`<LoginWithQRFlow /> errors renders data_mismatch 1`] = `
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;
exports[`<LoginWithQRFlow /> errors renders device_already_exists 1`] = `
<div>
<div
class="mx_LoginWithQR mx_LoginWithQR_error"
data-testid="login-with-qr"
>
<div
class="mx_LoginWithQR_main"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
data-testid="try-again-button"
role="button"
tabindex="0"
class="mx_LoginWithQR_icon mx_LoginWithQR_icon--critical"
>
Try again
<div
width="32px"
/>
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
data-testid="cancel-button"
role="button"
tabindex="0"
<h1
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
>
Cancel
</div>
Something went wrong!
</h1>
<p
data-testid="cancellation-message"
>
An unexpected error occurred. The request to connect your other device has been cancelled.
</p>
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;
exports[`<LoginWithQRFlow /> errors renders device_not_found 1`] = `
<div>
<div
class="mx_LoginWithQR mx_LoginWithQR_error"
data-testid="login-with-qr"
>
<div
class="mx_LoginWithQR_main"
>
<div
class="mx_LoginWithQR_icon mx_LoginWithQR_icon--critical"
>
<div
width="32px"
/>
</div>
<h1
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
>
Something went wrong!
</h1>
<p
data-testid="cancellation-message"
>
An unexpected error occurred. The request to connect your other device has been cancelled.
</p>
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;
exports[`<LoginWithQRFlow /> errors renders etag_missing 1`] = `
<div>
<div
class="mx_LoginWithQR mx_LoginWithQR_error"
data-testid="login-with-qr"
>
<div
class="mx_LoginWithQR_main"
>
<div
class="mx_LoginWithQR_icon mx_LoginWithQR_icon--critical"
>
<div
width="32px"
/>
</div>
<h1
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
>
Something went wrong!
</h1>
<p
data-testid="cancellation-message"
>
An unexpected error occurred. This may be due to a browser extension, proxy server, or server misconfiguration.
</p>
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;
@ -80,24 +199,41 @@ exports[`<LoginWithQRFlow /> errors renders expired 1`] = `
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;
exports[`<LoginWithQRFlow /> errors renders expired 2`] = `
<div>
<div
class="mx_LoginWithQR mx_LoginWithQR_error"
data-testid="login-with-qr"
>
<div
class="mx_LoginWithQR_main"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
data-testid="try-again-button"
role="button"
tabindex="0"
class="mx_LoginWithQR_icon mx_LoginWithQR_icon--critical"
>
Try again
<div
width="32px"
/>
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
data-testid="cancel-button"
role="button"
tabindex="0"
<h1
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
>
Cancel
</div>
The sign in was not completed in time
</h1>
<p
data-testid="cancellation-message"
>
Sign in expired. Please try again.
</p>
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;
@ -121,39 +257,56 @@ exports[`<LoginWithQRFlow /> errors renders homeserver_lacks_support 1`] = `
<h1
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
>
Other device not compatible
Something went wrong!
</h1>
<p
data-testid="cancellation-message"
>
This device does not support signing in to the other device with a QR code.
An unexpected error occurred. The request to connect your other device has been cancelled.
</p>
</div>
<div
class="mx_LoginWithQR_buttons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
data-testid="try-again-button"
role="button"
tabindex="0"
>
Try again
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
data-testid="cancel-button"
role="button"
tabindex="0"
>
Cancel
</div>
</div>
/>
</div>
</div>
`;
exports[`<LoginWithQRFlow /> errors renders invalid_code 1`] = `
exports[`<LoginWithQRFlow /> errors renders homeserver_lacks_support 2`] = `
<div>
<div
class="mx_LoginWithQR mx_LoginWithQR_error"
data-testid="login-with-qr"
>
<div
class="mx_LoginWithQR_main"
>
<div
class="mx_LoginWithQR_icon mx_LoginWithQR_icon--critical"
>
<div
width="32px"
/>
</div>
<h1
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
>
Something went wrong!
</h1>
<p
data-testid="cancellation-message"
>
An unexpected error occurred. The request to connect your other device has been cancelled.
</p>
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;
exports[`<LoginWithQRFlow /> errors renders insecure_channel_detected 1`] = `
<div>
<div
class="mx_LoginWithQR mx_LoginWithQR_error"
@ -195,24 +348,41 @@ exports[`<LoginWithQRFlow /> errors renders invalid_code 1`] = `
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;
exports[`<LoginWithQRFlow /> errors renders invalid_code 1`] = `
<div>
<div
class="mx_LoginWithQR mx_LoginWithQR_error"
data-testid="login-with-qr"
>
<div
class="mx_LoginWithQR_main"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
data-testid="try-again-button"
role="button"
tabindex="0"
class="mx_LoginWithQR_icon mx_LoginWithQR_icon--critical"
>
Try again
<div
width="32px"
/>
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
data-testid="cancel-button"
role="button"
tabindex="0"
<h1
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
>
Cancel
</div>
Something went wrong!
</h1>
<p
data-testid="cancellation-message"
>
An unexpected error occurred. The request to connect your other device has been cancelled.
</p>
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;
@ -246,24 +416,7 @@ exports[`<LoginWithQRFlow /> errors renders other_device_already_signed_in 1`] =
</div>
<div
class="mx_LoginWithQR_buttons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
data-testid="try-again-button"
role="button"
tabindex="0"
>
Try again
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
data-testid="cancel-button"
role="button"
tabindex="0"
>
Cancel
</div>
</div>
/>
</div>
</div>
`;
@ -297,24 +450,7 @@ exports[`<LoginWithQRFlow /> errors renders other_device_not_signed_in 1`] = `
</div>
<div
class="mx_LoginWithQR_buttons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
data-testid="try-again-button"
role="button"
tabindex="0"
>
Try again
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
data-testid="cancel-button"
role="button"
tabindex="0"
>
Cancel
</div>
</div>
/>
</div>
</div>
`;
@ -348,24 +484,41 @@ exports[`<LoginWithQRFlow /> errors renders rate_limited 1`] = `
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;
exports[`<LoginWithQRFlow /> errors renders unexpected_message_received 1`] = `
<div>
<div
class="mx_LoginWithQR mx_LoginWithQR_error"
data-testid="login-with-qr"
>
<div
class="mx_LoginWithQR_main"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
data-testid="try-again-button"
role="button"
tabindex="0"
class="mx_LoginWithQR_icon mx_LoginWithQR_icon--critical"
>
Try again
<div
width="32px"
/>
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
data-testid="cancel-button"
role="button"
tabindex="0"
<h1
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
>
Cancel
</div>
Something went wrong!
</h1>
<p
data-testid="cancellation-message"
>
An unexpected error occurred. The request to connect your other device has been cancelled.
</p>
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;
@ -399,24 +552,41 @@ exports[`<LoginWithQRFlow /> errors renders unknown 1`] = `
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;
exports[`<LoginWithQRFlow /> errors renders unknown 2`] = `
<div>
<div
class="mx_LoginWithQR mx_LoginWithQR_error"
data-testid="login-with-qr"
>
<div
class="mx_LoginWithQR_main"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
data-testid="try-again-button"
role="button"
tabindex="0"
class="mx_LoginWithQR_icon mx_LoginWithQR_icon--critical"
>
Try again
<div
width="32px"
/>
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
data-testid="cancel-button"
role="button"
tabindex="0"
<h1
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
>
Cancel
</div>
Something went wrong!
</h1>
<p
data-testid="cancellation-message"
>
An unexpected error occurred. The request to connect your other device has been cancelled.
</p>
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;
@ -440,39 +610,22 @@ exports[`<LoginWithQRFlow /> errors renders unsupported_algorithm 1`] = `
<h1
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
>
Other device not compatible
Something went wrong!
</h1>
<p
data-testid="cancellation-message"
>
This device does not support signing in to the other device with a QR code.
An unexpected error occurred. The request to connect your other device has been cancelled.
</p>
</div>
<div
class="mx_LoginWithQR_buttons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
data-testid="try-again-button"
role="button"
tabindex="0"
>
Try again
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
data-testid="cancel-button"
role="button"
tabindex="0"
>
Cancel
</div>
</div>
/>
</div>
</div>
`;
exports[`<LoginWithQRFlow /> errors renders unsupported_transport 1`] = `
exports[`<LoginWithQRFlow /> errors renders unsupported_protocol 1`] = `
<div>
<div
class="mx_LoginWithQR mx_LoginWithQR_error"
@ -501,24 +654,41 @@ exports[`<LoginWithQRFlow /> errors renders unsupported_transport 1`] = `
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;
exports[`<LoginWithQRFlow /> errors renders unsupported_protocol 2`] = `
<div>
<div
class="mx_LoginWithQR mx_LoginWithQR_error"
data-testid="login-with-qr"
>
<div
class="mx_LoginWithQR_main"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
data-testid="try-again-button"
role="button"
tabindex="0"
class="mx_LoginWithQR_icon mx_LoginWithQR_icon--critical"
>
Try again
<div
width="32px"
/>
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
data-testid="cancel-button"
role="button"
tabindex="0"
<h1
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
>
Cancel
</div>
Other device not compatible
</h1>
<p
data-testid="cancellation-message"
>
This device does not support signing in to the other device with a QR code.
</p>
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;
@ -552,24 +722,41 @@ exports[`<LoginWithQRFlow /> errors renders user_cancelled 1`] = `
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;
exports[`<LoginWithQRFlow /> errors renders user_cancelled 2`] = `
<div>
<div
class="mx_LoginWithQR mx_LoginWithQR_error"
data-testid="login-with-qr"
>
<div
class="mx_LoginWithQR_main"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
data-testid="try-again-button"
role="button"
tabindex="0"
class="mx_LoginWithQR_icon mx_LoginWithQR_icon--critical"
>
Try again
<div
width="32px"
/>
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
data-testid="cancel-button"
role="button"
tabindex="0"
<h1
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
>
Cancel
</div>
Sign in request cancelled
</h1>
<p
data-testid="cancellation-message"
>
The sign in was cancelled on the other device.
</p>
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;
@ -598,29 +785,46 @@ exports[`<LoginWithQRFlow /> errors renders user_declined 1`] = `
<p
data-testid="cancellation-message"
>
You declined the request from your other device to sign in.
You or the account provider declined the sign in request.
</p>
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;
exports[`<LoginWithQRFlow /> errors renders user_declined 2`] = `
<div>
<div
class="mx_LoginWithQR mx_LoginWithQR_error"
data-testid="login-with-qr"
>
<div
class="mx_LoginWithQR_main"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
data-testid="try-again-button"
role="button"
tabindex="0"
class="mx_LoginWithQR_icon mx_LoginWithQR_icon--critical"
>
Try again
<div
width="32px"
/>
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
data-testid="cancel-button"
role="button"
tabindex="0"
<h1
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
>
Cancel
</div>
Sign in declined
</h1>
<p
data-testid="cancellation-message"
>
You or the account provider declined the sign in request.
</p>
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;
@ -686,10 +890,10 @@ exports[`<LoginWithQRFlow /> renders QR code 1`] = `
</span>
</li>
<li>
Point the camera at the QR code shown here
Scan the QR code shown here
</li>
<li>
Follow the instructions to link your other device
Follow the remaining instructions
</li>
</ol>
</div>
@ -700,6 +904,80 @@ exports[`<LoginWithQRFlow /> renders QR code 1`] = `
</div>
`;
exports[`<LoginWithQRFlow /> renders check code confirmation 1`] = `
<div>
<div
class="mx_LoginWithQR"
data-testid="login-with-qr"
>
<div
class="mx_LoginWithQR_main"
>
<h1
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102"
>
Enter the number shown on your other device
</h1>
<p
class="_typography_yh5dq_162 _font-body-md-regular_yh5dq_59"
>
This will verify that the connection to your other device is secure.
</p>
<label
for="mx_LoginWithQR_checkCode"
>
2-digit code
</label>
<div
class="_container_9zyti_18 mx_LoginWithQR_checkCode_input mx_no_textinput"
>
<input
autocomplete="one-time-code"
class="_control_9zyti_33"
id="mx_LoginWithQR_checkCode"
inputmode="numeric"
maxlength="2"
minlength="0"
pattern="\\d{2}"
type="text"
/>
<div
aria-hidden="true"
class="_digit_9zyti_57"
/>
<div
aria-hidden="true"
class="_digit_9zyti_57"
/>
</div>
<div
class="mx_ErrorMessage"
/>
</div>
<div
class="mx_LoginWithQR_buttons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
data-testid="approve-login-button"
role="button"
tabindex="0"
>
Continue
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
data-testid="decline-login-button"
role="button"
tabindex="0"
>
Cancel
</div>
</div>
</div>
</div>
`;
exports[`<LoginWithQRFlow /> renders code when connected 1`] = `
<div>
<div
@ -752,72 +1030,6 @@ exports[`<LoginWithQRFlow /> renders code when connected 1`] = `
</div>
`;
exports[`<LoginWithQRFlow /> renders spinner while connecting 1`] = `
<div>
<div
class="mx_LoginWithQR"
data-testid="login-with-qr"
>
<div
class="mx_LoginWithQR_heading"
>
<div
aria-label="Back"
class="mx_AccessibleButton mx_LoginWithQR_BackButton"
data-testid="back-button"
role="button"
tabindex="0"
>
<div />
</div>
<div
class="mx_LoginWithQR_breadcrumbs"
>
Sessions
/
Link new device
</div>
</div>
<div
class="mx_LoginWithQR_main"
>
<div
class="mx_LoginWithQR_spinner"
>
<div>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
<p>
Connecting…
</p>
</div>
</div>
</div>
<div
class="mx_LoginWithQR_buttons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
data-testid="cancel-button"
role="button"
tabindex="0"
>
Cancel
</div>
</div>
</div>
</div>
`;
exports[`<LoginWithQRFlow /> renders spinner while loading 1`] = `
<div>
<div

View file

@ -1,12 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<LoginWithQRSection /> should not render MSC3886 + get_login_token disabled 1`] = `<div />`;
exports[`<LoginWithQRSection /> MSC3906 should not render MSC3886 + get_login_token disabled 1`] = `<div />`;
exports[`<LoginWithQRSection /> should not render no support at all 1`] = `<div />`;
exports[`<LoginWithQRSection /> MSC3906 should not render no support at all 1`] = `<div />`;
exports[`<LoginWithQRSection /> should not render only get_login_token enabled 1`] = `<div />`;
exports[`<LoginWithQRSection /> MSC3906 should not render only get_login_token enabled 1`] = `<div />`;
exports[`<LoginWithQRSection /> should render panel get_login_token + .well-known 1`] = `
exports[`<LoginWithQRSection /> MSC3906 should render panel get_login_token + .well-known 1`] = `
<div>
<div
class="mx_SettingsSubsection"
@ -48,7 +48,7 @@ exports[`<LoginWithQRSection /> should render panel get_login_token + .well-know
</div>
`;
exports[`<LoginWithQRSection /> should render panel get_login_token + MSC3886 1`] = `
exports[`<LoginWithQRSection /> MSC3906 should render panel get_login_token + MSC3886 1`] = `
<div>
<div
class="mx_SettingsSubsection"

View file

@ -34,6 +34,7 @@ import {
MatrixClient,
} from "matrix-js-sdk/src/matrix";
import { mocked, MockedObject } from "jest-mock";
import fetchMock from "fetch-mock-jest";
import {
clearAllModals,
@ -53,6 +54,8 @@ import SettingsStore from "../../../../../../src/settings/SettingsStore";
import { getClientInformationEventType } from "../../../../../../src/utils/device/clientInformation";
import { SDKContext, SdkContextClass } from "../../../../../../src/contexts/SDKContext";
import { OidcClientStore } from "../../../../../../src/stores/oidc/OidcClientStore";
import { mockOpenIdConfiguration } from "../../../../../test-utils/oidc";
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
mockPlatformPeg();
@ -119,6 +122,8 @@ describe("<SessionManagerTab />", () => {
getDeviceVerificationStatus: jest.fn(),
getUserDeviceInfo: jest.fn(),
requestDeviceVerification: jest.fn().mockResolvedValue(mockVerificationRequest),
supportsSecretsForQrLogin: jest.fn().mockReturnValue(false),
isCrossSigningReady: jest.fn().mockReturnValue(true),
} as unknown as CryptoApi);
let mockClient!: MockedObject<MatrixClient>;
@ -127,7 +132,9 @@ describe("<SessionManagerTab />", () => {
const defaultProps = {};
const getComponent = (props = {}): React.ReactElement => (
<SDKContext.Provider value={sdkContext}>
<SessionManagerTab {...defaultProps} {...props} />
<MatrixClientContext.Provider value={mockClient}>
<SessionManagerTab {...defaultProps} {...props} />
</MatrixClientContext.Provider>
</SDKContext.Provider>
);
@ -207,6 +214,7 @@ describe("<SessionManagerTab />", () => {
getPushers: jest.fn(),
setPusher: jest.fn(),
setLocalNotificationSettings: jest.fn(),
getAuthIssuer: jest.fn().mockReturnValue(new Promise(() => {})),
});
jest.clearAllMocks();
jest.spyOn(logger, "error").mockRestore();
@ -1664,7 +1672,7 @@ describe("<SessionManagerTab />", () => {
expect(checkbox.getAttribute("aria-checked")).toEqual("false");
});
describe("QR code login", () => {
describe("MSC3906 QR code login", () => {
const settingsValueSpy = jest.spyOn(SettingsStore, "getValue");
beforeEach(() => {
@ -1694,13 +1702,71 @@ describe("<SessionManagerTab />", () => {
});
it("enters qr code login section when show QR code button clicked", async () => {
const { getByText, getByTestId } = render(getComponent());
const { getByText, findByTestId } = render(getComponent());
// wait for versions call to settle
await flushPromises();
fireEvent.click(getByText("Show QR code"));
expect(getByTestId("login-with-qr")).toBeTruthy();
await expect(findByTestId("login-with-qr")).resolves.toBeTruthy();
});
});
describe("MSC4108 QR code login", () => {
const settingsValueSpy = jest.spyOn(SettingsStore, "getValue");
const issuer = "https://issuer.org";
const openIdConfiguration = mockOpenIdConfiguration(issuer);
beforeEach(() => {
settingsValueSpy.mockClear().mockReturnValue(true);
// enable server support for qr login
mockClient.getVersions.mockResolvedValue({
versions: [],
unstable_features: {
"org.matrix.msc4108": true,
},
});
mockClient.getCapabilities.mockResolvedValue({
[GET_LOGIN_TOKEN_CAPABILITY.name]: {
enabled: true,
},
});
mockClient.getAuthIssuer.mockResolvedValue({ issuer });
mockCrypto.exportSecretsBundle = jest.fn();
fetchMock.mock(`${issuer}/.well-known/openid-configuration`, {
...openIdConfiguration,
grant_types_supported: [
...openIdConfiguration.grant_types_supported,
"urn:ietf:params:oauth:grant-type:device_code",
],
});
fetchMock.mock(openIdConfiguration.jwks_uri!, {
status: 200,
headers: {
"Content-Type": "application/json",
},
keys: [],
});
});
it("renders qr code login section", async () => {
const { getByText } = render(getComponent());
// wait for versions call to settle
await flushPromises();
expect(getByText("Link new device")).toBeTruthy();
expect(getByText("Show QR code")).toBeTruthy();
});
it("enters qr code login section when show QR code button clicked", async () => {
const { getByText, findByTestId } = render(getComponent());
// wait for versions call to settle
await flushPromises();
fireEvent.click(getByText("Show QR code"));
await expect(findByTestId("login-with-qr")).resolves.toBeTruthy();
});
});
});