/* Copyright 2024 New Vector Ltd. Copyright 2022 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import React from "react"; import { mocked } from "jest-mock"; import { act, render, RenderResult, screen, waitFor } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import { MatrixClient, createClient } from "matrix-js-sdk/src/matrix"; import ForgotPassword from "../../../../../src/components/structures/auth/ForgotPassword"; import { ValidatedServerConfig } from "../../../../../src/utils/ValidatedServerConfig"; import { clearAllModals, filterConsole, flushPromisesWithFakeTimers, stubClient, waitEnoughCyclesForModal, } from "../../../../test-utils"; import AutoDiscoveryUtils from "../../../../../src/utils/AutoDiscoveryUtils"; jest.mock("matrix-js-sdk/src/matrix", () => ({ ...jest.requireActual("matrix-js-sdk/src/matrix"), createClient: jest.fn(), })); describe("", () => { const testEmail = "user@example.com"; const testSid = "sid42"; const testPassword = "cRaZyP4ssw0rd!"; let client: MatrixClient; let serverConfig: ValidatedServerConfig; let onComplete: () => void; let onLoginClick: () => void; let renderResult: RenderResult; const typeIntoField = async (label: string, value: string): Promise => { await act(async () => { await userEvent.type(screen.getByLabelText(label), value, { delay: null }); // the message is shown after some time jest.advanceTimersByTime(500); }); }; const click = async (element: Element): Promise => { await userEvent.click(element, { delay: null }); }; const itShouldCloseTheDialogAndShowThePasswordInput = (): void => { it("should close the dialog and show the password input", async () => { await waitFor(() => expect(screen.queryByText("Verify your email to continue")).not.toBeInTheDocument()); expect(screen.getByText("Reset your password")).toBeInTheDocument(); }); }; filterConsole( // not implemented by js-dom https://github.com/jsdom/jsdom/issues/1937 "Not implemented: HTMLFormElement.prototype.requestSubmit", ); beforeEach(() => { client = stubClient(); mocked(createClient).mockReturnValue(client); serverConfig = { hsName: "example.com" } as ValidatedServerConfig; onComplete = jest.fn(); onLoginClick = jest.fn(); jest.spyOn(AutoDiscoveryUtils, "validateServerConfigWithStaticUrls").mockResolvedValue(serverConfig); jest.spyOn(AutoDiscoveryUtils, "authComponentStateForError"); }); afterEach(async () => { // clean up modals await clearAllModals(); }); beforeAll(() => { jest.useFakeTimers(); }); afterAll(() => { jest.useRealTimers(); }); describe("when starting a password reset flow", () => { beforeEach(() => { renderResult = render( , ); }); it("should show the email input and mention the homeserver", () => { expect(screen.queryByLabelText("Email address")).toBeInTheDocument(); expect(screen.queryByText("example.com")).toBeInTheDocument(); }); describe("and updating the server config", () => { beforeEach(() => { serverConfig.hsName = "example2.com"; renderResult.rerender( , ); }); it("should show the new homeserver server name", () => { expect(screen.queryByText("example2.com")).toBeInTheDocument(); }); }); describe("and clicking »Sign in instead«", () => { beforeEach(async () => { await click(screen.getByText("Sign in instead")); }); it("should call onLoginClick()", () => { expect(onLoginClick).toHaveBeenCalled(); }); }); describe("and entering a non-email value", () => { beforeEach(async () => { await typeIntoField("Email address", "not en email"); }); it("should show a message about the wrong format", () => { expect(screen.getByText("The email address doesn't appear to be valid.")).toBeInTheDocument(); }); }); describe("and submitting an unknown email", () => { beforeEach(async () => { await typeIntoField("Email address", testEmail); mocked(client).requestPasswordEmailToken.mockRejectedValue({ errcode: "M_THREEPID_NOT_FOUND", }); await click(screen.getByText("Send email")); }); it("should show an email not found message", () => { expect(screen.getByText("This email address was not found")).toBeInTheDocument(); }); }); describe("and a connection error occurs", () => { beforeEach(async () => { await typeIntoField("Email address", testEmail); mocked(client).requestPasswordEmailToken.mockRejectedValue({ name: "ConnectionError", }); await click(screen.getByText("Send email")); }); it("should show an info about that", () => { expect( screen.getByText( "Cannot reach homeserver: " + "Ensure you have a stable internet connection, or get in touch with the server admin", ), ).toBeInTheDocument(); }); }); describe("and the server liveness check fails", () => { beforeEach(async () => { await typeIntoField("Email address", testEmail); mocked(AutoDiscoveryUtils.validateServerConfigWithStaticUrls).mockRejectedValue({}); mocked(AutoDiscoveryUtils.authComponentStateForError).mockReturnValue({ serverErrorIsFatal: true, serverIsAlive: false, serverDeadError: "server down", }); await click(screen.getByText("Send email")); }); it("should show the server error", () => { expect(screen.queryByText("server down")).toBeInTheDocument(); }); }); describe("and submitting an known email", () => { beforeEach(async () => { await typeIntoField("Email address", testEmail); mocked(client).requestPasswordEmailToken.mockResolvedValue({ sid: testSid, }); await click(screen.getByText("Send email")); }); it("should send the mail and show the check email view", () => { expect(client.requestPasswordEmailToken).toHaveBeenCalledWith( testEmail, expect.any(String), 1, // second send attempt ); expect(screen.getByText("Check your email to continue")).toBeInTheDocument(); expect(screen.getByText(testEmail)).toBeInTheDocument(); }); describe("and clicking »Re-enter email address«", () => { beforeEach(async () => { await click(screen.getByText("Re-enter email address")); }); it("go back to the email input", () => { expect(screen.queryByText("Enter your email to reset password")).toBeInTheDocument(); }); }); describe("and clicking »Resend«", () => { beforeEach(async () => { await click(screen.getByText("Resend")); // the message is shown after some time jest.advanceTimersByTime(500); }); it("should should resend the mail and show the tooltip", () => { expect(client.requestPasswordEmailToken).toHaveBeenCalledWith( testEmail, expect.any(String), 2, // second send attempt ); expect( screen.getByRole("tooltip", { name: "Verification link email resent!" }), ).toBeInTheDocument(); }); }); describe("and clicking »Next«", () => { beforeEach(async () => { await click(screen.getByText("Next")); }); it("should show the password input view", () => { expect(screen.getByText("Reset your password")).toBeInTheDocument(); }); describe("and entering different passwords", () => { beforeEach(async () => { await typeIntoField("New Password", testPassword); await typeIntoField("Confirm new password", testPassword + "asd"); }); it("should show an info about that", () => { expect(screen.getByText("New passwords must match each other.")).toBeInTheDocument(); }); }); describe("and entering a new password", () => { beforeEach(async () => { mocked(client.setPassword).mockRejectedValue({ httpStatus: 401 }); await typeIntoField("New Password", testPassword); await typeIntoField("Confirm new password", testPassword); }); describe("and submitting it running into rate limiting", () => { beforeEach(async () => { mocked(client.setPassword).mockRejectedValue({ message: "rate limit reached", httpStatus: 429, data: { retry_after_ms: (13 * 60 + 37) * 1000, }, }); await click(screen.getByText("Reset password")); }); it("should show the rate limit error message", () => { expect( screen.getByText("Too many attempts in a short time. Retry after 13:37."), ).toBeInTheDocument(); }); }); describe("and confirm the email link and submitting the new password", () => { beforeEach(async () => { // fake link confirmed by resolving client.setPassword instead of raising an error mocked(client.setPassword).mockResolvedValue({}); await click(screen.getByText("Reset password")); }); it("should send the new password (once)", () => { expect(client.setPassword).toHaveBeenCalledWith( { type: "m.login.email.identity", threepid_creds: { client_secret: expect.any(String), sid: testSid, }, }, testPassword, false, ); // be sure that the next attempt to set the password would have been sent jest.advanceTimersByTime(3000); // it should not retry to set the password expect(client.setPassword).toHaveBeenCalledTimes(1); }); }); describe("and submitting it", () => { beforeEach(async () => { await click(screen.getByText("Reset password")); await waitEnoughCyclesForModal({ useFakeTimers: true, }); }); it("should send the new password and show the click validation link dialog", async () => { expect(client.setPassword).toHaveBeenCalledWith( { type: "m.login.email.identity", threepid_creds: { client_secret: expect.any(String), sid: testSid, }, }, testPassword, false, ); await expect( screen.findByText("Verify your email to continue"), ).resolves.toBeInTheDocument(); expect(screen.getByText(testEmail)).toBeInTheDocument(); }); describe("and dismissing the dialog by clicking the background", () => { beforeEach(async () => { await userEvent.click(await screen.findByTestId("dialog-background"), { delay: null }); await waitEnoughCyclesForModal({ useFakeTimers: true, }); }); itShouldCloseTheDialogAndShowThePasswordInput(); }); describe("and dismissing the dialog", () => { beforeEach(async () => { await click(await screen.findByLabelText("Close dialog")); await waitEnoughCyclesForModal({ useFakeTimers: true, }); }); itShouldCloseTheDialogAndShowThePasswordInput(); }); describe("and clicking »Re-enter email address«", () => { beforeEach(async () => { await click(await screen.findByText("Re-enter email address")); await waitEnoughCyclesForModal({ useFakeTimers: true, }); }); it("should close the dialog and go back to the email input", async () => { await waitFor(() => expect(screen.queryByText("Verify your email to continue")).not.toBeInTheDocument(), ); expect(screen.queryByText("Enter your email to reset password")).toBeInTheDocument(); }); }); describe("and validating the link from the mail", () => { beforeEach(async () => { mocked(client.setPassword).mockResolvedValue({}); // be sure the next set password attempt was sent jest.advanceTimersByTime(3000); // quad flush promises for the modal to disappear await flushPromisesWithFakeTimers(); await flushPromisesWithFakeTimers(); await flushPromisesWithFakeTimers(); await flushPromisesWithFakeTimers(); }); it("should display the confirm reset view and now show the dialog", () => { expect(screen.queryByText("Your password has been reset.")).toBeInTheDocument(); expect(screen.queryByText("Verify your email to continue")).not.toBeInTheDocument(); }); }); }); describe("and clicking »Sign out of all devices« and »Reset password«", () => { beforeEach(async () => { await click(screen.getByText("Sign out of all devices")); await click(screen.getByText("Reset password")); await waitEnoughCyclesForModal({ useFakeTimers: true, }); }); it("should show the sign out warning dialog", async () => { await expect( screen.findByText( "Signing out your devices will delete the message encryption keys stored on them, making encrypted chat history unreadable.", ), ).resolves.toBeInTheDocument(); // confirm dialog await click(screen.getByText("Continue")); // expect setPassword with logoutDevices = true expect(client.setPassword).toHaveBeenCalledWith( { type: "m.login.email.identity", threepid_creds: { client_secret: expect.any(String), sid: testSid, }, }, testPassword, true, ); }); }); }); }); }); }); });