From f3de85852f4a6f00623d5a09f20f38534b178664 Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Sun, 27 Apr 2025 16:22:11 -0300 Subject: [PATCH] feat: add email validation and password reset (#430) * feat: add user confirmation email - wip issue #414 * feat: improve register flow and add confirmation email done. Issue 414 * feat: add resend email feature. Issue #414 * feat: add password reset feature. Issue #415 * test: fix and add test cases * fix: email_uuid column actually can be null * test: add mailgun service class unit tests * test: add tests for the authservice and mailgun service classes * chore: fix sonar cloud issues * test: add client test * test: add more test cases * test: fix test acse * test: add more test cases * chore: fix sonar code smells * chore: remove role element from dic --- client/src/App.tsx | 16 ++ .../__test__/components/LoginForm.test.tsx | 125 ++++++++- .../__test__/components/NoteTitle.test.tsx | 2 +- .../__test__/context/AuthProvider.test.tsx | 39 +-- .../views/CompleteResetPassword.test.tsx | 104 ++++++++ .../__test__/views/EmailConfirmation.test.tsx | 133 ++++++++++ client/src/api-service/apiConfig.ts | 8 + client/src/components/LoginForm/index.tsx | 165 ++++++++++-- client/src/constants/english.ts | 18 ++ client/src/context/AuthContext.ts | 2 +- client/src/context/AuthProvider.tsx | 20 +- .../src/views/CompleteResetPassword/index.tsx | 28 +++ client/src/views/EmailConfirmation/index.tsx | 92 +++++++ client/src/views/ResetPassword/index.tsx | 28 +++ client/tsconfig.json | 10 +- docker-compose.prod.yml | 1 + docker-compose.yml | 5 +- pending.txt | 6 - server/pom.xml | 6 +- .../controller/AuthenticationController.java | 108 +++++++- .../tasknoteapp/server/entity/UserEntity.java | 12 + .../exception/BadAlgorithmException.java | 14 ++ .../server/exception/BadUuidException.java | 14 ++ .../exception/MailServiceException.java | 14 ++ .../exception/ResetExpiredException.java | 14 ++ .../server/repository/UserRepository.java | 5 + .../request/EmailConfirmationRequest.java | 9 + .../server/request/LoginRequest.java | 7 + .../server/request/PasswordResetRequest.java | 11 + .../request/ResendConfirmationRequest.java | 27 ++ .../server/service/AuthService.java | 163 +++++++++++- .../server/service/MailgunEmailService.java | 161 ++++++++++++ .../server/templates/MailgunTemplate.java | 35 +++ .../templates/MailgunTemplateResetPwd.java | 29 +++ .../MailgunTemplateResetPwdConfirm.java | 25 ++ .../templates/MailgunTemplateSignUp.java | 29 +++ .../tasknoteapp/server/util/TokenUtil.java | 24 ++ .../com/tasknoteapp/server/util/UuidUtil.java | 72 ++++++ server/src/main/resources/application.yml | 6 + .../V202504221604__add_email_confirmation.sql | 5 + .../AuthenticationControllerTest.java | 28 +-- .../repository/UserRepositoryIntTest.java | 38 +++ .../server/service/AuthServiceTest.java | 238 +++++++++++++++++- .../service/MailgunEmailServiceTest.java | 99 ++++++++ .../server/templates/MailgunTemplateTest.java | 40 +++ .../server/util/TokenUtilTest.java | 15 ++ .../tasknoteapp/server/util/UuidUtilTest.java | 18 ++ server/src/test/resources/application.yml | 7 + .../test/resources/sql/UserRepositoryTest.sql | 4 + 49 files changed, 1929 insertions(+), 150 deletions(-) create mode 100644 client/src/__test__/views/CompleteResetPassword.test.tsx create mode 100644 client/src/__test__/views/EmailConfirmation.test.tsx create mode 100644 client/src/views/CompleteResetPassword/index.tsx create mode 100644 client/src/views/EmailConfirmation/index.tsx create mode 100644 client/src/views/ResetPassword/index.tsx delete mode 100644 pending.txt create mode 100644 server/src/main/java/br/com/tasknoteapp/server/exception/BadAlgorithmException.java create mode 100644 server/src/main/java/br/com/tasknoteapp/server/exception/BadUuidException.java create mode 100644 server/src/main/java/br/com/tasknoteapp/server/exception/MailServiceException.java create mode 100644 server/src/main/java/br/com/tasknoteapp/server/exception/ResetExpiredException.java create mode 100644 server/src/main/java/br/com/tasknoteapp/server/request/EmailConfirmationRequest.java create mode 100644 server/src/main/java/br/com/tasknoteapp/server/request/PasswordResetRequest.java create mode 100644 server/src/main/java/br/com/tasknoteapp/server/request/ResendConfirmationRequest.java create mode 100644 server/src/main/java/br/com/tasknoteapp/server/service/MailgunEmailService.java create mode 100644 server/src/main/java/br/com/tasknoteapp/server/templates/MailgunTemplate.java create mode 100644 server/src/main/java/br/com/tasknoteapp/server/templates/MailgunTemplateResetPwd.java create mode 100644 server/src/main/java/br/com/tasknoteapp/server/templates/MailgunTemplateResetPwdConfirm.java create mode 100644 server/src/main/java/br/com/tasknoteapp/server/templates/MailgunTemplateSignUp.java create mode 100644 server/src/main/java/br/com/tasknoteapp/server/util/TokenUtil.java create mode 100644 server/src/main/java/br/com/tasknoteapp/server/util/UuidUtil.java create mode 100644 server/src/main/resources/db/migration/V202504221604__add_email_confirmation.sql create mode 100644 server/src/test/java/br/com/tasknoteapp/server/repository/UserRepositoryIntTest.java create mode 100644 server/src/test/java/br/com/tasknoteapp/server/service/MailgunEmailServiceTest.java create mode 100644 server/src/test/java/br/com/tasknoteapp/server/templates/MailgunTemplateTest.java create mode 100644 server/src/test/java/br/com/tasknoteapp/server/util/TokenUtilTest.java create mode 100644 server/src/test/java/br/com/tasknoteapp/server/util/UuidUtilTest.java create mode 100644 server/src/test/resources/sql/UserRepositoryTest.sql diff --git a/client/src/App.tsx b/client/src/App.tsx index ad178d1..4a96aa8 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -13,6 +13,9 @@ import Landing from './views/Landing'; import Login from './views/Login'; import NotFound from './views/NotFound'; import Register from './views/Register'; +import EmailConfirmation from './views/EmailConfirmation'; +import ResetPassword from './views/ResetPassword'; +import CompleteResetPassword from './views/CompleteResetPassword'; import './styles/custom.scss'; /** @@ -49,6 +52,19 @@ function App(): React.ReactNode { path: '/home', element: }, + { + path: '/email-confirmation', + element: + }, + { + // The reset-password is where the password reset workflow starts + path: '/reset-password', + element: + }, + { + path: '/finish-reset-password', + element: + }, { path: '*', element: diff --git a/client/src/__test__/components/LoginForm.test.tsx b/client/src/__test__/components/LoginForm.test.tsx index cddad49..57347be 100644 --- a/client/src/__test__/components/LoginForm.test.tsx +++ b/client/src/__test__/components/LoginForm.test.tsx @@ -1,17 +1,46 @@ import React from 'react'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { BrowserRouter } from 'react-router'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import LoginForm from '../../components/LoginForm'; import '../../i18n'; +import AuthContext from '../../context/AuthContext'; + +const authContextMock = { + signed: true, + user: { + userId: 1, + name: 'Ricardo', + email: 'test@example.com', + admin: false, + createdAt: new Date(), + gravatarImageUrl: 'http://image.com' + }, + checkCurrentAuthUser: vi.fn(), + signIn: vi.fn(), + signOut: vi.fn(), + register: vi.fn(), + isAdmin: false, + updateUser: vi.fn() +}; describe('LoginForm Component test', () => { - it('should render the Login Form component correctly', () => { - render( + beforeEach(() => { + vi.resetAllMocks(); + }); + + const renderFn = (prefix: string) => { + return render( - + + + ); + }; + + it('should render the Login Form component correctly', () => { + renderFn("login"); const loginEmailInput: HTMLInputElement = screen.getByTestId('login_email_input'); expect(loginEmailInput).toBeDefined(); @@ -19,14 +48,90 @@ describe('LoginForm Component test', () => { }); it('should render the Register Form component correctly', () => { - render( - - - - ); + renderFn("register"); const loginEmailInput: HTMLInputElement = screen.getByTestId('register_email_input'); expect(loginEmailInput).toBeDefined(); expect(loginEmailInput.required).toBe(true); }); + + it('should render the Login Form correctly for "login" prefix', () => { + renderFn("login"); + + const emailInput = screen.getByTestId('login_email_input'); + const passwordInput = screen.getByTestId('account-password-login'); + const submitButton = screen.getByRole('button', { name: /login/i }); + + expect(emailInput).toBeDefined(); + expect(passwordInput).toBeDefined(); + expect(submitButton).toBeDefined(); + }); + + it('should render the Register Form correctly for "register" prefix', () => { + renderFn("register"); + + const emailInput = screen.getByTestId('register_email_input'); + const passwordInput = screen.getByTestId('account-password-login'); + const repeatPasswordInput = screen.getByTestId('account-repeat-password-register'); + const submitButton = screen.getByRole('button', { name: /create account/i }); + + expect(emailInput).toBeDefined(); + expect(passwordInput).toBeDefined(); + expect(repeatPasswordInput).toBeDefined(); + expect(submitButton).toBeDefined(); + }); + + it('should render the Reset Password Form correctly for "reset" prefix', () => { + renderFn("reset"); + + const emailInput = screen.getByTestId('reset_email_input'); + const submitButton = screen.getByRole('button', { name: /Send confirmation email/i }); + + expect(emailInput).toBeDefined(); + expect(submitButton).toBeDefined(); + }); + + it('should display error message when form is invalid', () => { + renderFn("login"); + + const submitButton = screen.getByRole('button', { name: /login/i }); + fireEvent.click(submitButton); + + const errorMessage = screen.getByText(/please fill in your username and password/i); + expect(errorMessage).toBeDefined(); + }); + + it('should display success message for "register" prefix after successful submission', async () => { + renderFn("register"); + + const emailInput = screen.getByTestId('register_email_input'); + const passwordInput = screen.getByTestId('account-password-login'); + const repeatPasswordInput = screen.getByTestId('account-repeat-password-register'); + const submitButton = screen.getByRole('button', { name: /create account/i }); + + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'password123' } }); + fireEvent.change(repeatPasswordInput, { target: { value: 'password123' } }); + fireEvent.click(submitButton); + + const successMessage = await screen.findByText(/please confirm your email before proceeding/i); + expect(successMessage).toBeDefined(); + }); + + it('should disable resend button and show countdown for "register" prefix', async () => { + renderFn("register"); + + const emailInput = screen.getByTestId('register_email_input'); + const passwordInput = screen.getByTestId('account-password-login'); + const repeatPasswordInput = screen.getByTestId('account-repeat-password-register'); + const submitButton = screen.getByRole('button', { name: /create account/i }); + + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'password123' } }); + fireEvent.change(repeatPasswordInput, { target: { value: 'password123' } }); + fireEvent.click(submitButton); + + const resendButton = screen.queryByRole('button', { name: /resend confirmation email/i }) as HTMLButtonElement; + expect(resendButton).toBeNull(); + }); }); diff --git a/client/src/__test__/components/NoteTitle.test.tsx b/client/src/__test__/components/NoteTitle.test.tsx index 48df10c..817eb45 100644 --- a/client/src/__test__/components/NoteTitle.test.tsx +++ b/client/src/__test__/components/NoteTitle.test.tsx @@ -5,7 +5,7 @@ import NoteTitle from '../../components/NoteTitle'; describe('NoteTitle test cases', () => { it('should render the note title', () => { - const { getByText } = render() + const { getByText } = render() expect(getByText('Test Title')).toBeDefined(); }); diff --git a/client/src/__test__/context/AuthProvider.test.tsx b/client/src/__test__/context/AuthProvider.test.tsx index 57af514..8b4d9ee 100644 --- a/client/src/__test__/context/AuthProvider.test.tsx +++ b/client/src/__test__/context/AuthProvider.test.tsx @@ -38,7 +38,7 @@ const ConsumerComponent: React.FC = () => { + + + )} + + {successMessage.length > 1 && prefix !== 'register' && ( + + { successMessage } + + )} +
- - {t(`${prefix}_email_label`)} - ) => { + setEmail(e.target.value); + }} /> - + )} - - {t(`${prefix}_password_label`)} - ) => { + setPassword(e.target.value); + }} + data_testid="account-password-login" /> - + )} + + {(prefix === 'register' || prefix === 'complete_reset') && ( + ) => { + setPasswordAgain(e.target.value); + }} + data_testid={`account-repeat-password-${prefix}`} + /> + )}