From ed937b5ff64313863379f83942b877655a476186 Mon Sep 17 00:00:00 2001 From: Chad Brokaw <36685920+chadbrokaw@users.noreply.github.com> Date: Mon, 16 Dec 2024 10:46:04 -0700 Subject: [PATCH 1/5] feat: reset password api --- .../__tests__/api/authApiService.test.ts | 16 ++++++++++++++++ app/javascript/api/authApiService.ts | 7 +++++++ 2 files changed, 23 insertions(+) diff --git a/app/javascript/__tests__/api/authApiService.test.ts b/app/javascript/__tests__/api/authApiService.test.ts index dfd9a2b3d3..64f9cc6645 100644 --- a/app/javascript/__tests__/api/authApiService.test.ts +++ b/app/javascript/__tests__/api/authApiService.test.ts @@ -14,6 +14,7 @@ import { updatePassword, getApplications, deleteApplication, + resetPassword, } from "../../api/authApiService" jest.mock("axios") @@ -100,6 +101,21 @@ describe("authApiService", () => { }) }) + describe("resetPassword", () => { + it("calls apiService put", async () => { + const url = "/api/v1/auth/password" + const newPassword = "abc123" + await resetPassword(newPassword) + expect(authenticatedPut).toHaveBeenCalledWith( + url, + expect.objectContaining({ + password: newPassword, + password_confirmation: newPassword, + }) + ) + }) + }) + describe("updatePassword", () => { it("calls apiService put", async () => { const url = "/api/v1/auth/password" diff --git a/app/javascript/api/authApiService.ts b/app/javascript/api/authApiService.ts index 82300255da..89f1a4ff02 100644 --- a/app/javascript/api/authApiService.ts +++ b/app/javascript/api/authApiService.ts @@ -83,6 +83,13 @@ export const updateEmail = async (email: string): Promise => }, }).then(({ data }) => data.status) +export const resetPassword = async (new_password: string): Promise => + authenticatedPut<{ message: string }>("/api/v1/auth/password", { + password: new_password, + password_confirmation: new_password, + locale: getRoutePrefix(window.location.pathname) || LanguagePrefix.English, + }).then(({ data }) => data.message) + export const updatePassword = async ( new_password: string, current_password: string From 27a41e2e2f050e23f3c50f259eed466f57b1500c Mon Sep 17 00:00:00 2001 From: Chad Brokaw <36685920+chadbrokaw@users.noreply.github.com> Date: Wed, 18 Dec 2024 11:01:52 -0700 Subject: [PATCH 2/5] feat: Integrate API with page --- .../__tests__/__util__/accountUtils.tsx | 99 ++++++++++++++ .../pages/account/account-settings.test.tsx | 129 +++++------------- .../pages/account/my-account.test.tsx | 56 +------- .../pages/account/my-applications.test.tsx | 82 +---------- .../__tests__/pages/reset-password.test.tsx | 111 ++++++++++++++- app/javascript/pages/reset-password.tsx | 74 +++++++--- 6 files changed, 304 insertions(+), 247 deletions(-) create mode 100644 app/javascript/__tests__/__util__/accountUtils.tsx diff --git a/app/javascript/__tests__/__util__/accountUtils.tsx b/app/javascript/__tests__/__util__/accountUtils.tsx new file mode 100644 index 0000000000..acee3844d7 --- /dev/null +++ b/app/javascript/__tests__/__util__/accountUtils.tsx @@ -0,0 +1,99 @@ +import React from "react" +import UserContext, { ContextProps } from "../../authentication/context/UserContext" +import { User } from "../../authentication/user" + +export const mockProfileStub: User = { + uid: "abc123", + email: "email@email.com", + created_at: new Date(), + updated_at: new Date(), + DOB: "1999-01-01", + firstName: "FirstName", + lastName: "LastName", + middleName: "MiddleName", +} + +export const setupUserContext = ({ + loggedIn, + setUpMockLocation = true, + saveProfileMock = jest.fn(), + mockProfile = mockProfileStub, +}: { + loggedIn: boolean + setUpMockLocation?: boolean + saveProfileMock?: jest.Mock + mockProfile?: ContextProps["profile"] +}) => { + const originalUseContext = React.useContext + const originalLocation = window.location + const mockContextValue: ContextProps = { + profile: loggedIn ? mockProfile : undefined, + signIn: jest.fn(), + signOut: jest.fn(), + timeOut: jest.fn(), + saveProfile: saveProfileMock || jest.fn(), + loading: false, + initialStateLoaded: true, + } + + jest.spyOn(React, "useContext").mockImplementation((context) => { + if (context === UserContext) { + return mockContextValue + } + return originalUseContext(context) + }) + + if (!loggedIn && setUpMockLocation) { + // Allows for a redirect to the Sign In page + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (window as any)?.location + ;(window as Window).location = { + ...originalLocation, + href: "http://dahlia.com", + assign: jest.fn(), + replace: jest.fn(), + reload: jest.fn(), + toString: jest.fn(), + } + } + + return originalUseContext +} + +export const setupLocationAndRouteMock = () => { + const originalLocation = window.location + + const customLocation = { + ...originalLocation, + href: "http://dahlia.com", + assign: jest.fn(), + replace: jest.fn(), + reload: jest.fn(), + toString: jest.fn(), + } + + Object.defineProperty(window, "location", { + configurable: true, + enumerable: true, + writable: true, + value: customLocation, + }) + + // Redefine the href setter to resolve relative URLs + Object.defineProperty(window.location, "href", { + configurable: true, + enumerable: true, + set: function (href: string) { + const base = "http://dahlia.com" + try { + const newUrl = new URL(href, base) + this._href = newUrl.href + } catch { + this._href = href + } + }, + get: function () { + return this._href || "http://dahlia.com" + }, + }) +} diff --git a/app/javascript/__tests__/pages/account/account-settings.test.tsx b/app/javascript/__tests__/pages/account/account-settings.test.tsx index ef4d234d0b..b11a595da3 100644 --- a/app/javascript/__tests__/pages/account/account-settings.test.tsx +++ b/app/javascript/__tests__/pages/account/account-settings.test.tsx @@ -1,21 +1,9 @@ import React from "react" -import UserContext, { ContextProps } from "../../../authentication/context/UserContext" import { renderAndLoadAsync } from "../../__util__/renderUtils" import AccountSettingsPage from "../../../pages/account/account-settings" import { fireEvent, screen, within, act } from "@testing-library/react" import { authenticatedPut } from "../../../api/apiService" -import { User } from "../../../authentication/user" - -const mockProfile: User = { - uid: "abc123", - email: "email@email.com", - created_at: new Date(), - updated_at: new Date(), - DOB: "1999-01-01", - firstName: "FirstName", - lastName: "LastName", - middleName: "MiddleName", -} +import { mockProfileStub, setupUserContext } from "../../__util__/accountUtils" jest.mock("../../../api/apiService", () => ({ authenticatedPut: jest.fn(), @@ -25,39 +13,16 @@ const saveProfileMock = jest.fn() describe("", () => { describe("when the user is signed in", () => { - let getByText - let getAllByText - let queryByText - let originalUseContext let promise let renderResult beforeEach(async () => { document.documentElement.lang = "en" - originalUseContext = React.useContext - const mockContextValue: ContextProps = { - profile: mockProfile, - signIn: jest.fn(), - signOut: jest.fn(), - timeOut: jest.fn(), - saveProfile: saveProfileMock, - loading: false, - initialStateLoaded: true, - } - - jest.spyOn(React, "useContext").mockImplementation((context) => { - if (context === UserContext) { - return mockContextValue - } - return originalUseContext(context) - }) + setupUserContext({ loggedIn: true, saveProfileMock }) promise = Promise.resolve() renderResult = await renderAndLoadAsync() - getByText = renderResult.getByText - getAllByText = renderResult.getAllByText - queryByText = renderResult.queryByText }) afterEach(() => { @@ -65,7 +30,7 @@ describe("", () => { }) it("shows the correct header text", () => { - const title = getByText("Account settings") + const title = screen.getByText("Account settings") expect(title).not.toBeNull() }) @@ -88,11 +53,11 @@ describe("", () => { it("updates Name", async () => { ;(authenticatedPut as jest.Mock).mockResolvedValue({ data: { - contact: { ...mockProfile, firstName: "NewFirstName", lastName: "NewLastName" }, + contact: { ...mockProfileStub, firstName: "NewFirstName", lastName: "NewLastName" }, }, }) - const button = getAllByText("Update") + const button = screen.getAllByText("Update") const firstNameField: Element = screen.getByRole("textbox", { name: /first name/i, }) @@ -104,7 +69,7 @@ describe("", () => { fireEvent.change(firstNameField, { target: { value: "NewFirstName" } }) fireEvent.change(lastNameField, { target: { value: "NewLastName" } }) expect( - getByText("We will update any applications you have not submitted yet.") + screen.getByText("We will update any applications you have not submitted yet.") ).not.toBeNull() const closeButton = screen.getByLabelText("Close") @@ -113,7 +78,7 @@ describe("", () => { await promise }) - expect(getByText("Your changes have been saved.")).not.toBeNull() + expect(screen.getByText("Your changes have been saved.")).not.toBeNull() await act(async () => { const closeButton = screen.getByLabelText("Close") @@ -123,12 +88,12 @@ describe("", () => { }) expect( - queryByText( + screen.queryByText( "We sent you an email. Check your email and follow the link to finish changing your information." ) ).toBeNull() - expect(queryByText("Your changes have been saved.")).toBeNull() + expect(screen.queryByText("Your changes have been saved.")).toBeNull() expect(authenticatedPut).toHaveBeenCalledWith( "/api/v1/account/update", @@ -151,14 +116,14 @@ describe("", () => { ;(authenticatedPut as jest.Mock).mockResolvedValue({ data: { contact: { - ...mockProfile, + ...mockProfileStub, DOB: "2000-02-06", dobObject: { birthYear: "2000", birthMonth: "02", birthDay: "06" }, }, }, }) - const button = getAllByText("Update") + const button = screen.getAllByText("Update") const monthField: Element = screen.getByRole("spinbutton", { name: /month/i, }) @@ -174,7 +139,7 @@ describe("", () => { fireEvent.change(dayField, { target: { value: 6 } }) fireEvent.change(yearField, { target: { value: 2000 } }) expect( - getByText("We will update any applications you have not submitted yet.") + screen.getByText("We will update any applications you have not submitted yet.") ).not.toBeNull() const closeButton = screen.getByLabelText("Close") @@ -185,9 +150,9 @@ describe("", () => { }) expect( - queryByText("We will update any applications you have not submitted yet.") + screen.queryByText("We will update any applications you have not submitted yet.") ).toBeNull() - expect(getByText("Your changes have been saved.")).not.toBeNull() + expect(screen.getByText("Your changes have been saved.")).not.toBeNull() await act(async () => { const closeButton = screen.getByLabelText("Close") @@ -195,7 +160,7 @@ describe("", () => { await promise }) - expect(queryByText("Your changes have been saved.")).toBeNull() + expect(screen.queryByText("Your changes have been saved.")).toBeNull() expect(authenticatedPut).toHaveBeenCalledWith( "/api/v1/account/update", @@ -210,7 +175,7 @@ describe("", () => { }) it("blocks a DOB update if invalid", async () => { - const button = getAllByText("Update") + const button = screen.getAllByText("Update") const monthField: Element = screen.getByRole("spinbutton", { name: /month/i, }) @@ -261,7 +226,7 @@ describe("", () => { }, }) - const emailUpdateButton = getAllByText("Update")[2] + const emailUpdateButton = screen.getAllByText("Update")[2] const group = screen.getByRole("group", { name: /email/i, }) @@ -273,7 +238,7 @@ describe("", () => { emailUpdateButton.dispatchEvent(new MouseEvent("click")) expect( - getByText("We will update any applications you have not submitted yet.") + screen.getByText("We will update any applications you have not submitted yet.") ).not.toBeNull() const closeButton = screen.getByLabelText("Close") fireEvent.click(closeButton) @@ -282,7 +247,7 @@ describe("", () => { }) expect( - getByText( + screen.getByText( "We sent you an email. Check your email and follow the link to finish changing your information." ) ).not.toBeNull() @@ -295,7 +260,7 @@ describe("", () => { }) expect( - queryByText( + screen.queryByText( "We sent you an email. Check your email and follow the link to finish changing your information." ) ).toBeNull() @@ -317,7 +282,7 @@ describe("", () => { }, }) - const emailUpdateButton = getAllByText("Update")[2] + const emailUpdateButton = screen.getAllByText("Update")[2] const group = screen.getByRole("group", { name: /email/i, }) @@ -342,7 +307,7 @@ describe("", () => { }, }) - const passwordUpdateButton = getAllByText("Update")[3] + const passwordUpdateButton = screen.getAllByText("Update")[3] const currentPasswordField = screen.getByLabelText(/current password/i) await act(async () => { @@ -361,7 +326,7 @@ describe("", () => { }, }) - const passwordUpdateButton = getAllByText("Update")[3] + const passwordUpdateButton = screen.getAllByText("Update")[3] const currentPasswordField = screen.getByLabelText(/current password/i) const newPasswordField = screen.getByLabelText(/choose a new password/i) @@ -383,7 +348,7 @@ describe("", () => { }, }) - const passwordUpdateButton = getAllByText("Update")[3] + const passwordUpdateButton = screen.getAllByText("Update")[3] const currentPasswordField = screen.getByLabelText(/current password/i) const newPasswordField = screen.getByLabelText(/choose a new password/i) @@ -395,7 +360,7 @@ describe("", () => { await promise }) - expect(getByText("Your changes have been saved.")).not.toBeNull() + expect(screen.getByText("Your changes have been saved.")).not.toBeNull() await act(async () => { const closeButton = screen.getByLabelText("Close") @@ -403,7 +368,7 @@ describe("", () => { await promise }) - expect(queryByText("Your changes have been saved.")).toBeNull() + expect(screen.queryByText("Your changes have been saved.")).toBeNull() expect(authenticatedPut).toHaveBeenCalledWith( "/api/v1/auth/password", @@ -433,7 +398,7 @@ describe("", () => { }, }) - const button = getAllByText("Update") + const button = screen.getAllByText("Update") const firstNameField: Element = screen.getByRole("textbox", { name: /first name/i, }) @@ -449,8 +414,8 @@ describe("", () => { await promise }) - expect(getAllByText("Enter first name")).toHaveLength(2) - expect(getAllByText("Enter last name")).toHaveLength(2) + expect(screen.getAllByText("Enter first name")).toHaveLength(2) + expect(screen.getAllByText("Enter last name")).toHaveLength(2) await act(async () => { screen @@ -481,7 +446,7 @@ describe("", () => { }) it("date of birth errors", async () => { - const button = getAllByText("Update") + const button = screen.getAllByText("Update") const monthField: Element = screen.getByRole("spinbutton", { name: /month/i, }) @@ -593,7 +558,7 @@ describe("", () => { }) it("email Errors", async () => { - const button = getAllByText("Update") + const button = screen.getAllByText("Update") const group = screen.getByRole("group", { name: /email/i, }) @@ -651,7 +616,7 @@ describe("", () => { ).not.toBeNull() }) it("password Errors", async () => { - const button = getAllByText("Update") + const button = screen.getAllByText("Update") const currentPasswordField = screen.getByLabelText(/current password/i) const newPasswordField = screen.getByLabelText(/choose a new password/i) @@ -729,39 +694,11 @@ describe("", () => { }) describe("when the user is not signed in", () => { - let originalUseContext let originalLocation: Location beforeEach(() => { - originalUseContext = React.useContext originalLocation = window.location - const mockContextValue: ContextProps = { - profile: undefined, - signIn: jest.fn(), - signOut: jest.fn(), - timeOut: jest.fn(), - saveProfile: jest.fn(), - loading: false, - initialStateLoaded: true, - } - - jest.spyOn(React, "useContext").mockImplementation((context) => { - if (context === UserContext) { - return mockContextValue - } - return originalUseContext(context) - }) - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - delete (window as any)?.location - ;(window as Window).location = { - ...originalLocation, - href: "http://dahlia.com", - assign: jest.fn(), - replace: jest.fn(), - reload: jest.fn(), - toString: jest.fn(), - } + setupUserContext({ loggedIn: false, saveProfileMock }) }) afterEach(() => { diff --git a/app/javascript/__tests__/pages/account/my-account.test.tsx b/app/javascript/__tests__/pages/account/my-account.test.tsx index b459dfba64..4eba5bd419 100644 --- a/app/javascript/__tests__/pages/account/my-account.test.tsx +++ b/app/javascript/__tests__/pages/account/my-account.test.tsx @@ -1,7 +1,7 @@ import { renderAndLoadAsync } from "../../__util__/renderUtils" import MyAccount from "../../../pages/account/my-account" import React from "react" -import UserContext, { ContextProps } from "../../../authentication/context/UserContext" +import { setupUserContext } from "../../__util__/accountUtils" describe("", () => { beforeEach(() => { @@ -12,31 +12,9 @@ describe("", () => { describe("when the user is signed in", () => { let getByTestId - let originalUseContext beforeEach(async () => { - originalUseContext = React.useContext - const mockContextValue: ContextProps = { - profile: { - uid: "abc123", - email: "email@email.com", - created_at: new Date(), - updated_at: new Date(), - }, - signIn: jest.fn(), - signOut: jest.fn(), - timeOut: jest.fn(), - saveProfile: jest.fn(), - loading: false, - initialStateLoaded: true, - } - - jest.spyOn(React, "useContext").mockImplementation((context) => { - if (context === UserContext) { - return mockContextValue - } - return originalUseContext(context) - }) + setupUserContext({ loggedIn: true }) const renderResult = await renderAndLoadAsync() getByTestId = renderResult.getByTestId @@ -71,39 +49,11 @@ describe("", () => { }) describe("when the user is not signed in", () => { - let originalUseContext let originalLocation: Location beforeEach(async () => { - originalUseContext = React.useContext originalLocation = window.location - const mockContextValue: ContextProps = { - profile: undefined, - signIn: jest.fn(), - signOut: jest.fn(), - timeOut: jest.fn(), - saveProfile: jest.fn(), - loading: false, - initialStateLoaded: true, - } - - jest.spyOn(React, "useContext").mockImplementation((context) => { - if (context === UserContext) { - return mockContextValue - } - return originalUseContext(context) - }) - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - delete (window as any)?.location - ;(window as Window).location = { - ...originalLocation, - href: "http://dahlia.com", - assign: jest.fn(), - replace: jest.fn(), - reload: jest.fn(), - toString: jest.fn(), - } + setupUserContext({ loggedIn: false }) await renderAndLoadAsync() }) diff --git a/app/javascript/__tests__/pages/account/my-applications.test.tsx b/app/javascript/__tests__/pages/account/my-applications.test.tsx index d6c423742d..a948ede59a 100644 --- a/app/javascript/__tests__/pages/account/my-applications.test.tsx +++ b/app/javascript/__tests__/pages/account/my-applications.test.tsx @@ -4,12 +4,12 @@ import MyApplications, { determineApplicationItemList, } from "../../../pages/account/my-applications" import React from "react" -import UserContext, { ContextProps } from "../../../authentication/context/UserContext" import { authenticatedGet, authenticatedDelete } from "../../../api/apiService" import { fireEvent, render, screen, waitFor, within } from "@testing-library/react" import { applicationWithOpenListing } from "../../data/RailsApplication/application-with-open-listing" import { Application } from "../../../api/types/rails/application/RailsApplication" import { openSaleListing } from "../../data/RailsSaleListing/listing-sale-open" +import { setupLocationAndRouteMock, setupUserContext } from "../../__util__/accountUtils" jest.mock("axios") @@ -45,63 +45,12 @@ describe("", () => { beforeEach(() => { originalLocation = window.location - const customLocation = { - ...originalLocation, - href: "http://dahlia.com", - assign: jest.fn(), - replace: jest.fn(), - reload: jest.fn(), - toString: jest.fn(), - } - - Object.defineProperty(window, "location", { - configurable: true, - enumerable: true, - writable: true, - value: customLocation, - }) - - // Redefine the href setter to resolve relative URLs - Object.defineProperty(window.location, "href", { - configurable: true, - enumerable: true, - set: function (href: string) { - const base = "http://dahlia.com" - try { - const newUrl = new URL(href, base) - this._href = newUrl.href - } catch { - this._href = href - } - }, - get: function () { - return this._href || "http://dahlia.com" - }, - }) + setupLocationAndRouteMock() }) describe("when the user is not signed in", () => { - let originalUseContext - beforeEach(() => { - originalUseContext = React.useContext - - const mockContextValue = { - profile: undefined, - signIn: jest.fn(), - signOut: jest.fn(), - timeOut: jest.fn(), - saveProfile: jest.fn(), - loading: false, - initialStateLoaded: true, - } - - jest.spyOn(React, "useContext").mockImplementation((context) => { - if (context === UserContext) { - return mockContextValue - } - return originalUseContext(context) - }) + setupUserContext({ loggedIn: false, setUpMockLocation: false }) }) afterEach(() => { @@ -118,31 +67,8 @@ describe("", () => { }) describe("when a user is signed in", () => { - let originalUseContext - beforeEach(() => { - originalUseContext = React.useContext - const mockContextValue: ContextProps = { - profile: { - uid: "abc123", - email: "email@email.com", - created_at: new Date(), - updated_at: new Date(), - }, - signIn: jest.fn(), - signOut: jest.fn(), - timeOut: jest.fn(), - saveProfile: jest.fn(), - loading: false, - initialStateLoaded: true, - } - - jest.spyOn(React, "useContext").mockImplementation((context) => { - if (context === UserContext) { - return mockContextValue - } - return originalUseContext(context) - }) + setupUserContext({ loggedIn: true, setUpMockLocation: false }) ;(authenticatedGet as jest.Mock).mockResolvedValue({ data: { data: "test-data" } }) }) diff --git a/app/javascript/__tests__/pages/reset-password.test.tsx b/app/javascript/__tests__/pages/reset-password.test.tsx index aaba3a0cab..9f9662d9c5 100644 --- a/app/javascript/__tests__/pages/reset-password.test.tsx +++ b/app/javascript/__tests__/pages/reset-password.test.tsx @@ -2,6 +2,10 @@ import React from "react" import { renderAndLoadAsync } from "../__util__/renderUtils" import ResetPassword from "../../pages/reset-password" +import { setupLocationAndRouteMock, setupUserContext } from "../__util__/accountUtils" +import { screen } from "@testing-library/react" +import { authenticatedPut } from "../../api/apiService" +import userEvent from "@testing-library/user-event" jest.mock("react-helmet-async", () => { return { @@ -10,9 +14,110 @@ jest.mock("react-helmet-async", () => { } }) +jest.mock("../../api/apiService", () => ({ + authenticatedPut: jest.fn(), +})) + describe("", () => { - it("shows the correct form text", async () => { - const { getAllByText } = await renderAndLoadAsync() - expect(getAllByText("Reset password")).not.toBeNull() + describe("when the user is not signed in", () => { + let originalLocation: Location + beforeEach(() => { + originalLocation = window.location + setupUserContext({ loggedIn: false }) + }) + afterEach(() => { + jest.restoreAllMocks() + window.location = originalLocation + }) + it("redirects to the sign in page", async () => { + await renderAndLoadAsync() + // This is a temporary workaround until we implement the redirects to the sign in page + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (window as any).location + // eslint-disable-next-line @typescript-eslint/no-explicit-any + window.location = { href: "" } as any + + Object.defineProperty(window, "location", { + value: { + href: "/sign-in", + }, + writable: true, + }) + expect(window.location.href).toBe("/sign-in") + }) + }) + describe("when the user is signed in", () => { + let originalLocation: Location + beforeEach(() => { + originalLocation = window.location + setupUserContext({ loggedIn: true }) + setupLocationAndRouteMock() + }) + + afterEach(() => { + jest.restoreAllMocks() + window.location = originalLocation + }) + + it("shows the correct form text", async () => { + await renderAndLoadAsync() + expect(screen.getAllByText("Reset password")).not.toBeNull() + }) + + it("correctly resets the users password", async () => { + ;(authenticatedPut as jest.Mock).mockResolvedValue({ + data: { + status: "success", + }, + }) + await renderAndLoadAsync() + expect(screen.getAllByText("Reset password")).not.toBeNull() + + const passwordField = screen.getAllByLabelText(/password/i)[0] + const updateButton = screen.getByRole("button", { name: /update password/i }) + + await userEvent.type(passwordField, "password") + await userEvent.click(updateButton) + + expect( + screen.getByText( + /choose a strong password with at least 8 characters, 1 letter, and 1 number/i + ) + ).not.toBeNull() + + await userEvent.type(passwordField, "1") + await userEvent.click(updateButton) + // screen.logTestingPlaygroundURL() + + expect(authenticatedPut).toHaveBeenCalledWith( + "/api/v1/auth/password", + expect.objectContaining({ + password: "password1", + password_confirmation: "password1", + }) + ) + expect(window.location.href).toBe("http://dahlia.com/my-applications") + }) + + it("shows an error message when the server responds with an error", async () => { + ;(authenticatedPut as jest.Mock).mockRejectedValueOnce({ + response: { + status: 500, + }, + }) + + await renderAndLoadAsync() + expect(screen.getAllByText("Reset password")).not.toBeNull() + + const passwordField = screen.getAllByLabelText(/password/i)[0] + const updateButton = screen.getByRole("button", { name: /update password/i }) + + await userEvent.type(passwordField, "password1") + await userEvent.click(updateButton) + + expect( + screen.getByText(/something went wrong\. try again or check back later/i) + ).not.toBeNull() + }) }) }) diff --git a/app/javascript/pages/reset-password.tsx b/app/javascript/pages/reset-password.tsx index 0674533f15..9283054960 100644 --- a/app/javascript/pages/reset-password.tsx +++ b/app/javascript/pages/reset-password.tsx @@ -3,32 +3,79 @@ import React from "react" import { AppearanceStyleType, Button, Form, FormCard, Icon, t } from "@bloom-housing/ui-components" import withAppSetup from "../layouts/withAppSetup" -import { Link } from "@bloom-housing/ui-seeds" +import { Alert, Link } from "@bloom-housing/ui-seeds" import FormLayout from "../layouts/FormLayout" import { getMyApplicationsPath, getSignInPath } from "../util/routeUtil" import { useForm } from "react-hook-form" import PasswordFieldset from "./account/components/PasswordFieldset" +import { resetPassword } from "../api/authApiService" +import UserContext from "../authentication/context/UserContext" interface ResetPasswordProps { assetPaths: unknown } -const onSubmit = (data: { email: string }) => { - // TODO: DAH-2987 API integration - console.log(data) - window.location.href = getMyApplicationsPath() +const CardTitle = () => { + return ( +
+ +

{t("pageTitle.resetPassword.lowercase")}

+
+ ) +} + +const ServerAlert = ({ message }: { message?: string }) => { + return ( + message && ( + + {message} + + ) + ) +} + +const FormButtons = () => { + return ( + <> +
+ +
+
+ {t("label.cancel")} +
+ + ) } const ResetPassword = (_props: ResetPasswordProps) => { + const [serverError, setServerError] = React.useState(null) + const { profile, loading: authLoading, initialStateLoaded } = React.useContext(UserContext) // eslint-disable-next-line @typescript-eslint/unbound-method const { register, handleSubmit, errors, watch } = useForm() + + if (!profile && !authLoading && initialStateLoaded) { + window.location.href = getSignInPath() + return null + } + + const onSubmit = (data: { password: string }) => { + // TODO: DAH-2987 API integration + resetPassword(data.password) + .then(() => { + window.location.href = getMyApplicationsPath() + }) + .catch(() => { + setServerError(t("error.account.genericServerError")) + }) + } + return ( -
- -

{t("pageTitle.resetPassword.lowercase")}

-
+ +
{ labelText={t("label.chooseNewPassword")} passwordType="resetPassword" /> -
- -
-
- {t("label.cancel")} -
+
From 74ca294cb7a5e0e1d73a71bf6c2dcdc63fb0594b Mon Sep 17 00:00:00 2001 From: Chad Brokaw <36685920+chadbrokaw@users.noreply.github.com> Date: Fri, 3 Jan 2025 11:06:30 -0700 Subject: [PATCH 3/5] fix: create temporary auth headers --- .../context/UserProvider.test.tsx | 134 ++++++++++++++++++ .../__tests__/authentication/token.test.ts | 37 +++++ .../authentication/context/UserProvider.tsx | 10 +- app/javascript/authentication/token.ts | 52 +++++++ 4 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 app/javascript/__tests__/authentication/context/UserProvider.test.tsx diff --git a/app/javascript/__tests__/authentication/context/UserProvider.test.tsx b/app/javascript/__tests__/authentication/context/UserProvider.test.tsx new file mode 100644 index 0000000000..841962aba3 --- /dev/null +++ b/app/javascript/__tests__/authentication/context/UserProvider.test.tsx @@ -0,0 +1,134 @@ +import React, { useContext } from "react" +import { act, render, screen, waitFor } from "@testing-library/react" +import UserProvider from "../../../authentication/context/UserProvider" +import UserContext, { ContextProps } from "../../../authentication/context/UserContext" +import { getProfile, signIn } from "../../../api/authApiService" +import { isTokenValid } from "../../../authentication/token" +import { renderAndLoadAsync } from "../../__util__/renderUtils" +import { mockProfileStub } from "../../__util__/accountUtils" + +jest.mock("../../../api/authApiService", () => ({ + getProfile: jest.fn(), + signIn: jest.fn(), +})) + +jest.mock("../../../authentication/token", () => { + const actualTokenModule = jest.requireActual("../../../authentication/token") + return { + ...actualTokenModule, + isTokenValid: jest.fn(), + } +}) + +const mockGetItem = jest.fn() +const mockSetItem = jest.fn() +const mockRemoveItem = jest.fn() +Object.defineProperty(window, "sessionStorage", { + value: { + getItem: (...args: string[]) => mockGetItem(...args), + setItem: (...args: string[]) => mockSetItem(...args), + removeItem: (...args: string[]) => mockRemoveItem(...args), + }, +}) + +const TestComponent = () => { + const { signIn, signOut, profile, initialStateLoaded } = useContext(UserContext) as ContextProps + + return ( +
+ {initialStateLoaded &&

Initial state loaded

} + {profile ? ( +
+

Signed in as {profile.uid}

+ +
+ ) : ( + + )} +
+ ) +} + +describe("UserProvider", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("should load profile on mount if access token is available", async () => { + ;(getProfile as jest.Mock).mockResolvedValue(mockProfileStub) + ;(isTokenValid as jest.Mock).mockReturnValue(true) + + render( + + + + ) + + await waitFor(() => expect(screen.getByText("Signed in as abc123")).not.toBeNull()) + }) + + it("should sign in and sign out a user", async () => { + ;(getProfile as jest.Mock).mockRejectedValue(undefined) + ;(signIn as jest.Mock).mockResolvedValue(mockProfileStub) + ;(isTokenValid as jest.Mock).mockReturnValueOnce(false).mockReturnValueOnce(true) + + await renderAndLoadAsync( + + + + ) + + act(() => { + screen.getByText("Sign In").click() + }) + + await waitFor(() => expect(screen.getByText("Signed in as abc123")).not.toBeNull()) + + act(() => { + screen.getByText("Sign Out").click() + }) + + await waitFor(() => expect(screen.getByText("Sign In")).not.toBeNull()) + }) + + it("should handle token invalidation on initial load", async () => { + ;(isTokenValid as jest.Mock).mockReturnValue(false) + ;(getProfile as jest.Mock).mockResolvedValue(mockProfileStub) + + await renderAndLoadAsync( + + + + ) + + expect(screen.getByText("Initial state loaded")).not.toBeNull() + }) + + it("should handle temporary auth params from URL", async () => { + Object.defineProperty(window, "location", { + writable: true, + value: { + href: "http://localhost:3000/reset-password?access-token=DDDDD&client=CCCCC&client_id=BBBBB&config=default&expiry=100&reset_password=true&token=AAAAAA&uid=test%40test.com", + }, + }) + ;(isTokenValid as jest.Mock).mockReturnValue(true) + ;(getProfile as jest.Mock).mockResolvedValue(mockProfileStub) + + await renderAndLoadAsync( + + + + ) + + await waitFor(() => expect(screen.getByText("Signed in as abc123")).not.toBeNull()) + expect(getProfile).toHaveBeenCalled() + }) +}) diff --git a/app/javascript/__tests__/authentication/token.test.ts b/app/javascript/__tests__/authentication/token.test.ts index 5aa4ef796e..45c5143216 100644 --- a/app/javascript/__tests__/authentication/token.test.ts +++ b/app/javascript/__tests__/authentication/token.test.ts @@ -3,6 +3,8 @@ import { clearHeaders, clearHeadersTimeOut, clearHeadersConnectionIssue, + getTemporaryAuthParamsFromUrl, + setAuthHeadersFromUrl, } from "../../authentication/token" const ACCESS_TOKEN_LOCAL_STORAGE_KEY = "auth_headers" @@ -49,4 +51,39 @@ describe("token.ts", () => { "There was a connection issue, so we signed you out. We do this for your security. Sign in again to continue." ) }) + it("getTemporaryAuthParamsFromURL", () => { + // Mock the window.location.href + Object.defineProperty(window, "location", { + writable: true, + value: { + href: "http://localhost:3000/reset-password?access-token=DDDDD&client=CCCCC&client_id=BBBBB&config=default&expiry=100&reset_password=true&token=AAAAAA&uid=test%40test.com", + }, + }) + + const expectedParams = { + expiry: "100", + accessToken: "DDDDD", + client: "CCCCC", + uid: "test@test.com", + tokenType: "Bearer", + reset_password: "true", + } + + const result = getTemporaryAuthParamsFromUrl() + expect(result).toEqual(expectedParams) + + const setAuthReturn = setAuthHeadersFromUrl(result) + + expect(setAuthReturn).toEqual(true) + expect(mockSetItem).toHaveBeenCalledWith( + ACCESS_TOKEN_LOCAL_STORAGE_KEY, + JSON.stringify({ + expiry: "100", + "access-token": "DDDDD", + client: "CCCCC", + uid: "test@test.com", + "token-type": "Bearer", + }) + ) + }) }) diff --git a/app/javascript/authentication/context/UserProvider.tsx b/app/javascript/authentication/context/UserProvider.tsx index fb7e7505b2..d2a85d9cd3 100644 --- a/app/javascript/authentication/context/UserProvider.tsx +++ b/app/javascript/authentication/context/UserProvider.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useReducer } from "react" import { getProfile, signIn } from "../../api/authApiService" -import { isTokenValid } from "../token" +import { getTemporaryAuthParamsFromUrl, isTokenValid, setAuthHeadersFromUrl } from "../token" import { saveProfile, userSignOut, @@ -28,6 +28,14 @@ const UserProvider = (props: UserProviderProps) => { useEffect(() => { if (!state.profile) { dispatch(startLoading()) + const temporaryAuthParamsFromUrl = getTemporaryAuthParamsFromUrl() + if ( + temporaryAuthParamsFromUrl && + temporaryAuthParamsFromUrl.accessToken && + temporaryAuthParamsFromUrl.reset_password === "true" + ) { + setAuthHeadersFromUrl(temporaryAuthParamsFromUrl) + } getProfile() .then((profile) => { dispatch(saveProfile(profile)) diff --git a/app/javascript/authentication/token.ts b/app/javascript/authentication/token.ts index 216c5fd49a..565421acf1 100644 --- a/app/javascript/authentication/token.ts +++ b/app/javascript/authentication/token.ts @@ -39,6 +39,58 @@ export const setAuthHeaders = (headers: AuthHeaders | AxiosHeaders) => { getStorage().setItem(ACCESS_TOKEN_LOCAL_STORAGE_KEY, JSON.stringify(headersToSet)) } +const parseUrlParams = (url: string): URLSearchParams => { + const urlObj = new URL(url) + return urlObj.searchParams +} + +export interface TemporaryURLParams { + expiry: string | null + accessToken: string | null + client: string | null + uid: string | null + tokenType: string + reset_password: string | null +} + +export const getTemporaryAuthParamsFromUrl = (): TemporaryURLParams => { + const params = parseUrlParams(window.location.href) + + const expiry = params.get("expiry") + const accessToken = params.get("access-token") + const client = params.get("client") + const uid = params.get("uid") + const resetPassword = params.get("reset_password") + + return { + expiry, + accessToken, + client, + uid, + tokenType: "Bearer", + reset_password: resetPassword, + } +} + +export const setAuthHeadersFromUrl = ({ + expiry, + accessToken, + client, + uid, + tokenType, +}: TemporaryURLParams) => { + if (expiry && accessToken && client && uid && tokenType) { + setAuthHeaders({ + expiry, + "access-token": accessToken, + client, + uid, + "token-type": tokenType, + } as AuthHeaders) + return true + } else return false +} + export const getHeaders = (): AuthHeaders | AxiosHeaders | undefined => getAuthHeaders() export const clearHeaders = () => { From 6420ddb031308736701b2b859a1dcf4f61e96114 Mon Sep 17 00:00:00 2001 From: Chad Brokaw <36685920+chadbrokaw@users.noreply.github.com> Date: Fri, 3 Jan 2025 11:08:34 -0700 Subject: [PATCH 4/5] fix: comment --- .../__tests__/authentication/context/UserProvider.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/__tests__/authentication/context/UserProvider.test.tsx b/app/javascript/__tests__/authentication/context/UserProvider.test.tsx index 841962aba3..0838251b59 100644 --- a/app/javascript/__tests__/authentication/context/UserProvider.test.tsx +++ b/app/javascript/__tests__/authentication/context/UserProvider.test.tsx @@ -45,9 +45,9 @@ const TestComponent = () => { ) : (