Merge matrix-react-sdk into element-web

Merge remote-tracking branch 'repomerge/t3chguy/repomerge' into t3chguy/repo-merge

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski
2024-10-15 14:57:26 +01:00
3265 changed files with 484599 additions and 699 deletions

View File

@@ -0,0 +1,79 @@
/*
Copyright 2024 New Vector Ltd.
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 { render, screen } from "jest-matrix-react";
import { mocked } from "jest-mock";
import EventEmitter from "events";
import CompleteSecurity from "../../../../../src/components/structures/auth/CompleteSecurity";
import { stubClient } from "../../../../test-utils";
import { Phase, SetupEncryptionStore } from "../../../../../src/stores/SetupEncryptionStore";
import SdkConfig from "../../../../../src/SdkConfig";
class MockSetupEncryptionStore extends EventEmitter {
public phase: Phase = Phase.Intro;
public lostKeys(): boolean {
return false;
}
public start: () => void = jest.fn();
public stop: () => void = jest.fn();
}
describe("CompleteSecurity", () => {
beforeEach(() => {
const client = stubClient();
const deviceIdToDevice = new Map();
deviceIdToDevice.set("DEVICE_ID", {
deviceId: "DEVICE_ID",
userId: "USER_ID",
});
const userIdToDevices = new Map();
userIdToDevices.set("USER_ID", deviceIdToDevice);
mocked(client.getCrypto()!.getUserDeviceInfo).mockResolvedValue(userIdToDevices);
const mockSetupEncryptionStore = new MockSetupEncryptionStore();
jest.spyOn(SetupEncryptionStore, "sharedInstance").mockReturnValue(
mockSetupEncryptionStore as SetupEncryptionStore,
);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("Renders with a cancel button by default", () => {
render(<CompleteSecurity onFinished={() => {}} />);
expect(screen.getByRole("button", { name: "Skip verification for now" })).toBeInTheDocument();
});
it("Renders with a cancel button if forceVerification false", () => {
jest.spyOn(SdkConfig, "get").mockImplementation((key: string) => {
if (key === "forceVerification") {
return false;
}
});
render(<CompleteSecurity onFinished={() => {}} />);
expect(screen.getByRole("button", { name: "Skip verification for now" })).toBeInTheDocument();
});
it("Renders without a cancel button if forceVerification true", () => {
jest.spyOn(SdkConfig, "get").mockImplementation((key: string) => {
if (key === "force_verification") {
return true;
}
});
render(<CompleteSecurity onFinished={() => {}} />);
expect(screen.queryByRole("button", { name: "Skip verification for now" })).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,427 @@
/*
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 } 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("<ForgotPassword>", () => {
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<void> => {
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<void> => {
await act(async () => {
await userEvent.click(element, { delay: null });
});
};
const itShouldCloseTheDialogAndShowThePasswordInput = (): void => {
it("should close the dialog and show the password input", () => {
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(
<ForgotPassword serverConfig={serverConfig} onComplete={onComplete} onLoginClick={onLoginClick} />,
);
});
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(
<ForgotPassword serverConfig={serverConfig} onComplete={onComplete} onLoginClick={onLoginClick} />,
);
});
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", () => {
expect(client.setPassword).toHaveBeenCalledWith(
{
type: "m.login.email.identity",
threepid_creds: {
client_secret: expect.any(String),
sid: testSid,
},
},
testPassword,
false,
);
expect(screen.getByText("Verify your email to continue")).toBeInTheDocument();
expect(screen.getByText(testEmail)).toBeInTheDocument();
});
describe("and dismissing the dialog by clicking the background", () => {
beforeEach(async () => {
await act(async () => {
await userEvent.click(screen.getByTestId("dialog-background"), { delay: null });
});
await waitEnoughCyclesForModal({
useFakeTimers: true,
});
});
itShouldCloseTheDialogAndShowThePasswordInput();
});
describe("and dismissing the dialog", () => {
beforeEach(async () => {
await click(screen.getByLabelText("Close dialog"));
await waitEnoughCyclesForModal({
useFakeTimers: true,
});
});
itShouldCloseTheDialogAndShowThePasswordInput();
});
describe("and clicking »Re-enter email address«", () => {
beforeEach(async () => {
await click(screen.getByText("Re-enter email address"));
await waitEnoughCyclesForModal({
useFakeTimers: true,
});
});
it("should close the dialog and go back to the email input", () => {
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 () => {
expect(
screen.getByText(
"Signing out your devices will delete the message encryption keys stored on them, making encrypted chat history unreadable.",
),
).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,
);
});
});
});
});
});
});
});

View File

@@ -0,0 +1,473 @@
/*
Copyright 2019-2024 New Vector Ltd.
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 { fireEvent, render, screen, waitForElementToBeRemoved } from "jest-matrix-react";
import { mocked, MockedObject } from "jest-mock";
import fetchMock from "fetch-mock-jest";
import { DELEGATED_OIDC_COMPATIBILITY, IdentityProviderBrand, OidcClientConfig } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import * as Matrix from "matrix-js-sdk/src/matrix";
import { OidcError } from "matrix-js-sdk/src/oidc/error";
import SdkConfig from "../../../../../src/SdkConfig";
import { mkServerConfig, mockPlatformPeg, unmockPlatformPeg } from "../../../../test-utils";
import Login from "../../../../../src/components/structures/auth/Login";
import BasePlatform from "../../../../../src/BasePlatform";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { Features } from "../../../../../src/settings/Settings";
import * as registerClientUtils from "../../../../../src/utils/oidc/registerClient";
import { makeDelegatedAuthConfig } from "../../../../test-utils/oidc";
jest.useRealTimers();
const oidcStaticClientsConfig = {
"https://staticallyregisteredissuer.org/": {
client_id: "static-clientId-123",
},
};
describe("Login", function () {
let platform: MockedObject<BasePlatform>;
const mockClient = mocked({
login: jest.fn().mockResolvedValue({}),
loginFlows: jest.fn(),
} as unknown as Matrix.MatrixClient);
beforeEach(function () {
SdkConfig.put({
brand: "test-brand",
disable_custom_urls: true,
oidc_static_clients: oidcStaticClientsConfig,
});
mockClient.login.mockClear().mockResolvedValue({
access_token: "TOKEN",
device_id: "IAMADEVICE",
user_id: "@user:server",
});
mockClient.loginFlows.mockClear().mockResolvedValue({ flows: [{ type: "m.login.password" }] });
jest.spyOn(Matrix, "createClient").mockImplementation((opts) => {
mockClient.idBaseUrl = opts.idBaseUrl;
mockClient.baseUrl = opts.baseUrl;
return mockClient;
});
fetchMock.resetBehavior();
fetchMock.resetHistory();
fetchMock.get("https://matrix.org/_matrix/client/versions", {
unstable_features: {},
versions: ["v1.1"],
});
platform = mockPlatformPeg({
startSingleSignOn: jest.fn(),
});
});
afterEach(function () {
fetchMock.restore();
SdkConfig.reset(); // we touch the config, so clean up
unmockPlatformPeg();
});
function getRawComponent(
hsUrl = "https://matrix.org",
isUrl = "https://vector.im",
delegatedAuthentication?: OidcClientConfig,
) {
return (
<Login
serverConfig={mkServerConfig(hsUrl, isUrl, delegatedAuthentication)}
onLoggedIn={() => {}}
onRegisterClick={() => {}}
onServerConfigChange={() => {}}
/>
);
}
function getComponent(hsUrl?: string, isUrl?: string, delegatedAuthentication?: OidcClientConfig) {
return render(getRawComponent(hsUrl, isUrl, delegatedAuthentication));
}
it("should show form with change server link", async () => {
SdkConfig.put({
brand: "test-brand",
disable_custom_urls: false,
});
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(container.querySelector("form")).toBeTruthy();
expect(container.querySelector(".mx_ServerPicker_change")).toBeTruthy();
});
it("should show form without change server link when custom URLs disabled", async () => {
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(container.querySelector("form")).toBeTruthy();
expect(container.querySelectorAll(".mx_ServerPicker_change")).toHaveLength(0);
});
it("should show SSO button if that flow is available", async () => {
mockClient.loginFlows.mockResolvedValue({ flows: [{ type: "m.login.sso" }] });
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
const ssoButton = container.querySelector(".mx_SSOButton");
expect(ssoButton).toBeTruthy();
});
it("should show both SSO button and username+password if both are available", async () => {
mockClient.loginFlows.mockResolvedValue({ flows: [{ type: "m.login.password" }, { type: "m.login.sso" }] });
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(container.querySelector("form")).toBeTruthy();
const ssoButton = container.querySelector(".mx_SSOButton");
expect(ssoButton).toBeTruthy();
});
it("should show multiple SSO buttons if multiple identity_providers are available", async () => {
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "m.login.sso",
identity_providers: [
{
id: "a",
name: "Provider 1",
},
{
id: "b",
name: "Provider 2",
},
{
id: "c",
name: "Provider 3",
},
],
},
],
});
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
const ssoButtons = container.querySelectorAll(".mx_SSOButton");
expect(ssoButtons.length).toBe(3);
});
it("should show single SSO button if identity_providers is null", async () => {
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "m.login.sso",
},
],
});
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
const ssoButtons = container.querySelectorAll(".mx_SSOButton");
expect(ssoButtons.length).toBe(1);
});
it("should handle serverConfig updates correctly", async () => {
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "m.login.sso",
},
],
});
const { container, rerender } = render(getRawComponent());
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
fireEvent.click(container.querySelector(".mx_SSOButton")!);
expect(platform.startSingleSignOn.mock.calls[0][0].baseUrl).toBe("https://matrix.org");
fetchMock.get("https://server2/_matrix/client/versions", {
unstable_features: {},
versions: ["v1.1"],
});
rerender(getRawComponent("https://server2"));
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
fireEvent.click(container.querySelector(".mx_SSOButton")!);
expect(platform.startSingleSignOn.mock.calls[1][0].baseUrl).toBe("https://server2");
});
it("should handle updating to a server with no supported flows", async () => {
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "m.login.sso",
},
],
});
const { container, rerender } = render(getRawComponent());
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// update the mock for the new server with no supported flows
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "just something weird",
},
],
});
// render with a new server
rerender(getRawComponent("https://server2"));
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(
screen.getByText("This homeserver doesn't offer any login flows that are supported by this client."),
).toBeInTheDocument();
// no sso button because server2 doesnt support sso
expect(container.querySelector(".mx_SSOButton")).not.toBeInTheDocument();
});
it("should show single Continue button if OIDC MSC3824 compatibility is given by server", async () => {
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "m.login.sso",
[DELEGATED_OIDC_COMPATIBILITY.name]: true,
},
{
type: "m.login.password",
},
],
});
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
const ssoButtons = container.querySelectorAll(".mx_SSOButton");
expect(ssoButtons.length).toBe(1);
expect(ssoButtons[0].textContent).toBe("Continue");
// no password form visible
expect(container.querySelector("form")).toBeFalsy();
});
it("should show branded SSO buttons", async () => {
const idpsWithIcons = Object.values(IdentityProviderBrand).map((brand) => ({
id: brand,
brand,
name: `Provider ${brand}`,
}));
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "m.login.sso",
identity_providers: [
...idpsWithIcons,
{
id: "foo",
name: "Provider foo",
},
],
},
],
});
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
for (const idp of idpsWithIcons) {
const ssoButton = container.querySelector(`.mx_SSOButton.mx_SSOButton_brand_${idp.brand}`);
expect(ssoButton).toBeTruthy();
expect(ssoButton?.querySelector(`img[alt="${idp.brand}"]`)).toBeTruthy();
}
const ssoButtons = container.querySelectorAll(".mx_SSOButton");
expect(ssoButtons.length).toBe(idpsWithIcons.length + 1);
});
it("should display an error when homeserver doesn't offer any supported login flows", async () => {
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "just something weird",
},
],
});
getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(
screen.getByText("This homeserver doesn't offer any login flows that are supported by this client."),
).toBeInTheDocument();
});
it("should display a connection error when getting login flows fails", async () => {
mockClient.loginFlows.mockRejectedValue("oups");
getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(
screen.getByText("There was a problem communicating with the homeserver, please try again later."),
).toBeInTheDocument();
});
it("should display an error when homeserver fails liveliness check", async () => {
fetchMock.resetBehavior();
fetchMock.get("https://matrix.org/_matrix/client/versions", {
status: 0,
});
getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// error displayed
expect(screen.getByText("Cannot reach homeserver")).toBeInTheDocument();
});
it("should reset liveliness error when server config changes", async () => {
fetchMock.resetBehavior();
// matrix.org is not alive
fetchMock.get("https://matrix.org/_matrix/client/versions", {
status: 400,
});
// but server2 is
fetchMock.get("https://server2/_matrix/client/versions", {
unstable_features: {},
versions: ["v1.1"],
});
const { rerender } = render(getRawComponent());
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// error displayed
expect(screen.getByText("Cannot reach homeserver")).toBeInTheDocument();
rerender(getRawComponent("https://server2"));
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// error cleared
expect(screen.queryByText("Cannot reach homeserver")).not.toBeInTheDocument();
});
describe("OIDC native flow", () => {
const hsUrl = "https://matrix.org";
const isUrl = "https://vector.im";
const issuer = "https://test.com/";
const delegatedAuth = makeDelegatedAuthConfig(issuer);
beforeEach(() => {
jest.spyOn(logger, "error");
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === Features.OidcNativeFlow,
);
});
afterEach(() => {
jest.spyOn(logger, "error").mockRestore();
});
it("should not attempt registration when oidc native flow setting is disabled", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
getComponent(hsUrl, isUrl, delegatedAuth);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// didn't try to register
expect(fetchMock).not.toHaveBeenCalledWith(delegatedAuth.registrationEndpoint);
// continued with normal setup
expect(mockClient.loginFlows).toHaveBeenCalled();
// normal password login rendered
expect(screen.getByLabelText("Username")).toBeInTheDocument();
});
it("should attempt to register oidc client", async () => {
// dont mock, spy so we can check config values were correctly passed
jest.spyOn(registerClientUtils, "getOidcClientId");
fetchMock.post(delegatedAuth.registrationEndpoint!, { status: 500 });
getComponent(hsUrl, isUrl, delegatedAuth);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// tried to register
expect(fetchMock).toHaveBeenCalledWith(delegatedAuth.registrationEndpoint, expect.any(Object));
// called with values from config
expect(registerClientUtils.getOidcClientId).toHaveBeenCalledWith(delegatedAuth, oidcStaticClientsConfig);
});
it("should fallback to normal login when client registration fails", async () => {
fetchMock.post(delegatedAuth.registrationEndpoint!, { status: 500 });
getComponent(hsUrl, isUrl, delegatedAuth);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// tried to register
expect(fetchMock).toHaveBeenCalledWith(delegatedAuth.registrationEndpoint, expect.any(Object));
expect(logger.error).toHaveBeenCalledWith(new Error(OidcError.DynamicRegistrationFailed));
// continued with normal setup
expect(mockClient.loginFlows).toHaveBeenCalled();
// normal password login rendered
expect(screen.getByLabelText("Username")).toBeInTheDocument();
});
// short term during active development, UI will be added in next PRs
it("should show continue button when oidc native flow is correctly configured", async () => {
fetchMock.post(delegatedAuth.registrationEndpoint!, { client_id: "abc123" });
getComponent(hsUrl, isUrl, delegatedAuth);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// did not continue with matrix login
expect(mockClient.loginFlows).not.toHaveBeenCalled();
expect(screen.getByText("Continue")).toBeInTheDocument();
});
/**
* Oidc-aware flows still work while the oidc-native feature flag is disabled
*/
it("should show oidc-aware flow for oidc-enabled homeserver when oidc native flow setting is disabled", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "m.login.sso",
[DELEGATED_OIDC_COMPATIBILITY.name]: true,
},
{
type: "m.login.password",
},
],
});
const { container } = getComponent(hsUrl, isUrl, delegatedAuth);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// didn't try to register
expect(fetchMock).not.toHaveBeenCalledWith(delegatedAuth.registrationEndpoint);
// continued with normal setup
expect(mockClient.loginFlows).toHaveBeenCalled();
// oidc-aware 'continue' button displayed
const ssoButtons = container.querySelectorAll(".mx_SSOButton");
expect(ssoButtons.length).toBe(1);
expect(ssoButtons[0].textContent).toBe("Continue");
// no password form visible
expect(container.querySelector("form")).toBeFalsy();
});
});
});

