/* Copyright 2024 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import { render, screen } from "@testing-library/react"; import { MatrixClient, ThreepidMedium } from "matrix-js-sdk/src/matrix"; import React from "react"; import userEvent from "@testing-library/user-event"; import { mocked } from "jest-mock"; import { AddRemoveThreepids } from "../../../../src/components/views/settings/AddRemoveThreepids"; import { clearAllModals, stubClient } from "../../../test-utils"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import Modal from "../../../../src/Modal"; const MOCK_IDENTITY_ACCESS_TOKEN = "mock_identity_access_token"; const mockGetAccessToken = jest.fn().mockResolvedValue(MOCK_IDENTITY_ACCESS_TOKEN); jest.mock("../../../../src/IdentityAuthClient", () => jest.fn().mockImplementation(() => ({ getAccessToken: mockGetAccessToken, })), ); const EMAIL1 = { medium: ThreepidMedium.Email, address: "alice@nowhere.dummy", }; const PHONE1 = { medium: ThreepidMedium.Phone, address: "447700900000", }; const PHONE1_LOCALNUM = "07700900000"; describe("AddRemoveThreepids", () => { let client: MatrixClient; beforeEach(() => { client = stubClient(); }); afterEach(() => { jest.restoreAllMocks(); clearAllModals(); }); const clientProviderWrapper: React.FC = ({ children }) => ( {children} ); it("should render a loader while loading", async () => { render( {}} />, ); expect(screen.getByLabelText("Loading…")).toBeInTheDocument(); }); it("should render email addresses", async () => { const { container } = render( {}} />, ); expect(container).toMatchSnapshot(); }); it("should render phone numbers", async () => { const { container } = render( {}} />, ); expect(container).toMatchSnapshot(); }); it("should handle no email addresses", async () => { const { container } = render( {}} />, ); expect(container).toMatchSnapshot(); }); it("should add an email address", async () => { const onChangeFn = jest.fn(); mocked(client.requestAdd3pidEmailToken).mockResolvedValue({ sid: "1" }); render( , { wrapper: clientProviderWrapper, }, ); const input = screen.getByRole("textbox", { name: "Email Address" }); await userEvent.type(input, EMAIL1.address); const addButton = screen.getByRole("button", { name: "Add" }); await userEvent.click(addButton); expect(client.requestAdd3pidEmailToken).toHaveBeenCalledWith(EMAIL1.address, client.generateClientSecret(), 1); const continueButton = screen.getByRole("button", { name: "Continue" }); expect(continueButton).toBeEnabled(); await userEvent.click(continueButton); expect(client.addThreePidOnly).toHaveBeenCalledWith({ client_secret: client.generateClientSecret(), sid: "1", auth: undefined, }); expect(onChangeFn).toHaveBeenCalled(); }); it("should display an error if the link has not been clicked", async () => { const onChangeFn = jest.fn(); const createDialogFn = jest.spyOn(Modal, "createDialog"); mocked(client.requestAdd3pidEmailToken).mockResolvedValue({ sid: "1" }); render( , { wrapper: clientProviderWrapper, }, ); const input = screen.getByRole("textbox", { name: "Email Address" }); await userEvent.type(input, EMAIL1.address); const addButton = screen.getByRole("button", { name: "Add" }); await userEvent.click(addButton); const continueButton = screen.getByRole("button", { name: "Continue" }); expect(continueButton).toBeEnabled(); mocked(client).addThreePidOnly.mockRejectedValueOnce(new Error("Unauthorized")); await userEvent.click(continueButton); expect(createDialogFn).toHaveBeenCalledWith(expect.anything(), { description: "Unauthorized", title: "Unable to verify email address.", }); expect(onChangeFn).not.toHaveBeenCalled(); }); it("should add a phone number", async () => { const onChangeFn = jest.fn(); mocked(client.requestAdd3pidMsisdnToken).mockResolvedValue({ sid: "1", msisdn: PHONE1.address, intl_fmt: "+" + PHONE1.address, success: true, submit_url: "https://example.dummy", }); render( , { wrapper: clientProviderWrapper, }, ); const countryDropdown = screen.getByRole("button", { name: "Country Dropdown" }); await userEvent.click(countryDropdown); const gbOption = screen.getByRole("option", { name: "🇬🇧 United Kingdom (+44)" }); await userEvent.click(gbOption); const input = screen.getByRole("textbox", { name: "Phone Number" }); await userEvent.type(input, PHONE1_LOCALNUM); const addButton = screen.getByRole("button", { name: "Add" }); await userEvent.click(addButton); expect(client.requestAdd3pidMsisdnToken).toHaveBeenCalledWith( "GB", PHONE1_LOCALNUM, client.generateClientSecret(), 1, ); const continueButton = screen.getByRole("button", { name: "Continue" }); expect(continueButton).toHaveAttribute("aria-disabled", "true"); const verificationInput = screen.getByRole("textbox", { name: "Verification code" }); await userEvent.type(verificationInput, "123456"); expect(continueButton).not.toHaveAttribute("aria-disabled", "true"); await userEvent.click(continueButton); expect(client.addThreePidOnly).toHaveBeenCalledWith({ client_secret: client.generateClientSecret(), sid: "1", auth: undefined, }); expect(onChangeFn).toHaveBeenCalled(); }); it("should display an error if the code is incorrect", async () => { const onChangeFn = jest.fn(); const createDialogFn = jest.spyOn(Modal, "createDialog"); mocked(client.requestAdd3pidMsisdnToken).mockResolvedValue({ sid: "1", msisdn: PHONE1.address, intl_fmt: "+" + PHONE1.address, success: true, submit_url: "https://example.dummy", }); render( , { wrapper: clientProviderWrapper, }, ); const input = screen.getByRole("textbox", { name: "Phone Number" }); await userEvent.type(input, PHONE1_LOCALNUM); const countryDropdown = screen.getByRole("button", { name: "Country Dropdown" }); await userEvent.click(countryDropdown); const gbOption = screen.getByRole("option", { name: "🇬🇧 United Kingdom (+44)" }); await userEvent.click(gbOption); const addButton = screen.getByRole("button", { name: "Add" }); await userEvent.click(addButton); mocked(client).addThreePidOnly.mockRejectedValueOnce(new Error("Unauthorized")); const verificationInput = screen.getByRole("textbox", { name: "Verification code" }); await userEvent.type(verificationInput, "123457"); const continueButton = screen.getByRole("button", { name: "Continue" }); await userEvent.click(continueButton); expect(createDialogFn).toHaveBeenCalledWith(expect.anything(), { description: "Unauthorized", title: "Unable to verify phone number.", }); expect(onChangeFn).not.toHaveBeenCalled(); }); it("should remove an email address", async () => { const onChangeFn = jest.fn(); render( , { wrapper: clientProviderWrapper, }, ); const removeButton = screen.getByRole("button", { name: "Remove" }); await userEvent.click(removeButton); expect(screen.getByText(`Remove ${EMAIL1.address}?`)).toBeVisible(); const confirmRemoveButton = screen.getByRole("button", { name: "Remove" }); await userEvent.click(confirmRemoveButton); expect(client.deleteThreePid).toHaveBeenCalledWith(ThreepidMedium.Email, EMAIL1.address); expect(onChangeFn).toHaveBeenCalled(); }); it("should return to default view if adding is cancelled", async () => { const onChangeFn = jest.fn(); render( , { wrapper: clientProviderWrapper, }, ); const removeButton = screen.getByRole("button", { name: "Remove" }); await userEvent.click(removeButton); expect(screen.getByText(`Remove ${EMAIL1.address}?`)).toBeVisible(); const confirmRemoveButton = screen.getByRole("button", { name: "Cancel" }); await userEvent.click(confirmRemoveButton); expect(screen.queryByText(`Remove ${EMAIL1.address}?`)).not.toBeInTheDocument(); expect(client.deleteThreePid).not.toHaveBeenCalledWith(ThreepidMedium.Email, EMAIL1.address); expect(onChangeFn).not.toHaveBeenCalled(); }); it("should remove a phone number", async () => { const onChangeFn = jest.fn(); render( , { wrapper: clientProviderWrapper, }, ); const removeButton = screen.getByRole("button", { name: "Remove" }); await userEvent.click(removeButton); expect(screen.getByText(`Remove ${PHONE1.address}?`)).toBeVisible(); const confirmRemoveButton = screen.getByRole("button", { name: "Remove" }); await userEvent.click(confirmRemoveButton); expect(client.deleteThreePid).toHaveBeenCalledWith(ThreepidMedium.Phone, PHONE1.address); expect(onChangeFn).toHaveBeenCalled(); }); it("should bind an email address", async () => { mocked(client).requestEmailToken.mockResolvedValue({ sid: "1" }); mocked(client).getIdentityServerUrl.mockReturnValue("https://the_best_id_server.dummy"); const onChangeFn = jest.fn(); render( , { wrapper: clientProviderWrapper, }, ); expect(screen.getByText(EMAIL1.address)).toBeVisible(); const shareButton = screen.getByRole("button", { name: "Share" }); await userEvent.click(shareButton); expect(screen.getByText("Verify the link in your inbox")).toBeVisible(); expect(client.requestEmailToken).toHaveBeenCalledWith( EMAIL1.address, client.generateClientSecret(), 1, undefined, MOCK_IDENTITY_ACCESS_TOKEN, ); const completeButton = screen.getByRole("button", { name: "Complete" }); await userEvent.click(completeButton); expect(client.bindThreePid).toHaveBeenCalledWith({ sid: "1", client_secret: client.generateClientSecret(), id_server: "https://the_best_id_server.dummy", id_access_token: MOCK_IDENTITY_ACCESS_TOKEN, }); expect(onChangeFn).toHaveBeenCalled(); }); it("should bind a phone number", async () => { mocked(client).requestMsisdnToken.mockResolvedValue({ success: true, sid: "1", msisdn: PHONE1.address, intl_fmt: "+" + PHONE1.address, }); mocked(client).getIdentityServerUrl.mockReturnValue("https://the_best_id_server.dummy"); const onChangeFn = jest.fn(); render( , { wrapper: clientProviderWrapper, }, ); expect(screen.getByText(PHONE1.address)).toBeVisible(); const shareButton = screen.getByRole("button", { name: "Share" }); await userEvent.click(shareButton); expect(screen.getByText("Please enter verification code sent via text.")).toBeVisible(); expect(client.requestMsisdnToken).toHaveBeenCalledWith( null, "+" + PHONE1.address, client.generateClientSecret(), 1, undefined, MOCK_IDENTITY_ACCESS_TOKEN, ); const codeInput = screen.getByRole("textbox", { name: "Verification code" }); await userEvent.type(codeInput, "123456"); await userEvent.keyboard("{Enter}"); expect(client.bindThreePid).toHaveBeenCalledWith({ sid: "1", client_secret: client.generateClientSecret(), id_server: "https://the_best_id_server.dummy", id_access_token: MOCK_IDENTITY_ACCESS_TOKEN, }); expect(onChangeFn).toHaveBeenCalled(); }); it("should revoke a bound email address", async () => { const onChangeFn = jest.fn(); render( , { wrapper: clientProviderWrapper, }, ); expect(screen.getByText(EMAIL1.address)).toBeVisible(); const revokeButton = screen.getByRole("button", { name: "Revoke" }); await userEvent.click(revokeButton); expect(client.unbindThreePid).toHaveBeenCalledWith(ThreepidMedium.Email, EMAIL1.address); expect(onChangeFn).toHaveBeenCalled(); }); it("should revoke a bound phone number", async () => { const onChangeFn = jest.fn(); render( , { wrapper: clientProviderWrapper, }, ); expect(screen.getByText(PHONE1.address)).toBeVisible(); const revokeButton = screen.getByRole("button", { name: "Revoke" }); await userEvent.click(revokeButton); expect(client.unbindThreePid).toHaveBeenCalledWith(ThreepidMedium.Phone, PHONE1.address); expect(onChangeFn).toHaveBeenCalled(); }); });