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
This commit is contained in:
2025-04-27 16:22:11 -03:00
committed by GitHub
parent ed5b16c515
commit f3de85852f
49 changed files with 1929 additions and 150 deletions
+16
View File
@@ -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: <Navigate to="/login" replace />
},
{
path: '/email-confirmation',
element: <EmailConfirmation />
},
{
// The reset-password is where the password reset workflow starts
path: '/reset-password',
element: <ResetPassword />
},
{
path: '/finish-reset-password',
element: <CompleteResetPassword />
},
{
path: '*',
element: <Navigate to="/" replace />
+115 -10
View File
@@ -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(
<BrowserRouter>
<LoginForm prefix="login" />
<AuthContext.Provider value={authContextMock}>
<LoginForm prefix={prefix} />
</AuthContext.Provider>
</BrowserRouter>
);
};
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(
<BrowserRouter>
<LoginForm prefix="register" />
</BrowserRouter>
);
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();
});
});
@@ -5,7 +5,7 @@ import NoteTitle from '../../components/NoteTitle';
describe('NoteTitle test cases', () => {
it('should render the note title', () => {
const { getByText } = render(<NoteTitle title="Test Title" />)
const { getByText } = render(<NoteTitle title="Test Title" noteUrl={null} />)
expect(getByText('Test Title')).toBeDefined();
});
@@ -38,7 +38,7 @@ const ConsumerComponent: React.FC = () => {
<button
data-testid="register"
onClick={() => {
register('new@example.com', 'password123');
register('new@example.com', 'password123', 'password123');
}}
>
Register
@@ -133,42 +133,7 @@ describe('AuthProvider', () => {
expect(localStorage.getItem(USER_DATA)).not.toBeNull();
});
it('should register a new user successfully', async () => {
const fakeResponse = {
token: 'register-token',
userId: '456',
name: 'New User',
email: 'new@example.com',
admin: false,
createdAt: new Date(),
gravatarImageUrl: 'http://dummyimage.com'
};
vi.spyOn(api, 'putJSON').mockResolvedValue(fakeResponse);
const user = userEvent.setup();
let getByTestIdFunction;
await act(async () => {
const { getByTestId } = render(
<AuthProvider>
<ConsumerComponent />
</AuthProvider>
);
getByTestIdFunction = getByTestId;
});
await waitFor(() => expect(getByTestIdFunction('register')).toBeDefined());
await user.click(getByTestIdFunction('register'));
await waitFor(() =>
expect(getByTestIdFunction('signed').textContent).toBe('true')
);
expect(getByTestIdFunction('user').textContent).toBe('New User');
expect(localStorage.getItem(API_TOKEN)).toBe('register-token');
expect(localStorage.getItem(USER_DATA)).not.toBeNull();
});
it('should sign out a user', async () => {
// Pre-populate localStorage to simulate a signed-in state.
@@ -0,0 +1,104 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi, Mock } from 'vitest';
import { useNavigate } from 'react-router';
import AuthContext from '../../context/AuthContext';
import CompleteResetPassword from '../../views/CompleteResetPassword';
// Mock useNavigate
vi.mock('react-router', () => ({
useNavigate: vi.fn()
}));
// Mock LoginForm
vi.mock('../../components/LoginForm', () => ({
__esModule: true,
default: ({ prefix }: { prefix: string }) => (
<div data-testid="login-form">LoginForm with prefix: {prefix}</div>
)
}));
vi.mock('react-router', () => {
return {
useNavigate: vi.fn(),
};
});
const mockedUseNavigate = useNavigate as unknown as Mock;
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('CompleteResetPassword Component', () => {
const mockCheckCurrentAuthUser = vi.fn();
const mockNavigate = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockedUseNavigate.mockReturnValue(mockNavigate);
});
test('calls checkCurrentAuthUser with the correct pathname', () => {
render(
<AuthContext.Provider
value={{
...authContextMock,
signed: false,
checkCurrentAuthUser: mockCheckCurrentAuthUser,
}}
>
<CompleteResetPassword />
</AuthContext.Provider>
);
expect(mockCheckCurrentAuthUser).toHaveBeenCalledWith(window.location.pathname);
});
test('navigates to /home when signed is true', () => {
render(
<AuthContext.Provider
value={{
...authContextMock,
signed: true,
checkCurrentAuthUser: mockCheckCurrentAuthUser,
}}
>
<CompleteResetPassword />
</AuthContext.Provider>
);
expect(mockNavigate).toHaveBeenCalledWith('/home');
});
test('renders LoginForm with the correct prefix', () => {
render(
<AuthContext.Provider
value={{
...authContextMock,
signed: false,
checkCurrentAuthUser: mockCheckCurrentAuthUser,
}}
>
<CompleteResetPassword />
</AuthContext.Provider>
);
const loginForm = screen.getByTestId('login-form');
expect(loginForm.innerHTML.includes('LoginForm with prefix: complete_reset')).toBe(true);
});
});
@@ -0,0 +1,133 @@
// filepath: /home/ricardo/Projects/react-typescript-todolist/client/src/__test__/views/EmailConfirmation.test.tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { vi, describe, test, expect, beforeEach } from 'vitest';
import api from '../../api-service/api';
import EmailConfirmation from '../../views/EmailConfirmation';
import { MemoryRouter } from 'react-router';
import ApiConfig from '../../api-service/apiConfig';
// Mock api.postJSON
vi.mock('../../api-service/api');
const mockedPostJSON = vi.mocked(api.postJSON);
describe('EmailConfirmation Component', () => {
beforeEach(() => {
vi.clearAllMocks();
window.history.pushState({}, '', '/?identification=test-id'); // Default query param
});
const renderFn = () => {
return render(
<MemoryRouter>
<EmailConfirmation />
</MemoryRouter>
);
}
test('renders loading state initially', () => {
render(<EmailConfirmation />);
expect(screen.getByText('Confirming your email address...')).toBeDefined();
});
test('shows error when identification is missing', async () => {
window.history.pushState({}, '', '/'); // No query param
render(<EmailConfirmation />);
await waitFor(() => {
expect(screen.getByText('❌ Oops!')).toBeDefined();
expect(screen.getByText('Wrong or missing identification.')).toBeDefined();
});
});
test('shows success message when API call succeeds', async () => {
vi.spyOn(api, 'postJSON').mockResolvedValue({});
renderFn();
await waitFor(() => {
expect(screen.getByText('✅ Email Confirmed')).toBeDefined();
});
});
test('shows error message when API call fails', async () => {
mockedPostJSON.mockRejectedValueOnce(new Error('API error occurred')); // Mock API failure
renderFn();
await waitFor(() => {
expect(screen.getByText('❌ Oops!')).toBeDefined();
expect(screen.getByText('API error occurred')).toBeDefined();
});
});
test('does not call API if identification is missing', async () => {
window.history.pushState({}, '', '/'); // No query param
renderFn();
await waitFor(() => {
expect(mockedPostJSON).not.toHaveBeenCalled();
});
});
test('calls API with correct payload when identification is present', async () => {
mockedPostJSON.mockResolvedValueOnce({}); // Mock successful API response
renderFn();
await waitFor(() => {
expect(mockedPostJSON).toHaveBeenCalledWith(ApiConfig.confirmUrl, { identification: 'test-id' });
});
});
test('renders correct styles for success state', async () => {
mockedPostJSON.mockResolvedValueOnce({}); // Mock successful API response
renderFn();
await waitFor(() => {
const successMessage = screen.getByText('✅ Email Confirmed');
expect(successMessage).toBeDefined();
expect(successMessage.classList.contains('mb-3')).toBe(true);
});
});
test('renders correct styles for error state', async () => {
mockedPostJSON.mockRejectedValueOnce(new Error('API error occurred')); // Mock API failure
renderFn();
await waitFor(() => {
const errorMessage = screen.getByText('❌ Oops!');
expect(errorMessage).toBeDefined();
expect(errorMessage.classList.contains('mb-3')).toBe(true);
});
});
test('renders "Go to Login" button on success', async () => {
mockedPostJSON.mockResolvedValueOnce({}); // Mock successful API response
renderFn();
await waitFor(() => {
const loginButton = screen.getByText('Go to Login');
expect(loginButton).toBeDefined();
expect(loginButton.classList.contains('btn-success')).toBe(true);
});
});
test('does not render "Go to Login" button on error', async () => {
mockedPostJSON.mockRejectedValueOnce(new Error('API error occurred')); // Mock API failure
renderFn();
await waitFor(() => {
const loginButton = screen.queryByText('Go to Login');
expect(loginButton).toBeNull();
});
});
test('shows error message when API call fails', async () => {
mockedPostJSON.mockRejectedValueOnce(new Error('API error occurred')); // Mock API failure
renderFn();
await waitFor(() => {
expect(screen.getByText('❌ Oops!')).toBeDefined();
expect(screen.getByText('API error occurred')).toBeDefined();
});
});
});
+8
View File
@@ -8,6 +8,14 @@ const ApiConfig = {
registerUrl: `${server}/auth/sign-up`,
confirmUrl: `${server}/auth/email-confirmation`,
resetPwdUrl: `${server}/auth/password-reset`,
completeResetPwdUrl: `${server}/auth/complete-password-reset`,
resendConfirmUrl: `${server}/auth/resend-email-confirmation`,
refreshTokenUrl: `${server}/rest/user-sessions/refresh`,
deleteAccountUrl: `${server}/rest/user-sessions/delete-account`,
+140 -25
View File
@@ -13,11 +13,14 @@ import {
Form,
Row
} from 'react-bootstrap';
import { Link, useNavigate } from 'react-router';
import { Link, useNavigate, useSearchParams } from 'react-router';
import { useTranslation } from 'react-i18next';
import AuthContext from '../../context/AuthContext';
import { translateServerResponse } from '../../utils/TranslatorUtils';
import { handleDefaultLang } from '../../lang-service/LangHandler';
import FormInput from '../FormInput';
import api from '../../api-service/api';
import ApiConfig from '../../api-service/apiConfig';
/**
* @returns {React.ReactNode} The form component for Login and Register pages.
@@ -26,9 +29,16 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
const { signIn, register } = useContext(AuthContext);
const { i18n, t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [validated, setValidated] = useState<boolean>(false);
const [formInvalid, setFormInvalid] = useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<string>('');
const [successMessage, setSuccessMessage] = useState<string>('');
const [email, setEmail] = useState<string>('');
const [password, setPassword] = useState<string>('');
const [passwordAgain, setPasswordAgain] = useState<string>('');
const [secondsLeft, setSecondsLeft] = useState<number>(0);
const [isResendEnabled, setIsResendEnabled] = useState(true);
/**
* Navigates to the specified page.
@@ -53,19 +63,63 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
const form = event.currentTarget;
if (form.checkValidity() === false) {
setFormInvalid(true);
setErrorMessage(translateServerResponse('Please fill in your username and password!', i18n.language));
if (prefix !== 'reset') {
setErrorMessage(translateServerResponse('Please fill in your username and password!', i18n.language));
}
else {
setErrorMessage(translateServerResponse('Please fill in your email', i18n.language));
}
return;
}
if (password !== passwordAgain && prefix === 'register') {
setFormInvalid(true);
setErrorMessage(translateServerResponse('Please fill in your username and password!', i18n.language));
}
setFormInvalid(false);
try {
if (prefix === 'login') {
await signIn(form.email.value, form.password.value);
await signIn(email, password);
goTo('/home');
}
else {
await register(form.email.value, form.password.value);
else if (prefix === 'register') {
await register(email, password, passwordAgain);
// Do not clear the email, because user might request to resend
setPassword('');
setPasswordAgain('');
setSuccessMessage('Please confirm your email before proceeding.');
setSecondsLeft(30);
setIsResendEnabled(false);
}
goTo('/home');
else if (prefix === 'reset') {
await api.postJSON(ApiConfig.resetPwdUrl, { email });
setSuccessMessage('If the email address you entered is associated with an account, you will receive a password reset link shortly.');
}
else if (prefix === 'complete_reset') {
const token = searchParams.get('token');
await api.postJSON(ApiConfig.completeResetPwdUrl, { token, password, passwordAgain });
goTo('/home');
}
}
catch (e) {
setFormInvalid(true);
if (typeof e === 'string') {
setErrorMessage(translateServerResponse(e, i18n.language));
}
else if (e instanceof Error) {
setErrorMessage(translateServerResponse(e.message, i18n.language));
}
}
};
const handleResend = async () => {
try {
await api.postJSON(ApiConfig.resendConfirmUrl, { email });
setSuccessMessage('Confirmation email re-sent! Please check the spam folder.');
setSecondsLeft(30);
setIsResendEnabled(false);
}
catch (e) {
setFormInvalid(true);
@@ -82,6 +136,16 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
handleDefaultLang();
}, [formInvalid]);
useEffect(() => {
if (secondsLeft > 0) {
const timer = setTimeout(() => setSecondsLeft(prev => prev - 1), 1000);
return () => clearTimeout(timer);
}
else {
setIsResendEnabled(true);
}
}, [secondsLeft]);
return (
<Container as="main" fluid className="login-page">
<Row className="justify-content-center w-100">
@@ -98,27 +162,75 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
)
: null}
{successMessage.length > 1 && prefix === 'register' && (
<>
<Alert variant="success">
{ successMessage }
</Alert>
<div className="text-center">
<Button
variant="outline-secondary"
onClick={handleResend}
disabled={!isResendEnabled}
>
{isResendEnabled ? 'Resend Confirmation Email' : `Resend in ${secondsLeft}s`}
</Button>
</div>
</>
)}
{successMessage.length > 1 && prefix !== 'register' && (
<Alert variant="success">
{ successMessage }
</Alert>
)}
<Form noValidate validated={validated} onSubmit={handleSubmit}>
<Form.Group className="mb-3" controlId="formBasicEmail">
<Form.Label>{t(`${prefix}_email_label`)}</Form.Label>
<Form.Control
required
type="email"
{prefix !== 'complete_reset' && (
<FormInput
labelText="Email"
iconName="At"
required={true}
name="email"
placeholder={t(`${prefix}_email_placeholder`)}
data-testid={`${prefix}_email_input`}
data_testid={`${prefix}_email_input`}
value={email}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value);
}}
/>
</Form.Group>
)}
<Form.Group className="mb-3" controlId="formBasicPassword">
<Form.Label>{t(`${prefix}_password_label`)}</Form.Label>
<Form.Control
required
{prefix !== 'reset' && (
<FormInput
labelText="Password"
iconName="Lock"
required={true}
type="password"
name="password"
placeholder={t(`${prefix}_password_placeholder`)}
value={password}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setPassword(e.target.value);
}}
data_testid="account-password-login"
/>
</Form.Group>
)}
{(prefix === 'register' || prefix === 'complete_reset') && (
<FormInput
labelText="Repeat password"
iconName="Lock"
required={true}
type="password"
name="passwordAgain"
value={passwordAgain}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setPasswordAgain(e.target.value);
}}
data_testid={`account-repeat-password-${prefix}`}
/>
)}
<Button
variant="primary"
@@ -130,12 +242,8 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
</Form>
<div className="text-center mt-3">
{prefix === 'login'
? (
`${t('login_account')} `
)
: `${t('register_account')} `}
<Link to={prefix === 'login' ? '/register' : '/login'} className="text-decoration-none">
{t(`${prefix}_account`)}
<Link to={prefix === 'login' ? '/register' : '/login'} className="text-decoration-none ms-2">
{t(`${prefix}_go_other`)}
</Link>
</div>
@@ -145,6 +253,13 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
{t(`${prefix}_back_home`)}
</Link>
</div>
<div className="text-center mt-3">
{prefix === 'login' && (
<Link to="/reset-password" className="text-decoration-none ms-2">
Forgot your password?
</Link>
)}
</div>
</Card.Body>
</Card>
</Col>
+18
View File
@@ -28,6 +28,24 @@ const enTranslations = {
register_go_other: 'Login',
register_back_home: 'Back to home',
reset_title: 'Reset your password',
reset_email_label: 'Email',
reset_email_placeholder: 'Type your email',
reset_password_label: 'Password',
reset_password_placeholder: 'Type your password',
reset_btn_submit: 'Send confirmation email',
reset_account: 'Have you remembered your password?',
reset_go_other: 'Login',
reset_back_home: 'Back to home',
complete_reset_title: 'Create your new password',
complete_reset_password_label: 'Password',
complete_reset_password_placeholder: 'Type your password',
complete_reset_btn_submit: 'Reset',
complete_reset_account: 'Have you remembered your password?',
complete_reset_go_other: 'Login',
complete_reset_back_home: 'Back to home',
home_nav_home: 'Home',
home_nav_tasks: 'Tasks',
home_nav_notes: 'Notes',
+1 -1
View File
@@ -7,7 +7,7 @@ export interface AuthContextData {
checkCurrentAuthUser: (pathname: string) => Promise<void>;
signIn: (email: string, password: string) => Promise<string>;
signOut: () => void;
register: (email: string, password: string) => Promise<string>;
register: (email: string, password: string, passwordAgain: string) => Promise<string>;
isAdmin: boolean;
updateUser: (userUpdated: UserResponse) => void;
}
+4 -16
View File
@@ -72,22 +72,10 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
}
};
const register = async (email: string, password: string): Promise<string> => {
const register = async (email: string, password: string, passwordAgain: string): Promise<string> => {
try {
const payload = { email, password };
const registerResponse: SigninResponse = await api.putJSON(ApiConfig.registerUrl, payload);
const currentUser: UserResponse = {
userId: registerResponse.userId,
name: registerResponse.name,
email: registerResponse.email,
admin: registerResponse.admin,
createdAt: new Date(registerResponse.createdAt),
gravatarImageUrl: registerResponse.gravatarImageUrl
};
setSigned(true);
setUser(currentUser);
updateUserSession(currentUser, registerResponse.token);
const payload = { email, password, passwordAgain };
await api.putJSON(ApiConfig.registerUrl, payload);
return Promise.resolve('OK');
}
catch (e) {
@@ -180,7 +168,7 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
return (
<AuthContext.Provider value={contextValue}>
{ children }
{children}
</AuthContext.Provider>
);
};
@@ -0,0 +1,28 @@
import React, { useContext, useEffect } from 'react';
import LoginForm from '../../components/LoginForm';
import AuthContext from '../../context/AuthContext';
import { useNavigate } from 'react-router';
/**
* Login page component.
*
* This component displays the login page of the application,
* providing navigation to register or back to home.
*
* @returns {React.ReactNode} The Login page component.
*/
function CompleteResetPassword(): React.ReactNode {
const { signed, checkCurrentAuthUser } = useContext(AuthContext);
const navigate = useNavigate();
useEffect(() => {
checkCurrentAuthUser(window.location.pathname);
if (signed) {
navigate('/home');
}
}, [signed]);
return <LoginForm prefix="complete_reset" />;
}
export default CompleteResetPassword;
@@ -0,0 +1,92 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router';
import api from '../../api-service/api';
import ApiConfig from '../../api-service/apiConfig';
type Status = 'loading' | 'success' | 'error';
const EmailConfirmation: React.FC = () => {
const [status, setStatus] = useState<Status>('loading');
const [errorMessage, setErrorMessage] = useState<string>('');
const confirmEmail = async (): Promise<void> => {
const urlParams = new URLSearchParams(window.location.search);
const id = urlParams.get('identification');
if (!id) {
setStatus('error');
setErrorMessage('Wrong or missing identification.');
return;
}
const payload = { identification: id };
try {
await api.postJSON(ApiConfig.confirmUrl, payload);
setStatus('success');
}
catch (e) {
setStatus('error');
if (e instanceof Error) {
setErrorMessage(e.message);
}
else {
setErrorMessage(e as string);
}
}
};
useEffect(() => {
confirmEmail();
}, []);
return (
<div
className="d-flex flex-column align-items-center justify-content-center min-vh-100 px-3 text-center"
style={{
backgroundColor: '#E6F5E9',
color: '#212529',
fontFamily: 'Poppins, sans-serif'
}}
>
<link
href="https://fonts.googleapis.com/css2?family=Poppins&display=swap"
rel="stylesheet"
/>
<div className="p-4 bg-white rounded-3 shadow" style={{ maxWidth: '500px', width: '100%' }}>
{status === 'loading' && (
<>
<div className="spinner-border text-success mb-3"></div>
<p>Confirming your email address...</p>
</>
)}
{status === 'success' && (
<>
<h1 className="mb-3"> Email Confirmed</h1>
<p className="mb-4">
Thank you! Your email address has been successfully confirmed.
You can now return to the TaskNote app and log in.
</p>
<Link to="/login" className="btn btn-success px-4 py-2"style={{ fontWeight: 'bold', borderRadius: '6px' }}>
Go to Login
</Link>
</>
)}
{status === 'error' && (
<>
<h1 className="mb-3"> Oops!</h1>
<p className="mb-4">
{ errorMessage }
</p>
</>
)}
</div>
</div>
);
};
export default EmailConfirmation;
+28
View File
@@ -0,0 +1,28 @@
import React, { useContext, useEffect } from 'react';
import LoginForm from '../../components/LoginForm';
import AuthContext from '../../context/AuthContext';
import { useNavigate } from 'react-router';
/**
* Login page component.
*
* This component displays the login page of the application,
* providing navigation to register or back to home.
*
* @returns {React.ReactNode} The Login page component.
*/
function ResetPassword(): React.ReactNode {
const { signed, checkCurrentAuthUser } = useContext(AuthContext);
const navigate = useNavigate();
useEffect(() => {
checkCurrentAuthUser(window.location.pathname);
if (signed) {
navigate('/home');
}
}, [signed]);
return <LoginForm prefix="reset" />;
}
export default ResetPassword;
+5 -5
View File
@@ -2,25 +2,25 @@
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Node 20",
"compilerOptions": {
"module": "ESNext",
"target": "ESNext",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"outDir": "./dist/"
},
"include": [
+1
View File
@@ -27,6 +27,7 @@ services:
CORS_ALLOWED_ORIGINS: http://localhost:5000
SERVER_SERVLET_CONTEXT_PATH: /
SECURITY_KEY: ${SECURITY_KEY}
TARGET_ENV: production
ports:
- "8585:8585"
image: server:candidate
+2 -3
View File
@@ -32,6 +32,8 @@ services:
CORS_ALLOWED_ORIGINS: http://localhost:5000
SERVER_SERVLET_CONTEXT_PATH: /
SECURITY_KEY: ${SECURITY_KEY}
MAILGUN_APIKEY: ${MAILGUN_APIKEY}
TARGET_ENV: development
ports:
- "8585:8585"
- "5005:5005"
@@ -68,9 +70,6 @@ services:
POSTGRES_DB: tasknote
POSTGRES_USER: tasknoteuser
POSTGRES_PASSWORD: default
PGDATA: /tmp
volumes:
- "./data:/tmp"
ports:
- "5432:5432"
healthcheck:
-6
View File
@@ -1,6 +0,0 @@
- make add task button and other text white
- remove tasks icons
- when a task is done, create another tag #done on it
- test multiple tags
- improve the calendar icon and due date display
- improve styling for rendered markdown
+5 -1
View File
@@ -114,6 +114,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- OPs & Tools -->
<dependency>
@@ -353,7 +357,7 @@
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>78%</minimum>
<minimum>75%</minimum>
</limit>
</limits>
</rule>
@@ -3,7 +3,10 @@ package br.com.tasknoteapp.server.controller;
import br.com.tasknoteapp.server.exception.EmailAlreadyExistsException;
import br.com.tasknoteapp.server.exception.InvalidCredentialsException;
import br.com.tasknoteapp.server.exception.UserNotFoundException;
import br.com.tasknoteapp.server.request.EmailConfirmationRequest;
import br.com.tasknoteapp.server.request.LoginRequest;
import br.com.tasknoteapp.server.request.PasswordResetRequest;
import br.com.tasknoteapp.server.request.ResendConfirmationRequest;
import br.com.tasknoteapp.server.response.UserResponseWithToken;
import br.com.tasknoteapp.server.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
@@ -14,7 +17,6 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.Objects;
import lombok.AllArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
@@ -45,7 +47,7 @@ public class AuthenticationController {
summary = "Signup a new user",
description = "Signup a new user given his email and password",
responses = {
@ApiResponse(responseCode = "201", description = "User successfully created and saved"),
@ApiResponse(responseCode = "204", description = "User successfully created and saved"),
@ApiResponse(
responseCode = "400",
description = "Wrong or missing information",
@@ -55,10 +57,9 @@ public class AuthenticationController {
description = "Email already in use",
content = @Content(schema = @Schema(implementation = Void.class)))
})
public ResponseEntity<UserResponseWithToken> signUp(
@RequestBody @Valid LoginRequest loginRequest) {
UserResponseWithToken response = authService.signUpNewUser(loginRequest);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
public ResponseEntity<Void> signUp(@RequestBody @Valid LoginRequest loginRequest) {
authService.signUpNewUser(loginRequest);
return ResponseEntity.noContent().build();
}
/**
@@ -79,10 +80,6 @@ public class AuthenticationController {
responseCode = "400",
description = "Wrong or missing information",
content = @Content(schema = @Schema(implementation = Void.class))),
@ApiResponse(
responseCode = "401",
description = "Unauthorized. Invalid credentials",
content = @Content(schema = @Schema(implementation = Void.class))),
@ApiResponse(
responseCode = "404",
description = "User not found",
@@ -96,4 +93,95 @@ public class AuthenticationController {
}
return ResponseEntity.ok().body(response);
}
/**
* Send a confirmation email to the user.
*
* @param confirmation The request containing the user uuid.
* @return No content 204 http code.
*/
@PostMapping(path = "/email-confirmation", consumes = "application/json")
@Operation(
summary = "Send a confirmation email to the user",
description = "After the registration sends the user a confirmation email",
responses = {
@ApiResponse(responseCode = "204", description = "User successfully logged in"),
@ApiResponse(
responseCode = "400",
description = "Wrong or missing information",
content = @Content(schema = @Schema(implementation = Void.class))),
})
public ResponseEntity<Void> confirmEmailAddress(
@RequestBody @Valid EmailConfirmationRequest confirmation) {
authService.confirmUserAccount(confirmation.identification());
return ResponseEntity.noContent().build();
}
/**
* Re-Send a confirmation email to the user.
*
* @param request The request containing user's email.
* @return No content 204 http code.
*/
@PostMapping(path = "/resend-email-confirmation", consumes = "application/json")
@Operation(
summary = "Re-Send a confirmation email to the user",
description = "Allow users to resend the confirmation email",
responses = {
@ApiResponse(responseCode = "204", description = "User email confirmation resent"),
@ApiResponse(
responseCode = "400",
description = "Wrong or missing information",
content = @Content(schema = @Schema(implementation = Void.class))),
})
public ResponseEntity<Void> resendEmailConfirmation(
@RequestBody @Valid ResendConfirmationRequest request) {
authService.resendEmailConfirmation(request.email());
return ResponseEntity.noContent().build();
}
/**
* Request a user's password reset.
*
* @param request The request containing user's email.
* @return No content 204 http code.
*/
@PostMapping(path = "/password-reset", consumes = "application/json")
@Operation(
summary = "Request a user's password reset",
description = "Request the user password reset if there's a user",
responses = {
@ApiResponse(responseCode = "204", description = "User password requested"),
@ApiResponse(
responseCode = "400",
description = "Wrong or missing information",
content = @Content(schema = @Schema(implementation = Void.class))),
})
public ResponseEntity<Void> passwordReset(@RequestBody @Valid ResendConfirmationRequest request) {
authService.resetPasswordForUser(request.email());
return ResponseEntity.noContent().build();
}
/**
* Confirm the user password change request.
*
* @param request The request containing the token and the new password.
* @return No content 204 http code.
*/
@PostMapping(path = "/complete-password-reset", consumes = "application/json")
@Operation(
summary = "Confirm the password reset",
description = "Confirm and set the new password for the user",
responses = {
@ApiResponse(responseCode = "204", description = "User password reset completed"),
@ApiResponse(
responseCode = "400",
description = "Wrong or missing information",
content = @Content(schema = @Schema(implementation = Void.class))),
})
public ResponseEntity<Void> completePasswordReset(
@RequestBody @Valid PasswordResetRequest request) {
authService.confirmResetPasswordForUser(request);
return ResponseEntity.noContent().build();
}
}
@@ -10,6 +10,7 @@ import jakarta.persistence.Table;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
@@ -45,6 +46,17 @@ public class UserEntity implements UserDetails {
@OneToMany(mappedBy = "user")
private List<TaskEntity> tasks;
@Column(name = "email_confirmed_at", nullable = true)
private LocalDateTime emailConfirmedAt;
@Column(name = "email_uuid", columnDefinition = "uuid", nullable = true, unique = true)
private UUID emailUuid;
@Column(name = "reset_password_expiration", nullable = true)
private LocalDateTime resetPasswordExpiration;
@Column(name = "reset_token", nullable = true)
private String resetToken;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
@@ -0,0 +1,14 @@
package br.com.tasknoteapp.server.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.server.ResponseStatusException;
/** This exception represents an error when hashing. */
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
public class BadAlgorithmException extends ResponseStatusException {
public BadAlgorithmException(String error) {
super(HttpStatus.SERVICE_UNAVAILABLE, error);
}
}
@@ -0,0 +1,14 @@
package br.com.tasknoteapp.server.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.server.ResponseStatusException;
/** This class represents a bad request when trying to convert to UUID. */
@ResponseStatus(HttpStatus.BAD_REQUEST)
public class BadUuidException extends ResponseStatusException {
public BadUuidException() {
super(HttpStatus.BAD_REQUEST, "Bad user identification");
}
}
@@ -0,0 +1,14 @@
package br.com.tasknoteapp.server.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.server.ResponseStatusException;
/** This exception represents an error when sending email messages. */
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
public class MailServiceException extends ResponseStatusException {
public MailServiceException(String error) {
super(HttpStatus.SERVICE_UNAVAILABLE, error);
}
}
@@ -0,0 +1,14 @@
package br.com.tasknoteapp.server.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.server.ResponseStatusException;
/** This class represents a Reset expired request. */
@ResponseStatus(code = HttpStatus.BAD_REQUEST)
public class ResetExpiredException extends ResponseStatusException {
public ResetExpiredException() {
super(HttpStatus.NOT_FOUND, "Expired reset link.");
}
}
@@ -2,10 +2,15 @@ package br.com.tasknoteapp.server.repository;
import br.com.tasknoteapp.server.entity.UserEntity;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
/** This interface contains methods to access the user table in the database. */
public interface UserRepository extends JpaRepository<UserEntity, Long> {
Optional<UserEntity> findByEmail(String email);
Optional<UserEntity> findByEmailUuid(UUID uuid);
Optional<UserEntity> findByResetToken(String token);
}
@@ -0,0 +1,9 @@
package br.com.tasknoteapp.server.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
/** This record represents a confirmation payload. */
@Schema(description = "Confirmation payload for the user to confirm his email account.")
public record EmailConfirmationRequest(
@Schema(description = "Confirmation token") @NotNull String identification) {}
@@ -25,6 +25,9 @@ public class LoginRequest {
@NotNull
String password;
@Schema(description = "User password again.")
String passwordAgain;
public String email() {
return email.trim().toLowerCase();
}
@@ -32,4 +35,8 @@ public class LoginRequest {
public String password() {
return password;
}
public String passwordAgain() {
return passwordAgain;
}
}
@@ -0,0 +1,11 @@
package br.com.tasknoteapp.server.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
/** This record represents the confirmation of the password reset. */
@Schema(description = "The password request confirmation payload.")
public record PasswordResetRequest(
@Schema(description = "Reset token") @NotNull String token,
@Schema(description = "New password") @NotNull String password,
@Schema(description = "New password again") @NotNull String passwordAgain) {}
@@ -0,0 +1,27 @@
package br.com.tasknoteapp.server.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Setter;
import lombok.ToString;
/** This class represents a login request with user email and password. */
@Schema(description = "Resend confirmation request with user email and password.")
@Setter
@NotNull
@EqualsAndHashCode
@ToString
@AllArgsConstructor
public class ResendConfirmationRequest {
@Schema(description = "User email.")
@Email
@NotNull
String email;
public String email() {
return email.trim().toLowerCase();
}
}
@@ -3,18 +3,23 @@ package br.com.tasknoteapp.server.service;
import br.com.tasknoteapp.server.entity.UserEntity;
import br.com.tasknoteapp.server.entity.UserPwdLimitEntity;
import br.com.tasknoteapp.server.exception.BadPasswordException;
import br.com.tasknoteapp.server.exception.BadUuidException;
import br.com.tasknoteapp.server.exception.EmailAlreadyExistsException;
import br.com.tasknoteapp.server.exception.InvalidCredentialsException;
import br.com.tasknoteapp.server.exception.MaxLoginLimitAttemptException;
import br.com.tasknoteapp.server.exception.ResetExpiredException;
import br.com.tasknoteapp.server.exception.UserForbiddenException;
import br.com.tasknoteapp.server.exception.UserNotFoundException;
import br.com.tasknoteapp.server.repository.UserPwdLimitRepository;
import br.com.tasknoteapp.server.repository.UserRepository;
import br.com.tasknoteapp.server.request.LoginRequest;
import br.com.tasknoteapp.server.request.PasswordResetRequest;
import br.com.tasknoteapp.server.request.UserPatchRequest;
import br.com.tasknoteapp.server.response.UserResponse;
import br.com.tasknoteapp.server.response.UserResponseWithToken;
import br.com.tasknoteapp.server.util.AuthUtil;
import br.com.tasknoteapp.server.util.TokenUtil;
import br.com.tasknoteapp.server.util.UuidUtil;
import jakarta.transaction.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@@ -25,6 +30,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Sort;
@@ -54,12 +60,15 @@ public class AuthService {
private final UserPwdLimitRepository userPwdLimitRepository;
private final MailgunEmailService mailgunEmailService;
/**
* Create a new user in the app.
*
* @param login User details with email and password.
* @return Token
*/
@Transactional
public UserResponseWithToken signUpNewUser(LoginRequest login) {
log.info("Signing up new user! {}", login.email());
@@ -72,17 +81,24 @@ public class AuthService {
throw new BadPasswordException(passwordValidation.get());
}
if (Objects.isNull(login.passwordAgain()) || !login.password().equals(login.passwordAgain())) {
throw new BadPasswordException("The passwords should match");
}
UUID emailUuid = new UuidUtil().generateEmailUuid(login.email());
UserEntity user = new UserEntity();
user.setEmail(login.email());
user.setPassword(passwordEncoder.encode(login.password()));
user.setAdmin(login.email().equals("ricardompcampos@gmail.com"));
user.setCreatedAt(LocalDateTime.now());
user.setEmailUuid(emailUuid);
userRepository.save(user);
String token = jwtService.generateToken(user);
mailgunEmailService.sendNewUser(user);
log.info("User created! ID {}", user.getId());
return UserResponseWithToken.fromEntity(user, token, getGravatarImageUrl(login.email()));
return UserResponseWithToken.fromEntity(user, null, getGravatarImageUrl(login.email()));
}
/**
@@ -120,31 +136,35 @@ public class AuthService {
public UserResponseWithToken signInUser(LoginRequest login) {
log.info("Signing in user! {}", login.email());
Optional<UserEntity> user = findByEmail(login.email());
if (user.isEmpty()) {
Optional<UserEntity> userOptional = findByEmail(login.email());
if (userOptional.isEmpty()) {
throw new InvalidCredentialsException();
}
checkLoginAttemptLimit(user.get().getId());
checkLoginAttemptLimit(userOptional.get().getId());
UserEntity user = userOptional.get();
user.setResetToken(null);
user.setResetPasswordExpiration(null);
try {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(login.email(), login.password()));
String token = jwtService.generateToken(user.get());
String token = jwtService.generateToken(user);
log.info("User authenticated! Token {}", token);
userPwdLimitRepository.deleteAllForUser(user.get().getId());
return UserResponseWithToken.fromEntity(
user.get(), token, getGravatarImageUrl(login.email()));
userPwdLimitRepository.deleteAllForUser(user.getId());
userRepository.save(user);
return UserResponseWithToken.fromEntity(user, token, getGravatarImageUrl(login.email()));
} catch (BadCredentialsException e) {
log.error("BadCredentialsException when logging in user {}", user.get().getId());
log.error("BadCredentialsException when logging in user {}", user.getId());
// store attempt
UserPwdLimitEntity pwdLimit = new UserPwdLimitEntity();
pwdLimit.setWhenHappened(LocalDateTime.now());
pwdLimit.setUser(user.get());
pwdLimit.setUser(user);
userPwdLimitRepository.save(pwdLimit);
return null;
@@ -258,6 +278,10 @@ public class AuthService {
throw new BadPasswordException(passwordValidation.get());
}
if (!patchRequest.password().equals(patchRequest.passwordAgain())) {
throw new BadPasswordException("The passwords should match");
}
currentUser.setPassword(passwordEncoder.encode(patchRequest.password()));
shouldUpdate = true;
}
@@ -284,6 +308,123 @@ public class AuthService {
return findByEmail(email);
}
/**
* Confirm a user account.
*
* @param identification The UUID generated when registering.
* @throws BadUuidException if bad identification
*/
@Transactional
public void confirmUserAccount(String identification) {
log.info("Confirming user email account");
UUID uuid = null;
try {
uuid = UUID.fromString(identification);
} catch (IllegalArgumentException ex) {
throw new BadUuidException();
}
Optional<UserEntity> userOptional = userRepository.findByEmailUuid(uuid);
if (userOptional.isEmpty()) {
throw new UserNotFoundException();
}
UserEntity user = userOptional.get();
user.setEmailConfirmedAt(LocalDateTime.now());
userRepository.save(user);
log.info("User email address confirmed: {}", identification);
}
/**
* Re-send the confirm email to the user.
*
* @param email The email to re-send.
*/
public void resendEmailConfirmation(String email) {
log.info("Re-sending the confirmation email");
Optional<UserEntity> userOptional = userRepository.findByEmail(email);
if (userOptional.isEmpty()) {
throw new UserNotFoundException();
}
UserEntity user = userOptional.get();
mailgunEmailService.sendNewUser(user);
log.info("Confirmation email re-sent!");
}
/**
* Request the password reset for the user.
*
* @param email The user email.
*/
@Transactional
public void resetPasswordForUser(String email) {
log.info("Requesting password reset for email {}", email);
Optional<UserEntity> userOptional = userRepository.findByEmail(email);
if (userOptional.isEmpty()) {
log.info("No user found with this email {}", email);
return;
}
String resetToken = new TokenUtil().generateToken();
UserEntity user = userOptional.get();
user.setResetToken(resetToken);
user.setResetPasswordExpiration(LocalDateTime.now().plusHours(2L));
userRepository.save(user);
mailgunEmailService.sendResetPassword(user);
log.info("Password reset request succeeded");
}
/**
* Confirm the password reset and recreate the password for the user.
*
* @param request The token and new passwords.
*/
@Transactional
public void confirmResetPasswordForUser(PasswordResetRequest request) {
log.info("Saving new password for token {}", request.token());
Optional<UserEntity> userOptional = userRepository.findByResetToken(request.token());
if (userOptional.isEmpty()) {
throw new UserNotFoundException();
}
LocalDateTime requestTime = userOptional.get().getResetPasswordExpiration();
boolean isMoreThan2Hours =
Duration.between(LocalDateTime.now(), requestTime).abs().toHours() > 2;
if (isMoreThan2Hours) {
throw new ResetExpiredException();
}
Optional<String> passwordValidation = authUtil.validatePassword(request.password());
if (passwordValidation.isPresent()) {
throw new BadPasswordException(passwordValidation.get());
}
if (!request.password().equals(request.passwordAgain())) {
throw new BadPasswordException("The passwords should match");
}
UserEntity user = userOptional.get();
user.setResetToken(null);
user.setResetPasswordExpiration(null);
user.setPassword(passwordEncoder.encode(request.password()));
userRepository.save(user);
mailgunEmailService.sendPasswordResetConfirmation(user);
log.info("New password set for token {}", request.token());
}
private Optional<String> getGravatarImageUrl(String email) {
email = email.toLowerCase().trim();
log.info("Current user email: {}", email);
@@ -0,0 +1,161 @@
package br.com.tasknoteapp.server.service;
import br.com.tasknoteapp.server.entity.UserEntity;
import br.com.tasknoteapp.server.exception.MailServiceException;
import br.com.tasknoteapp.server.templates.MailgunTemplate;
import br.com.tasknoteapp.server.templates.MailgunTemplateResetPwd;
import br.com.tasknoteapp.server.templates.MailgunTemplateResetPwdConfirm;
import br.com.tasknoteapp.server.templates.MailgunTemplateSignUp;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Objects;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
/** This service handles email messages for Mailgun. */
@Slf4j
@Service
public class MailgunEmailService {
private final RestTemplate restTemplate;
private final String targetEnv;
private String domain;
private String senderEmail;
/**
* Creates an instance of the Mail service class.
*
* @param apiKey The api key to send emails with.
* @param domain The domain to send email from.
* @param sender The from option.
* @param targetEnv The environment.
* @param templateBuilder The template builder.
*/
public MailgunEmailService(
@Value("${mailgun.api-key}") String apiKey,
@Value("${mailgun.domain}") String domain,
@Value("${mailgun.sender-email}") String sender,
@Value("${br.com.tasknote.server.target-env}") String targetEnv,
RestTemplateBuilder templateBuilder) {
this.domain = domain;
this.senderEmail = sender;
this.targetEnv = targetEnv;
this.restTemplate =
templateBuilder.defaultHeader(HttpHeaders.AUTHORIZATION, basicAuth("api", apiKey)).build();
}
/**
* Send new users a message confirming their account.
*
* @param user The user that should be addressed the message.
*/
public void sendNewUser(UserEntity user) {
log.info("Sending message confirming user email address.");
String to = user.getEmail();
String subject = "TaskNote App confirmation email";
String link = getBaseUrl() + "/email-confirmation?identification=%s";
MailgunTemplateSignUp signUpTemplate = new MailgunTemplateSignUp();
signUpTemplate.setConfirmationLink(String.format(link, user.getEmailUuid().toString()));
sendEmail(to, subject, signUpTemplate);
}
/**
* Send a password reset link.
*
* @param user The user that should be addressed the message.
*/
public void sendResetPassword(UserEntity user) {
log.info("Sending message with password reset link");
String to = user.getEmail();
String subject = "TaskNote App password reset";
String link = getBaseUrl() + "/finish-reset-password?token=%s";
MailgunTemplateResetPwd resetTemplate = new MailgunTemplateResetPwd();
resetTemplate.setResetLink(String.format(link, user.getResetToken()));
sendEmail(to, subject, resetTemplate);
}
/**
* Send a confirmation for the password change.
*
* @param user The user that should be addressed the message.
*/
public void sendPasswordResetConfirmation(UserEntity user) {
log.info("Sending message with password reset confirmation");
String to = user.getEmail();
String subject = "TaskNote App password confirmation";
MailgunTemplateResetPwdConfirm resetTemplate = new MailgunTemplateResetPwdConfirm();
sendEmail(to, subject, resetTemplate);
}
/**
* Send an email message.
*
* @param to The target email address.
* @param subject The message subject.
* @param textBody The message text body to be displayed.
* @param htmlBody The message html body to be rendered.
*/
private void sendEmail(String to, String subject, MailgunTemplate template) {
String url = "https://api.mailgun.net/v3/" + domain + "/messages";
String from = "TaskNote App <" + senderEmail + ">";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> mailData = new LinkedMultiValueMap<>();
mailData.add("from", from);
mailData.add("to", to);
mailData.add("subject", subject);
mailData.add("template", template.getName());
if (!template.getVariables().isEmpty()) {
mailData.add("h:X-Mailgun-Variables", template.getVariableValuesJson());
log.info("JSON template variables: {}", template.getVariableValuesJson());
}
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(mailData, headers);
try {
ResponseEntity<String> response = restTemplate.postForEntity(url, request, String.class);
if (!response.getStatusCode().is2xxSuccessful()) {
throw new MailServiceException("Failed to send email: " + response.getStatusCode());
}
log.info("Email message send successfully.");
} catch (HttpClientErrorException ex) {
log.error("Unable to send email: {} - {}", ex.getMessage(), ex.getCause());
}
}
private String basicAuth(String username, String password) {
String auth = username + ":" + password;
return "Basic " + Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8));
}
private String getBaseUrl() {
if ("development".equals(targetEnv) || Objects.isNull(targetEnv)) {
return "http://localhost:5000";
}
String stage = targetEnv.equals("stage") ? "stage." : "";
return String.format("https://%s%s", stage, domain);
}
}
@@ -0,0 +1,35 @@
package br.com.tasknoteapp.server.templates;
import java.util.Map;
/** This interface represents a mailgun template structure. */
public interface MailgunTemplate {
static final String STRING_SCAPE = "\"";
static final String COLON = ":";
static final String COMMA = ",";
String getName();
Map<String, Object> getVariables();
/**
* Default method to get variables in JSON format.
*
* @return The JSON String representation.
*/
default String getVariableValuesJson() {
StringBuilder sb = new StringBuilder("{");
for (Map.Entry<String, Object> entry : getVariables().entrySet()) {
if (sb.toString().length() > 1) {
sb.append(COMMA);
}
sb.append(STRING_SCAPE).append(entry.getKey()).append(STRING_SCAPE);
sb.append(COLON);
sb.append(STRING_SCAPE).append(entry.getValue().toString()).append(STRING_SCAPE);
}
sb.append("}");
return sb.toString();
}
}
@@ -0,0 +1,29 @@
package br.com.tasknoteapp.server.templates;
import java.util.HashMap;
import java.util.Map;
/** This class represents a template for the password reset workflow. */
public class MailgunTemplateResetPwd implements MailgunTemplate {
private String templateName = "password reset";
private final Map<String, Object> props;
public MailgunTemplateResetPwd() {
this.props = new HashMap<>();
}
public void setResetLink(String resetLink) {
props.put("RESET_LINK", resetLink);
}
@Override
public String getName() {
return templateName;
}
@Override
public Map<String, Object> getVariables() {
return props;
}
}
@@ -0,0 +1,25 @@
package br.com.tasknoteapp.server.templates;
import java.util.HashMap;
import java.util.Map;
/** This class represents a template for the password reset confirmation workflow. */
public class MailgunTemplateResetPwdConfirm implements MailgunTemplate {
private String templateName = "password change confirmation";
private final Map<String, Object> props;
public MailgunTemplateResetPwdConfirm() {
this.props = new HashMap<>();
}
@Override
public String getName() {
return templateName;
}
@Override
public Map<String, Object> getVariables() {
return props;
}
}
@@ -0,0 +1,29 @@
package br.com.tasknoteapp.server.templates;
import java.util.HashMap;
import java.util.Map;
/** This class represents a template for the sign up workflow. */
public class MailgunTemplateSignUp implements MailgunTemplate {
private String templateName = "sign up confirmation";
private final Map<String, Object> props;
public MailgunTemplateSignUp() {
this.props = new HashMap<>();
}
public void setConfirmationLink(String confirmationLink) {
props.put("CONFIRMATION_LINK", confirmationLink);
}
@Override
public String getName() {
return templateName;
}
@Override
public Map<String, Object> getVariables() {
return props;
}
}
@@ -0,0 +1,24 @@
package br.com.tasknoteapp.server.util;
import java.security.SecureRandom;
import java.util.Base64;
/** This class contains a method to create token for the password reset. */
public class TokenUtil {
private static final SecureRandom secureRandom = new SecureRandom();
private static final Base64.Encoder base64Encoder = Base64.getUrlEncoder().withoutPadding();
/**
* Generate a token for the user reset his password.
*
* @return The encoded token with 24 bytes.
*/
public String generateToken() {
// 24 bytes ~ 32 chars length after Base64
// Base64 encoding expands that by a factor of 4/3.
// 24 bytes * 4/3 = 32 characters
byte[] randomBytes = new byte[24];
secureRandom.nextBytes(randomBytes);
return base64Encoder.encodeToString(randomBytes);
}
}
@@ -0,0 +1,72 @@
package br.com.tasknoteapp.server.util;
import br.com.tasknoteapp.server.exception.BadAlgorithmException;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
/** This class provides method to handle UUIDs. */
public class UuidUtil {
private final UUID namespaceUrl = UUID.fromString("6ba7b811-9dad-11d1-80b4-00c04fd430c8");
/**
* Generated a unique UUID to a given email.
*
* @param email The email to create the UUID.
* @return The generated UUID.
*/
public UUID generateEmailUuid(String email) {
return generateUuidFromName(namespaceUrl, email.toLowerCase().trim());
}
private UUID generateUuidFromName(UUID namespace, String name) {
// SHA-1 digest of namespace UUID + name
byte[] namespaceBytes = toBytes(namespace);
byte[] nameBytes = name.getBytes(StandardCharsets.UTF_8);
byte[] combined = new byte[namespaceBytes.length + nameBytes.length];
System.arraycopy(namespaceBytes, 0, combined, 0, namespaceBytes.length);
System.arraycopy(nameBytes, 0, combined, namespaceBytes.length, nameBytes.length);
byte[] sha1 = sha1(combined);
// Manipulate bits to make it UUID v5 (version 5, SHA-1)
sha1[6] &= 0x0f;
sha1[6] |= 0x50;
sha1[8] &= 0x3f;
sha1[8] |= 0x80;
return bytesToUuid(sha1);
}
private byte[] toBytes(UUID uuid) {
long msb = uuid.getMostSignificantBits();
long lsb = uuid.getLeastSignificantBits();
byte[] bytes = new byte[16];
for (int i = 0; i < 8; i++) {
bytes[i] = (byte) ((msb >>> (8 * (7 - i))) & 0xFF);
bytes[8 + i] = (byte) ((lsb >>> (8 * (7 - i))) & 0xFF);
}
return bytes;
}
private byte[] sha1(byte[] input) {
try {
return java.security.MessageDigest.getInstance("SHA-1").digest(input);
} catch (Exception e) {
throw new BadAlgorithmException("SHA-1 algorithm not available");
}
}
private UUID bytesToUuid(byte[] hash) {
long msb = 0;
long lsb = 0;
for (int i = 0; i < 8; i++) {
msb = (msb << 8) | (hash[i] & 0xff);
}
for (int i = 8; i < 16; i++) {
lsb = (lsb << 8) | (hash[i] & 0xff);
}
return new UUID(msb, lsb);
}
}
@@ -3,6 +3,7 @@ br:
tasknote:
server:
jwt-secret: ${SECURITY_KEY:empty}
target-env: ${TARGET_ENV:development}
version: ${BUILD:local}
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost}
@@ -10,6 +11,11 @@ logging:
level:
root: ${ROOT_LOG_LEVEL:INFO}
mailgun:
api-key: ${MAILGUN_APIKEY:abc123456}
domain: tasknoteapp.dev.br
sender-email: no-reply@tasknoteapp.dev.br
management:
endpoint:
health:
@@ -0,0 +1,5 @@
ALTER TABLE tasknote.users
ADD email_confirmed_at TIMESTAMP null,
ADD email_uuid UUID UNIQUE NULL,
ADD reset_password_expiration TIMESTAMP NULL,
ADD reset_token VARCHAR(35) NULL;
@@ -32,7 +32,7 @@ class AuthenticationControllerTest {
@Test
@DisplayName("Sign up happy path should succeed")
void signup_happyPath_shouldSucceed() throws Exception {
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456");
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456", "abcde123456");
final String token = "xaxbxcxdx1x2x3A@";
UserResponseWithToken response =
@@ -44,7 +44,8 @@ class AuthenticationControllerTest {
"""
{
"email": "user@domain.com",
"password": "abcde123456"
"password": "abcde123456",
"passwordAgain": "abcde123456"
}
""";
@@ -55,18 +56,14 @@ class AuthenticationControllerTest {
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.accept(MediaType.APPLICATION_JSON)
.content(jsonString))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.userId").value(response.userId()))
.andExpect(jsonPath("$.email").value(response.email()))
.andExpect(jsonPath("$.admin").value(response.admin()))
.andExpect(jsonPath("$.token").value(token))
.andExpect(status().isNoContent())
.andReturn();
}
@Test
@DisplayName("Sign up bad email request should fail")
void signup_badEmailRequest_shouldFail() throws Exception {
LoginRequest request = new LoginRequest("user@domain..com", "abcde123456");
LoginRequest request = new LoginRequest("user@domain..com", "abcde123456", "abcde123456");
final String token = "xaxbxcxdx1x2x3@A";
UserResponseWithToken response =
@@ -78,7 +75,8 @@ class AuthenticationControllerTest {
"""
{
"email": "user@domain..com",
"password": "abcde123456"
"password": "abcde123456",
"passwordAgain": "abcde123456"
}
""";
@@ -96,7 +94,7 @@ class AuthenticationControllerTest {
@Test
@DisplayName("Sign up email already exists should fail")
void signup_userAlreadyExists_shouldFail() throws Exception {
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456");
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456", "abcde123456");
when(authService.signUpNewUser(request)).thenThrow(new EmailAlreadyExistsException());
@@ -104,7 +102,8 @@ class AuthenticationControllerTest {
"""
{
"email": "user@domain.com",
"password": "abcde123456"
"password": "abcde123456",
"passwordAgain": "abcde123456"
}
""";
@@ -122,7 +121,7 @@ class AuthenticationControllerTest {
@Test
@DisplayName("Sign in happy path should succeed")
void signin_happyPath_shouldSucceed() throws Exception {
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456");
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456", "abcde123456");
final String token = "xaxbxcxdx1x2x3A@";
UserResponseWithToken response =
@@ -134,7 +133,8 @@ class AuthenticationControllerTest {
"""
{
"email": "user@domain.com",
"password": "abcde123456"
"password": "abcde123456",
"passwordAgain": "abcde123456"
}
""";
@@ -156,7 +156,7 @@ class AuthenticationControllerTest {
@Test
@DisplayName("Sign in invalid credentials should fail")
void signIn_invalidCredentials_shouldFail() throws Exception {
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456");
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456", "abcde123456");
when(authService.signInUser(request)).thenReturn(null);
@@ -0,0 +1,38 @@
package br.com.tasknoteapp.server.repository;
import br.com.tasknoteapp.server.entity.UserEntity;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.jdbc.Sql;
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
@Sql(scripts = {"classpath:sql/UserRepositoryTest.sql"})
class UserRepositoryIntTest {
@Autowired UserRepository userRepository;
private static final String UUID_CODE = "cc2b5506-83ed-5764-985e-611ad4ce8050";
@Test
void findByEmailUuid_happyPath_shouldSucceed() {
UUID uuid = UUID.fromString(UUID_CODE);
Optional<UserEntity> user = userRepository.findByEmailUuid(uuid);
Assertions.assertFalse(user.isEmpty());
}
@Test
void findByResetToken_happyPath_shouldSucceed() {
String token = "abc123456";
Optional<UserEntity> user = userRepository.findByResetToken(token);
Assertions.assertFalse(user.isEmpty());
}
}
@@ -9,14 +9,17 @@ import static org.mockito.Mockito.when;
import br.com.tasknoteapp.server.entity.UserEntity;
import br.com.tasknoteapp.server.entity.UserPwdLimitEntity;
import br.com.tasknoteapp.server.exception.BadPasswordException;
import br.com.tasknoteapp.server.exception.BadUuidException;
import br.com.tasknoteapp.server.exception.EmailAlreadyExistsException;
import br.com.tasknoteapp.server.exception.InvalidCredentialsException;
import br.com.tasknoteapp.server.exception.MaxLoginLimitAttemptException;
import br.com.tasknoteapp.server.exception.ResetExpiredException;
import br.com.tasknoteapp.server.exception.UserForbiddenException;
import br.com.tasknoteapp.server.exception.UserNotFoundException;
import br.com.tasknoteapp.server.repository.UserPwdLimitRepository;
import br.com.tasknoteapp.server.repository.UserRepository;
import br.com.tasknoteapp.server.request.LoginRequest;
import br.com.tasknoteapp.server.request.PasswordResetRequest;
import br.com.tasknoteapp.server.request.UserPatchRequest;
import br.com.tasknoteapp.server.response.UserResponse;
import br.com.tasknoteapp.server.response.UserResponseWithToken;
@@ -24,6 +27,7 @@ import br.com.tasknoteapp.server.util.AuthUtil;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
@@ -53,6 +57,8 @@ class AuthServiceTest {
@Mock private UserPwdLimitRepository userPwdLimitRepository;
@Mock private MailgunEmailService mailgunEmailService;
private AuthService authService;
@BeforeEach
@@ -64,13 +70,14 @@ class AuthServiceTest {
jwtService,
authenticationManager,
authUtil,
userPwdLimitRepository);
userPwdLimitRepository,
mailgunEmailService);
}
@Test
@DisplayName("SignUp new user happy path should succeed")
void signUpNewUser_happyPath_shouldSucceed() {
LoginRequest request = new LoginRequest("email@domain.com", "123456@abcde!");
LoginRequest request = new LoginRequest("email@domain.com", "123456@abcde!", "123456@abcde!");
when(userRepository.findByEmail(request.email())).thenReturn(Optional.empty());
when(authUtil.validatePassword(request.password())).thenReturn(Optional.empty());
@@ -79,22 +86,24 @@ class AuthServiceTest {
entity.setId(3L);
entity.setEmail(request.email());
entity.setName("User");
entity.setEmailUuid(UUID.randomUUID());
when(userRepository.save(any())).thenReturn(entity);
when(jwtService.generateToken(any())).thenReturn("a1b2c3");
doNothing().when(mailgunEmailService).sendNewUser(any());
UserResponseWithToken token = authService.signUpNewUser(request);
Assertions.assertNotNull(token);
Assertions.assertFalse(token.token().isBlank());
Assertions.assertEquals("a1b2c3", token.token());
Assertions.assertNull(token.token());
Assertions.assertEquals(entity.getEmail(), token.email());
Assertions.assertNotNull(entity.getEmailUuid());
}
@Test
@DisplayName("SignUp new user with existing email should fail")
void signUpNewUser_emailExists_shouldFail() {
LoginRequest request = new LoginRequest("email@domain.com", "123456@abcde!");
LoginRequest request = new LoginRequest("email@domain.com", "123456@abcde!", "123456@abcde!");
UserEntity existing = new UserEntity();
when(userRepository.findByEmail(request.email())).thenReturn(Optional.of(existing));
@@ -109,7 +118,7 @@ class AuthServiceTest {
@Test
@DisplayName("SignUp new user with bad password should fail")
void signUpNewUser_badPassword_shouldFail() {
LoginRequest request = new LoginRequest("email@domain.com", "123456");
LoginRequest request = new LoginRequest("email@domain.com", "123456", "123456");
when(userRepository.findByEmail(request.email())).thenReturn(Optional.empty());
when(authUtil.validatePassword(request.password())).thenReturn(Optional.of("Bad password"));
@@ -181,7 +190,7 @@ class AuthServiceTest {
@Test
@DisplayName("SignIn user happy path should succeed")
void signInUser_happyPath_shouldSucceed() {
LoginRequest request = new LoginRequest("email@domain.com", "123456");
LoginRequest request = new LoginRequest("email@domain.com", "123456", "123456");
UserEntity existing = new UserEntity();
existing.setId(919L);
@@ -204,7 +213,7 @@ class AuthServiceTest {
@Test
@DisplayName("SignIn wrong user or password should fail")
void signInUser_wrongUserOrPassword_shouldFail() {
LoginRequest request = new LoginRequest("email@domain.com", "123456");
LoginRequest request = new LoginRequest("email@domain.com", "123456", "123456");
when(userRepository.findByEmail(request.email())).thenReturn(Optional.empty());
@@ -218,7 +227,7 @@ class AuthServiceTest {
@Test
@DisplayName("SignIn max login attempt should fail")
void signInUser_maxLoginAttempt_shouldFail() {
LoginRequest request = new LoginRequest("email@domain.com", "123456");
LoginRequest request = new LoginRequest("email@domain.com", "123456", "123456");
UserEntity existing = new UserEntity();
existing.setId(919L);
@@ -242,7 +251,7 @@ class AuthServiceTest {
@Test
@DisplayName("SignIn bad credentials should fail")
void signInUser_badCredentials_shouldFail() {
LoginRequest request = new LoginRequest("email@domain.com", "123456");
LoginRequest request = new LoginRequest("email@domain.com", "123456", "123456");
UserEntity existing = new UserEntity();
existing.setId(919L);
@@ -444,4 +453,213 @@ class AuthServiceTest {
Assertions.assertThrows(UserNotFoundException.class, () -> authService.getCurrentUser());
}
@Test
@DisplayName("Confirm user account happy path should succeed")
void confirmUserAccount_happyPath_shouldSucceed() {
String uuid = UUID.randomUUID().toString();
UserEntity user = new UserEntity();
user.setEmailUuid(UUID.fromString(uuid));
when(userRepository.findByEmailUuid(UUID.fromString(uuid))).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenReturn(user);
Assertions.assertDoesNotThrow(() -> authService.confirmUserAccount(uuid));
verify(userRepository, times(1)).save(user);
Assertions.assertNotNull(user.getEmailConfirmedAt());
}
@Test
@DisplayName("Confirm user account with invalid UUID should fail")
void confirmUserAccount_invalidUuid_shouldFail() {
String invalidUuid = "invalid-uuid";
Assertions.assertThrows(
BadUuidException.class,
() -> {
authService.confirmUserAccount(invalidUuid);
});
verify(userRepository, times(0)).save(any());
}
@Test
@DisplayName("Confirm user account with non-existent UUID should fail")
void confirmUserAccount_nonExistentUuid_shouldFail() {
String uuid = UUID.randomUUID().toString();
when(userRepository.findByEmailUuid(UUID.fromString(uuid))).thenReturn(Optional.empty());
Assertions.assertThrows(
UserNotFoundException.class,
() -> {
authService.confirmUserAccount(uuid);
});
verify(userRepository, times(0)).save(any());
}
@Test
@DisplayName("Resend email confirmation happy path should succeed")
void resendEmailConfirmation_happyPath_shouldSucceed() {
String email = "user@domain.com";
UserEntity existing = new UserEntity();
existing.setId(919L);
existing.setEmail(email);
when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing));
doNothing().when(mailgunEmailService).sendNewUser(existing);
Assertions.assertDoesNotThrow(() -> authService.resendEmailConfirmation(email));
verify(mailgunEmailService, times(1)).sendNewUser(existing);
}
@Test
@DisplayName("Resend email confirmation with non-existent email should fail")
void resendEmailConfirmation_nonExistentEmail_shouldFail() {
String email = "nonexistent@domain.com";
when(userRepository.findByEmail(email)).thenReturn(Optional.empty());
Assertions.assertThrows(
UserNotFoundException.class, () -> authService.resendEmailConfirmation(email));
verify(mailgunEmailService, times(0)).sendNewUser(any());
}
@Test
@DisplayName("Reset password for user happy path should succeed")
void resetPasswordForUser_happyPath_shouldSucceed() {
String email = "user@domain.com";
UserEntity existing = new UserEntity();
existing.setId(919L);
existing.setEmail(email);
when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing));
doNothing().when(mailgunEmailService).sendResetPassword(any());
when(userRepository.save(any())).thenReturn(existing);
Assertions.assertDoesNotThrow(() -> authService.resetPasswordForUser(email));
verify(userRepository, times(1)).save(existing);
verify(mailgunEmailService, times(1)).sendResetPassword(existing);
}
@Test
@DisplayName("Reset password for user with non-existent email should succeed without exception")
void resetPasswordForUser_nonExistentEmail_shouldSucceed() {
String email = "nonexistent@domain.com";
when(userRepository.findByEmail(email)).thenReturn(Optional.empty());
Assertions.assertDoesNotThrow(() -> authService.resetPasswordForUser(email));
verify(userRepository, times(0)).save(any());
verify(mailgunEmailService, times(0)).sendResetPassword(any());
}
@Test
@DisplayName("Confirm reset password happy path should succeed")
void confirmResetPasswordForUser_happyPath_shouldSucceed() {
String token = "validToken";
UserEntity user = new UserEntity();
user.setResetToken(token);
user.setResetPasswordExpiration(LocalDateTime.now().plusMinutes(30));
String newPassword = "NewPassword@123";
String encodedPassword = "hash@abcxasd123!";
when(passwordEncoder.encode(newPassword)).thenReturn(encodedPassword);
user.setPassword(encodedPassword);
when(userRepository.findByResetToken(token)).thenReturn(Optional.of(user));
when(authUtil.validatePassword(newPassword)).thenReturn(Optional.empty());
PasswordResetRequest request = new PasswordResetRequest(token, newPassword, newPassword);
Assertions.assertDoesNotThrow(() -> authService.confirmResetPasswordForUser(request));
verify(userRepository, times(1)).save(user);
verify(mailgunEmailService, times(1)).sendPasswordResetConfirmation(user);
Assertions.assertNull(user.getResetToken());
Assertions.assertNull(user.getResetPasswordExpiration());
Assertions.assertNotNull(user.getPassword());
}
@Test
@DisplayName("Confirm reset password with expired token should fail")
void confirmResetPasswordForUser_expiredToken_shouldFail() {
String token = "expiredToken";
String newPassword = "NewPassword@123";
UserEntity user = new UserEntity();
user.setResetToken(token);
user.setResetPasswordExpiration(LocalDateTime.now().minusHours(3));
PasswordResetRequest request = new PasswordResetRequest(token, newPassword, newPassword);
when(userRepository.findByResetToken(token)).thenReturn(Optional.of(user));
Assertions.assertThrows(
ResetExpiredException.class, () -> authService.confirmResetPasswordForUser(request));
verify(userRepository, times(0)).save(any());
verify(mailgunEmailService, times(0)).sendPasswordResetConfirmation(any());
}
@Test
@DisplayName("Confirm reset password with invalid token should fail")
void confirmResetPasswordForUser_invalidToken_shouldFail() {
String token = "invalidToken";
String newPassword = "NewPassword@123";
PasswordResetRequest request = new PasswordResetRequest(token, newPassword, newPassword);
when(userRepository.findByResetToken(token)).thenReturn(Optional.empty());
Assertions.assertThrows(
UserNotFoundException.class, () -> authService.confirmResetPasswordForUser(request));
verify(userRepository, times(0)).save(any());
verify(mailgunEmailService, times(0)).sendPasswordResetConfirmation(any());
}
@Test
@DisplayName("Confirm reset password with mismatched passwords should fail")
void confirmResetPasswordForUser_mismatchedPasswords_shouldFail() {
String token = "validToken";
String newPassword = "NewPassword@123";
String mismatchedPassword = "Mismatch@123";
UserEntity user = new UserEntity();
user.setResetToken(token);
user.setResetPasswordExpiration(LocalDateTime.now().plusMinutes(30));
PasswordResetRequest request = new PasswordResetRequest(token, newPassword, mismatchedPassword);
when(userRepository.findByResetToken(token)).thenReturn(Optional.of(user));
Assertions.assertThrows(
BadPasswordException.class, () -> authService.confirmResetPasswordForUser(request));
verify(userRepository, times(0)).save(any());
verify(mailgunEmailService, times(0)).sendPasswordResetConfirmation(any());
}
@Test
@DisplayName("Confirm reset password with invalid password should fail")
void confirmResetPasswordForUser_invalidPassword_shouldFail() {
String token = "validToken";
String newPassword = "weak";
UserEntity user = new UserEntity();
user.setResetToken(token);
user.setResetPasswordExpiration(LocalDateTime.now().plusMinutes(30));
PasswordResetRequest request = new PasswordResetRequest(token, newPassword, newPassword);
when(userRepository.findByResetToken(token)).thenReturn(Optional.of(user));
when(authUtil.validatePassword(newPassword)).thenReturn(Optional.of("Weak password"));
Assertions.assertThrows(
BadPasswordException.class, () -> authService.confirmResetPasswordForUser(request));
verify(userRepository, times(0)).save(any());
verify(mailgunEmailService, times(0)).sendPasswordResetConfirmation(any());
}
}
@@ -0,0 +1,99 @@
package br.com.tasknoteapp.server.service;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import br.com.tasknoteapp.server.entity.UserEntity;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
/** Test class generated by Copilot. I had to tweak it, to make it work. */
@ExtendWith(MockitoExtension.class)
class MailgunEmailServiceTest {
@Mock private RestTemplate restTemplate;
@Mock private RestTemplateBuilder restTemplateBuilder;
private MailgunEmailService mailgunEmailService;
@BeforeEach
void setUp() {
String apiKey = "abx123";
String domain = "domain.com";
String sender = "no-reply@domain.com";
String target = "development";
when(restTemplateBuilder.defaultHeader(any(), any())).thenReturn(restTemplateBuilder);
when(restTemplateBuilder.build()).thenReturn(restTemplate);
mailgunEmailService =
new MailgunEmailService(apiKey, domain, sender, target, restTemplateBuilder);
}
@Test
void testSendResetPassword() {
UserEntity user = new UserEntity();
user.setEmail("test@example.com");
user.setResetToken("reset-token");
when(restTemplate.postForEntity(anyString(), any(), eq(String.class)))
.thenReturn(ResponseEntity.ok("Success"));
mailgunEmailService.sendResetPassword(user);
verify(restTemplate, times(1)).postForEntity(anyString(), any(), eq(String.class));
}
@Test
void testSendPasswordResetConfirmation() {
UserEntity user = new UserEntity();
user.setEmail("test@example.com");
when(restTemplate.postForEntity(anyString(), any(), eq(String.class)))
.thenReturn(ResponseEntity.ok("Success"));
mailgunEmailService.sendPasswordResetConfirmation(user);
verify(restTemplate, times(1)).postForEntity(anyString(), any(), eq(String.class));
}
@Test
void testSendNewUser() {
UserEntity user = new UserEntity();
user.setEmail("test@example.com");
user.setEmailUuid(java.util.UUID.randomUUID());
when(restTemplate.postForEntity(anyString(), any(), eq(String.class)))
.thenReturn(ResponseEntity.ok("Success"));
mailgunEmailService.sendNewUser(user);
verify(restTemplate, times(1)).postForEntity(anyString(), any(), eq(String.class));
}
@Test
void testSendEmailHandlesHttpClientErrorException() {
UserEntity user = new UserEntity();
user.setEmail("test@example.com");
user.setResetToken("reset-token");
when(restTemplate.postForEntity(anyString(), any(), eq(String.class)))
.thenThrow(new HttpClientErrorException(HttpStatusCode.valueOf(400)));
mailgunEmailService.sendResetPassword(user);
verify(restTemplate, times(1)).postForEntity(anyString(), any(), eq(String.class));
}
}
@@ -0,0 +1,40 @@
package br.com.tasknoteapp.server.templates;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
class MailgunTemplateTest {
@Test
void mailgunTemplateResetPwdTest() {
MailgunTemplateResetPwd reset = new MailgunTemplateResetPwd();
Assertions.assertNotNull(reset.getName());
Assertions.assertNotNull(reset.getVariables());
Assertions.assertNotNull(reset.getVariableValuesJson());
Assertions.assertFalse(reset.getVariableValuesJson().isBlank());
}
@Test
void mailgunTemplateResetPwdConfirmTest() {
// MailgunTemplateResetPwdConfirm
MailgunTemplateResetPwdConfirm confirm = new MailgunTemplateResetPwdConfirm();
Assertions.assertNotNull(confirm.getName());
Assertions.assertNotNull(confirm.getVariables());
Assertions.assertNotNull(confirm.getVariableValuesJson());
Assertions.assertFalse(confirm.getVariableValuesJson().isBlank());
}
@Test
void mailgunTemplateSignUpTest() {
// MailgunTemplateSignUp
MailgunTemplateSignUp signUp = new MailgunTemplateSignUp();
Assertions.assertNotNull(signUp.getName());
Assertions.assertNotNull(signUp.getVariables());
Assertions.assertNotNull(signUp.getVariableValuesJson());
Assertions.assertFalse(signUp.getVariableValuesJson().isBlank());
}
}
@@ -0,0 +1,15 @@
package br.com.tasknoteapp.server.util;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
class TokenUtilTest {
@Test
void generateTokenTest() {
String token = new TokenUtil().generateToken();
Assertions.assertNotNull(token);
Assertions.assertTrue(token.length() >= 32);
}
}
@@ -0,0 +1,18 @@
package br.com.tasknoteapp.server.util;
import java.util.UUID;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
class UuidUtilTest {
@Test
void generateEmailUuidTest() {
UuidUtil uuidUtil = new UuidUtil();
String email = "example@test.com";
UUID uuid = uuidUtil.generateEmailUuid(email);
Assertions.assertNotNull(uuid);
Assertions.assertEquals(uuid, uuidUtil.generateEmailUuid(email));
}
}
@@ -3,9 +3,16 @@ br:
tasknote:
server:
jwt-secret: ${SECURITY_KEY:test-secret-key-not-for-production}
target-env: ${TARGET_ENV:development}
version: ${BUILD:local}
cors:
allowed-origins: "${CORS_ALLOWED_ORIGINS:http://localhost}\t"
mailgun:
api-key: ${MAILGUN_APIKEY:abc123456}
domain: tasknoteapp.dev.br
sender-email: no-reply@yourdomain.com
management:
endpoint:
health:
@@ -0,0 +1,4 @@
-- Create test user
insert into users (email, password, admin, created_at, inactivated_at, email_uuid, reset_token)
select 'testuuid@domain.com', 'a1b2c3d4f5g6', false, current_timestamp, null, 'cc2b5506-83ed-5764-985e-611ad4ce8050', 'abc123456'
where not exists (select 1 from users where email = 'testuuid@domain.com');