diff --git a/config.preprod.json b/config.preprod.json index 4a3750b0f..5864003de 100644 --- a/config.preprod.json +++ b/config.preprod.json @@ -109,8 +109,7 @@ "feature_space": ["*"], "feature_audio_call": ["i.tchap.gouv.fr", "e.tchap.gouv.fr"], "feature_video_call": ["i.tchap.gouv.fr", "e.tchap.gouv.fr"], - "feature_screenshare_call": ["*"], - "feature_sso_flow": [] + "feature_screenshare_call": ["*"] }, "tchap_sso_flow": { "isActive": false diff --git a/config.prod.json b/config.prod.json index 05089e22b..a7192ce50 100644 --- a/config.prod.json +++ b/config.prod.json @@ -196,8 +196,7 @@ "feature_space": ["*"], "feature_audio_call": ["*"], "feature_video_call": ["agent.dinum.tchap.gouv.fr"], - "feature_screenshare_call": ["*"], - "feature_sso_flow": [] + "feature_screenshare_call": ["*"] }, "tchap_sso_flow": { "isActive": false diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/auth/Welcome.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/auth/Welcome.tsx index c2c0a8a01..5d77ac175 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/auth/Welcome.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/auth/Welcome.tsx @@ -50,6 +50,7 @@ export default class Welcome extends React.PureComponent { replaceMap["$logoUrl"] = logoUrl; // :TCHAP: sso-agentconnect-flow - pageUrl = "welcome.html"; pageUrl = TchapUIFeature.isSSOFlowActive() ? "welcome_sso.html" : "welcome.html"; + // end :TCHAP: } return ( diff --git a/patches/subtree-modifications.json b/patches/subtree-modifications.json index f5dc88a0b..878d86eb8 100644 --- a/patches/subtree-modifications.json +++ b/patches/subtree-modifications.json @@ -82,7 +82,10 @@ "sso-agentconnect-flow": { "issue": "https://github.com/tchapgouv/tchap-web-v4/issues/386", "files": [ - "src/components/structures/MatrixChat.tsx" + "src/components/structures/MatrixChat.tsx", + "src/components/structures/auth/Registration.tsx", + "src/components/structures/auth/Login.tsx", + "src/components/views/auth/Welcome.tsx" ] } } \ No newline at end of file diff --git a/src/tchap/components/views/sso/EmailVerificationPage.tsx b/src/tchap/components/views/sso/EmailVerificationPage.tsx index f8c122853..d45b6fec8 100644 --- a/src/tchap/components/views/sso/EmailVerificationPage.tsx +++ b/src/tchap/components/views/sso/EmailVerificationPage.tsx @@ -68,10 +68,7 @@ export default function EmailVerificationPage() { const isFieldCorrect = await emailFieldRef.current?.validate({ allowEmpty: false }); if (!isFieldCorrect) { - emailFieldRef.current?.focus(); - emailFieldRef.current?.validate({ allowEmpty: false, focused: true }); - setErrorText(_td("auth|sso|error_email")); - setLoading(false); + displayError(_td("auth|sso|error_email")); return; } @@ -135,7 +132,7 @@ export default function EmailVerificationPage() { /> {errorText && } -
diff --git a/test/unit-tests/tchap/components/views/sso/EmailVerificationPage-test.tsx b/test/unit-tests/tchap/components/views/sso/EmailVerificationPage-test.tsx new file mode 100644 index 000000000..6754080eb --- /dev/null +++ b/test/unit-tests/tchap/components/views/sso/EmailVerificationPage-test.tsx @@ -0,0 +1,210 @@ +import React from "react"; +import { render, cleanup, fireEvent, screen, act } from "@testing-library/react"; +import { mocked, MockedObject } from "jest-mock"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; + +import EmailVerificationPage from "~tchap-web/src/tchap/components/views/sso/EmailVerificationPage"; +import TchapUtils from "~tchap-web/src/tchap/util/TchapUtils"; +import { ValidatedServerConfig } from "~matrix-react-sdk/src/utils/ValidatedServerConfig"; +import { mockPlatformPeg, stubClient } from "~matrix-react-sdk/test/test-utils"; +import BasePlatform from "~matrix-react-sdk/src/BasePlatform"; +import Login from "~matrix-react-sdk/src/Login"; + +jest.mock("~matrix-react-sdk/src/PlatformPeg"); +jest.mock("~tchap-web/src/tchap/util/TchapUtils"); +jest.mock("~matrix-react-sdk/src/Login"); + +describe("", () => { + const userEmail = "marc@tchap.beta.gouv.fr"; + const defaultHsUrl = "https://matrix.agent1.fr"; + const secondHsUrl = "https://matrix.agent2.fr"; + + const PlatformPegMocked: MockedObject = mockPlatformPeg(); + const mockedClient: MatrixClient = stubClient(); + const mockedTchapUtils = mocked(TchapUtils); + + const mockLoginObject = (hs: string = defaultHsUrl) => { + const mockLoginObject = mocked(new Login(hs, hs, null, {})); + mockLoginObject.createTemporaryClient.mockImplementation(() => mockedClient); + return mockLoginObject; + }; + + const mockedFetchHomeserverFromEmail = (hs: string = defaultHsUrl) => { + mockedTchapUtils.fetchHomeserverForEmail.mockImplementation(() => + Promise.resolve({ base_url: hs, server_name: hs }), + ); + }; + + const mockedValidatedServerConfig = (withError: boolean = false, hsUrl: string = defaultHsUrl) => { + if (withError) { + mockedTchapUtils.makeValidatedServerConfig.mockImplementation(() => { + throw new Error(); + }); + } else { + mockedTchapUtils.makeValidatedServerConfig.mockImplementation(() => + Promise.resolve({ + hsUrl: defaultHsUrl, + hsName: "hs", + hsNameIsDifferent: false, + isUrl: "", + isDefault: true, + isNameResolvable: true, + warning: "", + } as ValidatedServerConfig), + ); + } + }; + + const mockedPlatformPegStartSSO = (withError: boolean) => { + if (withError) { + jest.spyOn(PlatformPegMocked, "startSingleSignOn").mockImplementation(() => { + throw new Error(); + }); + } else { + jest.spyOn(PlatformPegMocked, "startSingleSignOn").mockImplementation(() => {}); + } + }; + + const renderEmailVerificationPage = () => render(); + + beforeEach(() => { + mockLoginObject(defaultHsUrl); + }); + + afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + }); + + it("returns error when empty email", async () => { + const { container } = renderEmailVerificationPage(); + + // Put text in email field + const emailField = screen.getByRole("textbox"); + fireEvent.focus(emailField); + fireEvent.change(emailField, { target: { value: "" } }); + + // click on proconnect button + const proconnectButton = screen.getByTestId("proconnect-submit"); + await act(async () => { + await fireEvent.click(proconnectButton); + }); + + // Error classes should not appear + expect(container.getElementsByClassName("mx_ErrorMessage").length).toBe(1); + }); + + it("returns inccorrect email", async () => { + const { container } = renderEmailVerificationPage(); + + // Put text in email field + const emailField = screen.getByRole("textbox"); + fireEvent.focus(emailField); + fireEvent.change(emailField, { target: { value: "falseemail" } }); + + // click on proconnect button + const proconnectButton = screen.getByTestId("proconnect-submit"); + await act(async () => { + await fireEvent.click(proconnectButton); + }); + + // Error classes should not appear + expect(container.getElementsByClassName("mx_ErrorMessage").length).toBe(1); + }); + + it("should throw error when homeserver catch an error", async () => { + const { container } = renderEmailVerificationPage(); + + // mock server returns an errorn, we dont need to mock the other implementation + // since the code should throw an error before accessing them + mockedValidatedServerConfig(true); + + // Put text in email field + const emailField = screen.getByRole("textbox"); + fireEvent.focus(emailField); + fireEvent.change(emailField, { target: { value: userEmail } }); + + // click on proconnect button + const proconnectButton = screen.getByTestId("proconnect-submit"); + await act(async () => { + await fireEvent.click(proconnectButton); + }); + + // Error classes should not appear + expect(container.getElementsByClassName("mx_ErrorMessage").length).toBe(1); + }); + + it("should throw and error when connecting to proconnect error", async () => { + const { container } = renderEmailVerificationPage(); + + mockedValidatedServerConfig(false); + // mock platform page startsso error + mockedPlatformPegStartSSO(true); + + // Put text in email field + const emailField = screen.getByRole("textbox"); + fireEvent.focus(emailField); + fireEvent.change(emailField, { target: { value: userEmail } }); + + // click on proconnect button + const proconnectButton = screen.getByTestId("proconnect-submit"); + await act(async () => { + await fireEvent.click(proconnectButton); + }); + + // Error classes should not appear + expect(container.getElementsByClassName("mx_ErrorMessage").length).toBe(1); + }); + + it("should start sso with correct homeserver 1", async () => { + renderEmailVerificationPage(); + + // Mock the implementation without error, what we want is to be sure they are called with the correct parameters + mockedFetchHomeserverFromEmail(defaultHsUrl); + mockedValidatedServerConfig(false, defaultHsUrl); + mockedPlatformPegStartSSO(false); + + // Put text in email field + const emailField = screen.getByRole("textbox"); + fireEvent.focus(emailField); + fireEvent.change(emailField, { target: { value: userEmail } }); + + // click on proconnect button + const proconnectButton = screen.getByTestId("proconnect-submit"); + await act(async () => { + await fireEvent.click(proconnectButton); + }); + + expect(mockedTchapUtils.makeValidatedServerConfig).toHaveBeenCalledWith({ + base_url: defaultHsUrl, + server_name: defaultHsUrl, + }); + expect(PlatformPegMocked.startSingleSignOn).toHaveBeenCalledTimes(1); + }); + + it("should start sso with correct homeserver 2", async () => { + renderEmailVerificationPage(); + + // Mock the implementation without error, what we want is to be sure they are called with the correct parameters + mockedFetchHomeserverFromEmail(secondHsUrl); + mockedValidatedServerConfig(false, secondHsUrl); + mockedPlatformPegStartSSO(false); + + // Put text in email field + const emailField = screen.getByRole("textbox"); + fireEvent.focus(emailField); + fireEvent.change(emailField, { target: { value: userEmail } }); + + // click on proconnect button + const proconnectButton = screen.getByTestId("proconnect-submit"); + await act(async () => { + await fireEvent.click(proconnectButton); + }); + + expect(mockedTchapUtils.makeValidatedServerConfig).toHaveBeenCalledWith({ + base_url: secondHsUrl, + server_name: secondHsUrl, + }); + expect(PlatformPegMocked.startSingleSignOn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/unit-tests/tchap/components/views/sso/Register-test.tsx b/test/unit-tests/tchap/components/views/sso/Register-test.tsx new file mode 100644 index 000000000..53acdf57d --- /dev/null +++ b/test/unit-tests/tchap/components/views/sso/Register-test.tsx @@ -0,0 +1,153 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import { mocked, MockedObject } from "jest-mock"; +import { MatrixClient, MatrixError, OidcClientConfig, createClient } from "matrix-js-sdk/src/matrix"; +import fetchMock from "fetch-mock"; + +import SdkConfig, { ConfigOptions, DEFAULTS } from "~matrix-react-sdk/src/SdkConfig"; +import Registration from "~matrix-react-sdk/src/components/structures/auth/Registration"; +import { + getMockClientWithEventEmitter, + mkServerConfig, + mockPlatformPeg, + unmockPlatformPeg, +} from "~matrix-react-sdk/test/test-utils"; +import { makeDelegatedAuthConfig } from "~matrix-react-sdk/test/test-utils/oidc"; +import SettingsStore from "~matrix-react-sdk/src/settings/SettingsStore"; +import AutoDiscoveryUtils from "~matrix-react-sdk/src/utils/AutoDiscoveryUtils"; +import { ValidatedServerConfig } from "~matrix-react-sdk/src/utils/ValidatedServerConfig"; + +jest.mock("~matrix-react-sdk/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("", () => { + let mockClient!: MockedObject; + + const defaultHsUrl = "https://matrix.org"; + const defaultIsUrl = "https://vector.im"; + + const addSSOFlowToMockConfig = (isActive: boolean = false) => { + // mock SdkConfig.get("tchap_features") + const config: ConfigOptions = { tchap_sso_flow: { isActive } }; + SdkConfig.put(config); + }; + + const defaultProps = { + defaultDeviceDisplayName: "test-device-display-name", + onLoggedIn: jest.fn(), + onLoginClick: jest.fn(), + onServerConfigChange: jest.fn(), + }; + + function getRawComponent(hsUrl = defaultHsUrl, isUrl = defaultIsUrl, authConfig?: OidcClientConfig) { + return ; + } + + beforeEach(async function () { + const authConfig = makeDelegatedAuthConfig(); + // @ts-ignore + authConfig.metadata["prompt_values_supported"] = ["create"]; + + SdkConfig.put({ + ...DEFAULTS, + disable_custom_urls: true, + }); + mockClient = await getMockClientWithEventEmitter({ + registerRequest: jest.fn().mockImplementation( + () => + new MatrixError( + { + flows: [{ stages: [] }], + }, + 401, + ), + ), + loginFlows: jest.fn().mockResolvedValue({ flows: [{ type: "m.login.sso" }, { type: "m.login.password" }] }), + getVersions: jest.fn().mockResolvedValue({ versions: SERVER_SUPPORTED_MATRIX_VERSIONS }), + }); + + // used for registerRequest, but should return a MatrixError instance for the code to work... which is not the case here + fetchMock.catch({ + status: 401, + body: '{"errcode": "M_UNAUTHORIZE", "error": "Unauthorize request"}', + headers: { "content-type": "application/json" }, + }); + + // Doing this line can mock the request we want, but we want it to throw an error 401 which this doesnt do + // fetchMock.post(`${defaultHsUrl}/_matrix/client/v3/register`, { status: 401, type: "error" }); + + await mocked(createClient).mockImplementation((opts) => { + mockClient.idBaseUrl = opts.idBaseUrl; + mockClient.baseUrl = opts.baseUrl; + return mockClient; + }); + + fetchMock.get(`${defaultHsUrl}/_matrix/client/versions`, { + unstable_features: {}, + versions: SERVER_SUPPORTED_MATRIX_VERSIONS, + }); + + 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: [] }); + + fetchMock.get(`${defaultHsUrl}/_matrix/client/v3/login`, { + body: { flows: [{ type: "m.login.sso" }, { type: "m.login.password" }] }, + }); + + jest.spyOn(SettingsStore, "getValue").mockImplementation(() => false); + + jest.spyOn(AutoDiscoveryUtils, "validateServerConfigWithStaticUrls").mockResolvedValue({ + hsName: "example.com", + } as ValidatedServerConfig); + + mockPlatformPeg({ + startSingleSignOn: jest.fn(), + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + fetchMock.restore(); + SdkConfig.reset(); // we touch the config, so clean up + unmockPlatformPeg(); + }); + + /** TODO weird behavior of requestregister which the mock is not detected + * So it will al { + // addSSOFlowToMockConfig(true); + + // const { container } = render(getRawComponent()); + + // await waitForElementToBeRemoved(() => screen.queryAllByTestId("spinner")); + + // screen.debug(); + + // expect(container.getElementsByClassName("tc_pronnect").length).toBe(1); + // }); + + it("returns no proconnect button when the config does'nt include sso flow", () => { + addSSOFlowToMockConfig(false); + + const { container } = render(getRawComponent()); + + expect(container.getElementsByClassName("tc_pronnect").length).toBe(0); + }); +}); diff --git a/test/unit-tests/tchap/components/views/sso/Welcome-test.tsx b/test/unit-tests/tchap/components/views/sso/Welcome-test.tsx new file mode 100644 index 000000000..2c08d9ccc --- /dev/null +++ b/test/unit-tests/tchap/components/views/sso/Welcome-test.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { render, cleanup, screen } from "@testing-library/react"; +import fetchMock from "fetch-mock"; + +import SdkConfig, { ConfigOptions } from "~matrix-react-sdk/src/SdkConfig"; +import Welcome from "~matrix-react-sdk/src/components/views/auth/Welcome"; +import { flushPromises } from "~matrix-react-sdk/test/test-utils"; + +describe("", () => { + const addSSOFlowToMockConfig = (isActive: boolean = false) => { + // mock SdkConfig.get("tchap_features") + const config: ConfigOptions = { tchap_sso_flow: { isActive } }; + SdkConfig.put(config); + }; + + const renderWelcomePage = () => render(); + + afterEach(() => { + cleanup(); + }); + + it("returns welcome_sso html when sso_flow is active in config ", async () => { + addSSOFlowToMockConfig(true); + + // we need to mock the call to the correct html page, since it is embeded in the component + // we don't need to mock the other html page since it shouldnt call it, otherwise it will simply throw an error + fetchMock.get("/welcome_sso.html", { body: "

SSO

" }); + + renderWelcomePage(); + await flushPromises(); + + // the component should choose the correct html page based on the sso_flo active value + expect(screen.getByRole("heading", { level: 1 }).textContent).toEqual("SSO"); + }); + + it("returns normal welcome html page without sso flow ", async () => { + addSSOFlowToMockConfig(false); + + // we need to mock the call to the correct html page, since it is embeded in the component + // we don't need to mock the other html page since it shouldnt call it, otherwise it will simply throw an error + fetchMock.get("/welcome.html", { body: "

Welcome

" }); + + renderWelcomePage(); + await flushPromises(); + + // the component should choose the correct html page based on the sso_flo active value + expect(screen.getByRole("heading", { level: 1 }).textContent).toEqual("Welcome"); + }); +});