View File

@@ -0,0 +1,67 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 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 { act, render, RenderResult } from "jest-matrix-react";
import React, { ComponentProps } from "react";
import EventEmitter from "events";
import { CryptoEvent } from "matrix-js-sdk/src/matrix";
import { sleep } from "matrix-js-sdk/src/utils";
import { LoginSplashView } from "../../../../../src/components/structures/auth/LoginSplashView";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
describe("<LoginSplashView />", () => {
let matrixClient: MatrixClient;
beforeEach(() => {
matrixClient = new EventEmitter() as unknown as MatrixClient;
});
function getComponent(props: Partial<ComponentProps<typeof LoginSplashView>> = {}): RenderResult {
const defaultProps = {
matrixClient,
onLogoutClick: () => {},
syncError: null,
};
return render(<LoginSplashView {...defaultProps} {...props} />);
}
it("Renders a spinner", () => {
const rendered = getComponent();
expect(rendered.getByTestId("spinner")).toBeInTheDocument();
expect(rendered.asFragment()).toMatchSnapshot();
});
it("Renders an error message", () => {
const rendered = getComponent({ syncError: new Error("boohoo") });
expect(rendered.asFragment()).toMatchSnapshot();
});
it("Calls onLogoutClick", () => {
const onLogoutClick = jest.fn();
const rendered = getComponent({ onLogoutClick });
expect(onLogoutClick).not.toHaveBeenCalled();
rendered.getByRole("button", { name: "Logout" }).click();
expect(onLogoutClick).toHaveBeenCalled();
});
it("Shows migration progress", async () => {
const rendered = getComponent();
act(() => {
matrixClient.emit(CryptoEvent.LegacyCryptoStoreMigrationProgress, 5, 10);
});
rendered.getByText("Hang tight.", { exact: false });
// Wait for the animation to update
await act(() => sleep(500));
const progress = rendered.getByRole("progressbar");
expect(progress.getAttribute("value")).toEqual("5");
expect(progress.getAttribute("max")).toEqual("10");
});
});

