[Labs] Sign in with QR on new EW using generated QR for MSC4108 v2024 (#33184)
* PoC Sign in with QR on new EW using generated QR for MSC4108 v2024 * Revert package.json changes * Prettier * Fix i18n * Tidy up * Remove unused state variable * Iterate tests * Partial revert * Iterate * Wire up qr_login route * Iterate UI * Fix React dev mode double rendering issue * Fix react key warning * Hide flow header on login * Re-roll qr code on channel expiry * Switch to AbortSignal * Improve auto-retry QR UX * Ensure we only show sign in with QR button if enabled * XXX: enable labs flag on Netlify builds * Tweak QR code sizing * Move qr login flow into a dialog to match designs * Fix null deviceId * Remove duplicate log * Iterate * Fix tests * Fix types * Fix tests * Fix tests * Make Netlify more useful * Make Netlify more useful v2 * Update copy * Refactor QR link flow to use new SDK methods Requires https://github.com/matrix-org/matrix-js-sdk/pull/5283 For element-hq/wat-internal#188 Split out from https://github.com/element-hq/element-web/pull/33184 * Link to js-sdk branch * Update tests * Simplify * Revert js-sdk linking * Iterate * Iterate * Refactor to handle most of the TODOs * Remove unused code * Remove unused code * Use js-sdk isSignInWithQRAvailable API to simplify code * Restore app-test.ts * Improve coverage * Improve coverage * Remove unused prop/state * Iterate * Fix tests * Iterate * Tests * Handle TODOs * Docs * Remove redundant call to crossSignDevice() * Workaround to remove training slash on the serverName before auto-discovery * Revert "Workaround to remove training slash on the serverName before auto-discovery" This reverts commit 0335a8fdd1b8e8d949ab7fca17c76f8fab335b58. * setLoggedIn not to be used with OIDC flows as it clears storage as per docs on setLoggedIn we should use restoreSessionFromStorage * Don't show the security_code_prompt unconditionally(i.e. for the web logging in mobile flow) * Update LoginWithQRFlow-test.tsx.snap * Update MatrixChat-test from setLoggedInSpy to restoreSessionSpy * Add todo for server switch * Add todo about handling base URL or server name * Handle server name or base URL being returned * Format * Fix loading state height * Handle the homeserver URL differing during QR code login * Comments * Comments * Register OIDC client ID after homeserver swap * Make QrLoginDialog async to minimise the impact on bundle size * Handle unsupported HS earlier in the flow * Iterate * Delint * Fix test * Discard changes to apps/web/element.io/develop/config.json --------- Co-authored-by: Hugh Nimmo-Smith <hughns@element.io> Co-authored-by: David Langley <langley.dave@gmail.com>
This commit is contained in:
co-authored by
Hugh Nimmo-Smith
David Langley
parent
d1a6137c90
commit
486fa57b68
@@ -28,6 +28,7 @@ import { UIFeature } from "../../../../../src/settings/UIFeature";
|
||||
import { SettingLevel } from "../../../../../src/settings/SettingLevel";
|
||||
import { SdkContextClass } from "../../../../../src/contexts/SDKContext";
|
||||
import { type FeatureSettingKey } from "../../../../../src/settings/Settings.tsx";
|
||||
import { mockOpenIdConfiguration } from "../../../../test-utils/oidc.ts";
|
||||
|
||||
mockPlatformPeg({
|
||||
supportsSpellCheckSettings: jest.fn().mockReturnValue(false),
|
||||
@@ -48,6 +49,7 @@ jest.mock("../../../../../src/settings/SettingsStore", () => ({
|
||||
shouldHaveWarning: jest.fn(),
|
||||
disabledMessage: jest.fn(),
|
||||
settingIsOveriddenAtConfigLevel: jest.fn(),
|
||||
doesSettingSupportLevel: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("<UserSettingsDialog />", () => {
|
||||
@@ -71,6 +73,8 @@ describe("<UserSettingsDialog />", () => {
|
||||
getIgnoredUsers: jest.fn().mockResolvedValue([]),
|
||||
getPushers: jest.fn().mockResolvedValue([]),
|
||||
getProfileInfo: jest.fn().mockResolvedValue({}),
|
||||
getMediaConfig: jest.fn(),
|
||||
getAuthMetadata: jest.fn().mockResolvedValue(mockOpenIdConfiguration()),
|
||||
});
|
||||
sdkContext = new SdkContextClass();
|
||||
sdkContext.client = mockClient;
|
||||
|
||||
@@ -14,11 +14,22 @@ import {
|
||||
MSC4108FailureReason,
|
||||
MSC4108SignInWithQR,
|
||||
RendezvousError,
|
||||
RendezvousIntent,
|
||||
} from "matrix-js-sdk/src/rendezvous";
|
||||
import { HTTPError, type MatrixClient, MatrixHttpApi } from "matrix-js-sdk/src/matrix";
|
||||
import { mockOpenIdConfiguration } from "matrix-js-sdk/src/testing";
|
||||
import {
|
||||
AutoDiscovery,
|
||||
AutoDiscoveryAction,
|
||||
HTTPError,
|
||||
type MatrixClient,
|
||||
MatrixHttpApi,
|
||||
OAuthGrantType,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import fetchMock from "@fetch-mock/jest";
|
||||
|
||||
import LoginWithQR, { LoginWithQRFailureReason } from "../../../../../../src/components/views/auth/LoginWithQR";
|
||||
import { Click, Mode, Phase } from "../../../../../../src/components/views/auth/LoginWithQR-types";
|
||||
import { mockPlatformPeg } from "../../../../../test-utils";
|
||||
|
||||
jest.mock("matrix-js-sdk/src/rendezvous/transports");
|
||||
jest.mock("matrix-js-sdk/src/rendezvous/channels");
|
||||
@@ -50,6 +61,7 @@ function makeClient() {
|
||||
getClientWellKnown: jest.fn().mockReturnValue({}),
|
||||
getCrypto: jest.fn().mockReturnValue({}),
|
||||
getDomain: jest.fn(),
|
||||
getAuthMetadata: jest.fn().mockReturnValue(mockOpenIdConfiguration()),
|
||||
} as unknown as MatrixClient);
|
||||
|
||||
cli.http = new MatrixHttpApi(cli, {
|
||||
@@ -80,209 +92,313 @@ describe("<LoginWithQR />", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client = makeClient();
|
||||
jest.clearAllMocks();
|
||||
jest.useRealTimers();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("MSC4108", () => {
|
||||
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());
|
||||
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({
|
||||
phase: Phase.ShowingQR,
|
||||
onClick: expect.any(Function),
|
||||
}),
|
||||
describe("reciprocate", () => {
|
||||
const getComponent = (props: {
|
||||
client: MatrixClient;
|
||||
onFinished?: () => void;
|
||||
ref?: RefObject<LoginWithQR | null>;
|
||||
}) => (
|
||||
<LoginWithQR
|
||||
{...defaultProps}
|
||||
{...props}
|
||||
intent={RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE}
|
||||
/>
|
||||
);
|
||||
|
||||
const rendezvous = ref.current!.state.rendezvous!;
|
||||
expect(rendezvous.generateCode).toHaveBeenCalled();
|
||||
expect(rendezvous.negotiateProtocols).toHaveBeenCalled();
|
||||
test("render QR then back", async () => {
|
||||
const onFinished = jest.fn();
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockReturnValue(unresolvedPromise());
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "generateCode");
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols");
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "cancel");
|
||||
const ref = createRef<LoginWithQR>();
|
||||
render(getComponent({ client, onFinished, ref }));
|
||||
|
||||
// back
|
||||
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||
await onClick(Click.Back);
|
||||
expect(onFinished).toHaveBeenCalledWith(false);
|
||||
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UserCancelled);
|
||||
});
|
||||
|
||||
test("should open a new channel if expires before qr scan", async () => {
|
||||
const onFinished = jest.fn();
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockReturnValue(unresolvedPromise());
|
||||
const ref = createRef<LoginWithQR>();
|
||||
render(getComponent({ client, onFinished, ref }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||
phase: Phase.ShowingQR,
|
||||
onClick: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
|
||||
const rendezvous = ref.current!.state.rendezvous!;
|
||||
expect(rendezvous.generateCode).toHaveBeenCalled();
|
||||
expect(rendezvous.negotiateProtocols).toHaveBeenCalled();
|
||||
|
||||
// Expire the channel
|
||||
rendezvous.onFailure!(ClientRendezvousFailureReason.Expired);
|
||||
await jest.runAllTimersAsync();
|
||||
await waitFor(() => expect(ref.current!.state.rendezvous).toBeDefined());
|
||||
expect(ref.current!.state.rendezvous).not.toBe(rendezvous);
|
||||
});
|
||||
|
||||
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("should show error if check code doesn't match", 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, "12");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||
phase: Phase.OutOfBandConfirmation,
|
||||
failureReason: LoginWithQRFailureReason.CheckCodeMismatch,
|
||||
onClick: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("reciprocates login", async () => {
|
||||
const ref = createRef<LoginWithQR>();
|
||||
jest.spyOn(global.window, "open");
|
||||
|
||||
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",
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
const rendezvous = ref.current!.state.rendezvous!;
|
||||
expect(rendezvous.shareSecrets).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles errors during protocol negotiation", async () => {
|
||||
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
|
||||
err.code = MSC4108FailureReason.UnsupportedProtocol;
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockRejectedValue(err);
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||
phase: Phase.ShowingQR,
|
||||
onClick: expect.any(Function),
|
||||
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE,
|
||||
}),
|
||||
),
|
||||
);
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const rendezvous = ref.current!.state.rendezvous!;
|
||||
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UnsupportedProtocol);
|
||||
expect(rendezvous.generateCode).toHaveBeenCalled();
|
||||
expect(rendezvous.negotiateProtocols).toHaveBeenCalled();
|
||||
|
||||
// back (cancel)
|
||||
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||
await onClick(Click.Cancel);
|
||||
expect(onFinished).toHaveBeenCalledWith(false);
|
||||
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UserCancelled);
|
||||
});
|
||||
|
||||
test("should open a new channel if expires before qr scan", async () => {
|
||||
const onFinished = jest.fn();
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockReturnValue(unresolvedPromise());
|
||||
const ref = createRef<LoginWithQR>();
|
||||
render(getComponent({ client, onFinished, ref }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||
phase: Phase.ShowingQR,
|
||||
onClick: expect.any(Function),
|
||||
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE,
|
||||
}),
|
||||
);
|
||||
|
||||
const rendezvous = ref.current!.state.rendezvous!;
|
||||
expect(rendezvous.generateCode).toHaveBeenCalled();
|
||||
expect(rendezvous.negotiateProtocols).toHaveBeenCalled();
|
||||
|
||||
// Expire the channel
|
||||
rendezvous.onFailure!(ClientRendezvousFailureReason.Expired);
|
||||
await jest.runAllTimersAsync();
|
||||
await waitFor(() => expect(ref.current!.state.rendezvous).toBeDefined());
|
||||
expect(ref.current!.state.rendezvous).not.toBe(rendezvous);
|
||||
});
|
||||
|
||||
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("should show error if check code doesn't match", 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),
|
||||
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE,
|
||||
}),
|
||||
);
|
||||
|
||||
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||
await onClick(Click.Approve, "12");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||
phase: Phase.OutOfBandConfirmation,
|
||||
failureReason: LoginWithQRFailureReason.CheckCodeMismatch,
|
||||
onClick: expect.any(Function),
|
||||
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("reciprocates login", async () => {
|
||||
const ref = createRef<LoginWithQR>();
|
||||
jest.spyOn(global.window, "open");
|
||||
|
||||
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",
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||
phase: Phase.OutOfBandConfirmation,
|
||||
onClick: expect.any(Function),
|
||||
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE,
|
||||
}),
|
||||
);
|
||||
|
||||
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||
await onClick(Click.Approve);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||
phase: Phase.WaitingForDevice,
|
||||
onClick: expect.any(Function),
|
||||
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE,
|
||||
}),
|
||||
);
|
||||
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 () => {
|
||||
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
|
||||
err.code = MSC4108FailureReason.UnsupportedProtocol;
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockRejectedValue(err);
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
phase: Phase.ShowingQR,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const rendezvous = ref.current!.state.rendezvous!;
|
||||
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UnsupportedProtocol);
|
||||
});
|
||||
});
|
||||
|
||||
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),
|
||||
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE,
|
||||
}),
|
||||
);
|
||||
|
||||
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 () => {
|
||||
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({});
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||
phase: Phase.OutOfBandConfirmation,
|
||||
onClick: expect.any(Function),
|
||||
intent: RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE,
|
||||
}),
|
||||
);
|
||||
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "cancel").mockResolvedValue();
|
||||
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||
await onClick(Click.Cancel);
|
||||
|
||||
const rendezvous = ref.current!.state.rendezvous!;
|
||||
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UserCancelled);
|
||||
});
|
||||
});
|
||||
|
||||
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),
|
||||
}),
|
||||
describe("login", () => {
|
||||
const getComponent = (props: {
|
||||
client: MatrixClient;
|
||||
onFinished?: () => void;
|
||||
onLoggedIn?: () => Promise<void>;
|
||||
ref?: RefObject<LoginWithQR | null>;
|
||||
}) => (
|
||||
<LoginWithQR
|
||||
onLoggedIn={jest.fn()}
|
||||
{...defaultProps}
|
||||
{...props}
|
||||
intent={RendezvousIntent.LOGIN_ON_NEW_DEVICE}
|
||||
/>
|
||||
);
|
||||
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "shareSecrets").mockRejectedValue(
|
||||
new HTTPError("Internal Server Error", 500),
|
||||
);
|
||||
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||
await onClick(Click.Approve);
|
||||
test("should handle qr login", async () => {
|
||||
fetchMock.get("https://hs/_matrix/client/versions", {
|
||||
unstable_features: {},
|
||||
versions: ["v1.1", "v1.5", "v1.6", "v1.8", "v1.9"],
|
||||
});
|
||||
const authMetadata = {
|
||||
...mockOpenIdConfiguration("https://auth.org/", [OAuthGrantType.DeviceAuthorization]),
|
||||
jwks_uri: undefined,
|
||||
};
|
||||
fetchMock.get("https://hs/_matrix/client/unstable/org.matrix.msc2965/auth_metadata", authMetadata);
|
||||
fetchMock.post(authMetadata.registration_endpoint!, {
|
||||
client_id: "!client_id!",
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
phase: Phase.Error,
|
||||
failureReason: ClientRendezvousFailureReason.Unknown,
|
||||
mockPlatformPeg({
|
||||
getOidcClientMetadata: jest.fn().mockReturnValue({
|
||||
clientName: "App name",
|
||||
clientUri: "https://company",
|
||||
redirectUris: ["https://app"],
|
||||
logoUri: "https://company/logo.png",
|
||||
applicationType: "web",
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("handles user cancelling during reciprocation", async () => {
|
||||
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({});
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||
phase: Phase.OutOfBandConfirmation,
|
||||
onClick: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
const ref = createRef<LoginWithQR>();
|
||||
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "cancel").mockResolvedValue();
|
||||
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||
await onClick(Click.Cancel);
|
||||
render(getComponent({ client, ref }));
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "shareSecrets").mockResolvedValue({
|
||||
secrets: {
|
||||
cross_signing: {
|
||||
master_key: "mk",
|
||||
self_signing_key: "ssk",
|
||||
user_signing_key: "usk",
|
||||
},
|
||||
},
|
||||
});
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "negotiateProtocols").mockResolvedValue({ serverName: "hs" });
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "deviceAuthorizationGrant").mockResolvedValue({
|
||||
userCode: "123456",
|
||||
});
|
||||
jest.spyOn(MSC4108SignInWithQR.prototype, "completeLoginOnNewDevice").mockResolvedValue({
|
||||
access_token: "token",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
jest.spyOn(AutoDiscovery, "findClientConfig").mockResolvedValue({
|
||||
"m.homeserver": { base_url: "https://hs", state: AutoDiscoveryAction.SUCCESS },
|
||||
"m.identity_server": { state: AutoDiscoveryAction.PROMPT },
|
||||
});
|
||||
|
||||
const rendezvous = ref.current!.state.rendezvous!;
|
||||
expect(rendezvous.cancel).toHaveBeenCalledWith(MSC4108FailureReason.UserCancelled);
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||
phase: Phase.OutOfBandConfirmation,
|
||||
onClick: expect.any(Function),
|
||||
intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE,
|
||||
}),
|
||||
);
|
||||
|
||||
const onClick = mockedFlow.mock.calls[0][0].onClick;
|
||||
await onClick(Click.Approve);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedFlow).toHaveBeenLastCalledWith({
|
||||
phase: Phase.WaitingForDevice,
|
||||
onClick: expect.any(Function),
|
||||
intent: RendezvousIntent.LOGIN_ON_NEW_DEVICE,
|
||||
userCode: "123456",
|
||||
}),
|
||||
);
|
||||
|
||||
const rendezvous = ref.current!.state.rendezvous!;
|
||||
expect(rendezvous.shareSecrets).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+90
-56
@@ -8,11 +8,11 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from "jest-matrix-react";
|
||||
import React from "react";
|
||||
import { ClientRendezvousFailureReason, MSC4108FailureReason } from "matrix-js-sdk/src/rendezvous";
|
||||
import { ClientRendezvousFailureReason, MSC4108FailureReason, RendezvousIntent } from "matrix-js-sdk/src/rendezvous";
|
||||
|
||||
import { mockQRCodeRender, resetQRCodeMock, waitForQRCodeRender } from "../../../../../test-utils/qrcode";
|
||||
import LoginWithQRFlow from "../../../../../../src/components/views/auth/LoginWithQRFlow";
|
||||
import { LoginWithQRFailureReason, type FailureReason } from "../../../../../../src/components/views/auth/LoginWithQR";
|
||||
import { type FailureReason, LoginWithQRFailureReason } from "../../../../../../src/components/views/auth/LoginWithQR";
|
||||
import { Click, Phase } from "../../../../../../src/components/views/auth/LoginWithQR-types";
|
||||
|
||||
describe("<LoginWithQRFlow />", () => {
|
||||
@@ -27,72 +27,106 @@ describe("<LoginWithQRFlow />", () => {
|
||||
onClick?: () => Promise<void>;
|
||||
failureReason?: FailureReason;
|
||||
code?: Uint8Array;
|
||||
intent: RendezvousIntent;
|
||||
}) => <LoginWithQRFlow {...defaultProps} {...props} />;
|
||||
|
||||
beforeEach(() => {});
|
||||
|
||||
afterEach(() => {
|
||||
resetQRCodeMock();
|
||||
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("spinner")).toHaveLength(1);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders QR code", async () => {
|
||||
mockQRCodeRender();
|
||||
const { container } = render(
|
||||
getComponent({ phase: Phase.ShowingQR, code: new TextEncoder().encode("mock-code") }),
|
||||
);
|
||||
// QR code is rendered async so we wait for it:
|
||||
await waitForQRCodeRender();
|
||||
expect(screen.getAllByAltText("QR Code")).toHaveLength(1);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
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, undefined);
|
||||
});
|
||||
|
||||
it("renders spinner while verifying", async () => {
|
||||
const { container } = render(getComponent({ phase: Phase.Verifying }));
|
||||
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(MSC4108FailureReason),
|
||||
...Object.values(LoginWithQRFailureReason),
|
||||
...Object.values(ClientRendezvousFailureReason),
|
||||
]) {
|
||||
it(`renders ${failureReason}`, async () => {
|
||||
describe.each([RendezvousIntent.LOGIN_ON_NEW_DEVICE, RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE])(
|
||||
"%s",
|
||||
(intent) => {
|
||||
it("renders spinner while loading", async () => {
|
||||
const { container } = render(
|
||||
getComponent({
|
||||
phase: Phase.Error,
|
||||
failureReason,
|
||||
phase: Phase.Loading,
|
||||
intent,
|
||||
}),
|
||||
);
|
||||
expect(screen.getAllByTestId("cancellation-message")).toHaveLength(1);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("renders spinner whilst QR generating", async () => {
|
||||
const { container } = render(
|
||||
getComponent({
|
||||
phase: Phase.ShowingQR,
|
||||
intent,
|
||||
}),
|
||||
);
|
||||
expect(screen.getByTestId("spinner")).toBeVisible();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders QR code", async () => {
|
||||
mockQRCodeRender();
|
||||
const { container } = render(
|
||||
getComponent({
|
||||
phase: Phase.ShowingQR,
|
||||
code: new TextEncoder().encode("mock-code"),
|
||||
intent,
|
||||
}),
|
||||
);
|
||||
// QR code is rendered async so we wait for it:
|
||||
await waitForQRCodeRender();
|
||||
expect(screen.getAllByAltText("QR Code")).toHaveLength(1);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders spinner while signing in", async () => {
|
||||
const { container } = render(
|
||||
getComponent({
|
||||
phase: Phase.WaitingForDevice,
|
||||
intent,
|
||||
}),
|
||||
);
|
||||
expect(screen.getAllByTestId("cancel-button")).toHaveLength(1);
|
||||
expect(container).toMatchSnapshot();
|
||||
fireEvent.click(screen.getByTestId("cancel-button"));
|
||||
expect(onClick).toHaveBeenCalledWith(Click.Cancel, undefined);
|
||||
});
|
||||
|
||||
it("renders spinner while verifying", async () => {
|
||||
const { container } = render(
|
||||
getComponent({
|
||||
phase: Phase.Verifying,
|
||||
intent,
|
||||
}),
|
||||
);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders check code confirmation", async () => {
|
||||
const { container } = render(
|
||||
getComponent({
|
||||
phase: Phase.OutOfBandConfirmation,
|
||||
intent,
|
||||
}),
|
||||
);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("errors", () => {
|
||||
for (const failureReason of [
|
||||
...Object.values(MSC4108FailureReason),
|
||||
...Object.values(LoginWithQRFailureReason),
|
||||
...Object.values(ClientRendezvousFailureReason),
|
||||
]) {
|
||||
it(`renders ${failureReason}`, async () => {
|
||||
const { container } = render(
|
||||
getComponent({
|
||||
phase: Phase.Error,
|
||||
failureReason,
|
||||
intent,
|
||||
}),
|
||||
);
|
||||
expect(screen.getAllByTestId("cancellation-message")).toHaveLength(1);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
+1718
-101
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user