View File

@@ -0,0 +1,254 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2019 New Vector Ltd
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 { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from "jest-matrix-react";
import { createClient, MatrixClient, MatrixError, OidcClientConfig } from "matrix-js-sdk/src/matrix";
import { mocked, MockedObject } from "jest-mock";
import fetchMock from "fetch-mock-jest";
import SdkConfig, { DEFAULTS } from "../../../../../src/SdkConfig";
import {
getMockClientWithEventEmitter,
mkServerConfig,
mockPlatformPeg,
unmockPlatformPeg,
} from "../../../../test-utils";
import Registration from "../../../../../src/components/structures/auth/Registration";
import { makeDelegatedAuthConfig } from "../../../../test-utils/oidc";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { Features } from "../../../../../src/settings/Settings";
import { startOidcLogin } from "../../../../../src/utils/oidc/authorize";
jest.mock("../../../../../src/utils/oidc/authorize", () => ({
startOidcLogin: jest.fn(),
}));
jest.mock("matrix-js-sdk/src/matrix", () => ({
...jest.requireActual("matrix-js-sdk/src/matrix"),
createClient: jest.fn(),
}));
/** The matrix versions our mock server claims to support */
const SERVER_SUPPORTED_MATRIX_VERSIONS = ["v1.1", "v1.5", "v1.6", "v1.8", "v1.9"];
describe("Registration", function () {
let mockClient!: MockedObject<MatrixClient>;
beforeEach(function () {
SdkConfig.put({
...DEFAULTS,
disable_custom_urls: true,
});
mockClient = getMockClientWithEventEmitter({
registerRequest: jest.fn(),
loginFlows: jest.fn(),
getVersions: jest.fn().mockResolvedValue({ versions: SERVER_SUPPORTED_MATRIX_VERSIONS }),
});
mockClient.registerRequest.mockRejectedValueOnce(
new MatrixError(
{
flows: [{ stages: [] }],
},
401,
),
);
mockClient.loginFlows.mockResolvedValue({ flows: [{ type: "m.login.password" }] });
mocked(createClient).mockImplementation((opts) => {
mockClient.idBaseUrl = opts.idBaseUrl;
mockClient.baseUrl = opts.baseUrl;
return mockClient;
});
fetchMock.catch(404);
fetchMock.get("https://matrix.org/_matrix/client/versions", {
unstable_features: {},
versions: SERVER_SUPPORTED_MATRIX_VERSIONS,
});
mockPlatformPeg({
startSingleSignOn: jest.fn(),
});
});
afterEach(function () {
jest.restoreAllMocks();
fetchMock.restore();
SdkConfig.reset(); // we touch the config, so clean up
unmockPlatformPeg();
});
const defaultProps = {
defaultDeviceDisplayName: "test-device-display-name",
onLoggedIn: jest.fn(),
onLoginClick: jest.fn(),
onServerConfigChange: jest.fn(),
};
const defaultHsUrl = "https://matrix.org";
const defaultIsUrl = "https://vector.im";
function getRawComponent(
hsUrl = defaultHsUrl,
isUrl = defaultIsUrl,
authConfig?: OidcClientConfig,
mobileRegister?: boolean,
) {
return (
<Registration
{...defaultProps}
serverConfig={mkServerConfig(hsUrl, isUrl, authConfig)}
mobileRegister={mobileRegister}
/>
);
}
function getComponent(hsUrl?: string, isUrl?: string, authConfig?: OidcClientConfig, mobileRegister?: boolean) {
return render(getRawComponent(hsUrl, isUrl, authConfig, mobileRegister));
}
it("should show server picker", async function () {
const { container } = getComponent();
expect(container.querySelector(".mx_ServerPicker")).toBeTruthy();
});
it("should show form when custom URLs disabled", async function () {
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(container.querySelector("form")).toBeTruthy();
});
it("should show SSO options if those are available", async () => {
mockClient.loginFlows.mockClear().mockResolvedValue({ flows: [{ type: "m.login.sso" }] });
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
const ssoButton = container.querySelector(".mx_SSOButton");
expect(ssoButton).toBeTruthy();
});
it("should handle serverConfig updates correctly", async () => {
mockClient.loginFlows.mockResolvedValue({
flows: [
{
type: "m.login.sso",
},
],
});
const { container, rerender } = render(getRawComponent());
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
fireEvent.click(container.querySelector(".mx_SSOButton")!);
expect(mockClient.baseUrl).toBe("https://matrix.org");
fetchMock.get("https://server2/_matrix/client/versions", {
unstable_features: {},
versions: SERVER_SUPPORTED_MATRIX_VERSIONS,
});
rerender(getRawComponent("https://server2"));
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
fireEvent.click(container.querySelector(".mx_SSOButton")!);
expect(mockClient.baseUrl).toBe("https://server2");
});
describe("when delegated authentication is configured and enabled", () => {
const authConfig = makeDelegatedAuthConfig();
const clientId = "test-client-id";
// @ts-ignore
authConfig.metadata["prompt_values_supported"] = ["create"];
beforeEach(() => {
// mock a statically registered client to avoid dynamic registration
SdkConfig.put({
oidc_static_clients: {
[authConfig.metadata.issuer]: {
client_id: clientId,
},
},
});
fetchMock.get(`${defaultHsUrl}/_matrix/client/unstable/org.matrix.msc2965/auth_issuer`, {
issuer: authConfig.metadata.issuer,
});
fetchMock.get("https://auth.org/.well-known/openid-configuration", authConfig.metadata);
fetchMock.get(authConfig.metadata.jwks_uri!, { keys: [] });
});
describe("when oidc native flow is not enabled in settings", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
});
it("should display user/pass registration form", async () => {
const { container } = getComponent(defaultHsUrl, defaultIsUrl, authConfig);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(container.querySelector("form")).toBeTruthy();
expect(mockClient.loginFlows).toHaveBeenCalled();
expect(mockClient.registerRequest).toHaveBeenCalled();
});
});
describe("when oidc native flow is enabled in settings", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((key) => key === Features.OidcNativeFlow);
});
it("should display oidc-native continue button", async () => {
const { container } = getComponent(defaultHsUrl, defaultIsUrl, authConfig);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
// no form
expect(container.querySelector("form")).toBeFalsy();
expect(await screen.findByText("Continue")).toBeTruthy();
});
it("should start OIDC login flow as registration on button click", async () => {
getComponent(defaultHsUrl, defaultIsUrl, authConfig);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
fireEvent.click(await screen.findByText("Continue"));
expect(startOidcLogin).toHaveBeenCalledWith(
authConfig,
clientId,
defaultHsUrl,
defaultIsUrl,
// isRegistration
true,
);
});
});
describe("when is mobile registeration", () => {
it("should not show server picker", async function () {
const { container } = getComponent(defaultHsUrl, defaultIsUrl, undefined, true);
expect(container.querySelector(".mx_ServerPicker")).toBeFalsy();
});
it("should show username field with autocaps disabled", async function () {
const { container } = getComponent(defaultHsUrl, defaultIsUrl, undefined, true);
await waitFor(() =>
expect(container.querySelector("#mx_RegistrationForm_username")).toHaveAttribute(
"autocapitalize",
"none",
),
);
});
it("should show password and confirm password fields in separate rows", async function () {
const { container } = getComponent(defaultHsUrl, defaultIsUrl, undefined, true);
await waitFor(() => expect(container.querySelector("#mx_RegistrationForm_username")).toBeTruthy());
// when password and confirm password fields are in separate rows there should be 4 rather than 3
expect(container.querySelectorAll(".mx_AuthBody_fieldRow")).toHaveLength(4);
});
});
});
});

View File

@@ -0,0 +1,70 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<LoginSplashView /> Renders a spinner 1`] = `
<DocumentFragment>
<div
class="mx_MatrixChat_splash"
>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
<div
class="mx_LoginSplashView_splashButtons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Logout
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`<LoginSplashView /> Renders an error message 1`] = `
<DocumentFragment>
<div
class="mx_MatrixChat_splash"
>
<div
class="mx_LoginSplashView_syncError"
>
<div>
Unable to connect to Homeserver. Retrying…
</div>
</div>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
<div
class="mx_LoginSplashView_splashButtons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
role="button"
tabindex="0"
>
Logout
</div>
</div>
</div>
</DocumentFragment>
`;