Building a robust registration form using React Vite for the frontend and for our backend Node.js, Express.js and mongoDB as the database. We will also implement several security measures to protect user data and prevent common vulnerabilities.
System interaction Flow of what we will be building here
System interaction Flow of what we will be building hereSetting up our mongoDB databaseRetrieving the MongoDB URISetting up the Backend ServerCreate a new node.js project and install dependenciesDefine the userModelSet up the express.js serverCreate the user registration routeCreate the user login routeValidate the user's JWTSetting up the FrontendCreate a new Vite ProjectPages and RoutingProtectedRoute componentLogin and Registration FormsRegistration FormLogin FormBenefits of using react-hook-form and yup:Wrap up what we have done till nowUnit Testing with Vitest and React Testing LibrarySetting up VitestTesting the LoginForm componentTesting the RegistrationForm componentEnd-To-End testing with CypressInstalling and Setting Up CypressWriting Tests with CypressScript to CLI run cypress tests on every available browserConclusionFull code repositoryOther project examples
Setting up our mongoDB database
Before we dive into the server setup, we need to set up a MongoDB database. For this project, we'll be using MongoDB Atlas, which is a cloud-based service provided by MongoDB. It allows us to create and manage our database without worrying about the underlying infrastructure.
Retrieving the MongoDB URI
The MongoDB URI is a string that provides all the information needed to connect to your MongoDB database. It includes details like the username, password, cluster address, and database name.
Here's an example of what the MongoDB URI might look like:
mongodb+srv://your_username:your_password@cluster0.abcde.mongodb.net/your_database_name?retryWrites=true&w=majority
By creating a new cluster on mongoDB website, after registration or login, by selecting a free tier that uses shared cluster, and after having a database created we can click on
connect
and we will have our MONGODB_URI
.Storing the MongoDB URI
For security reasons, it's not recommended to hard-code sensitive information like the MongoDB URI directly in your code. Instead, we'll store it in an environment variable using a
.env
file.- Create a new file called
.env
in the root directory of your project.
- Inside the
.env
file, add the following line:
MONGODB_URI=your_mongodb_uri
Later, in our server code, we'll use a library
dotenv
to load the environment variables from the .env
file and access the MongoDB URI securely.By following these steps, you'll have a MongoDB Atlas database set up and ready to be used in your application. The MongoDB URI stored in the
.env
file will be used to connect to the database from your server code.Setting up the Backend Server
Let's start by setting up the backend server using Node.js and Express.js. We'll also use Mongoose as an Object Document Mapping (ODM) library to interact with the MongoDB database.
Create a new node.js project and install dependencies
mkdir secure-registration-app cd secure-registration-app npm init -y npm install express mongoose bcrypt jsonwebtoken express-mongo-sanitize express-rate-limit yup helmet
Define the userModel
Now we need to think about what data we will store in the database and how we want it to be structured. This way we are ensuring that the user data stored in the database adheres our needs.
// /models/user.model.js const mongoose = require('mongoose'); const userSchema = new mongoose.Schema({ firstName: { type: String, required: true, trim: true }, lastName: { type: String, required: true, trim: true }, age: { type: Number, required: true, max: 120 }, gender: { type: String, required: true, lowercase: true, trim: true, enum: ['male', 'female', 'other'] }, username: { type: String, required: true, unique: true, lowercase: true, trim: true }, email: { type: String, required: true, unique: true, lowercase: true, trim: true }, password: { type: String, required: true }, }); const User = mongoose.model('User', userSchema); module.exports = User;
This code defines the User model with fields for firstName, lastName, age, gender, email, username, and password. We set the required option to ensure that these fields are not empty, and mark the email and username fields as unique and case-insensitive to prevent duplicates.
Set up the express.js server
Let’s set up the Express.js server, connect to the MongoDB database using the MONGODB_URI environment variable, and configure
middleware
, sanitizing user input with express-mongo-sanitize
, rate limiting with express-rate-limit
, and securing the app with helmet
.We will need a strong string for our
JWT_SECRET
for the .env file, we can use node and OS console to generate for us random secure strings.
node -e "console.log(require('crypto').randomBytes(64).toString('base64'))”
This uses Node crypto
module to generate 64 bytes of random data and then encodes that data as a base64 string. By changing the randomBytes you change the size of it.const express = require('express'); const mongoose = require('mongoose'); const cors = require('cors'); const mongoSanitize = require('express-mongo-sanitize'); const rateLimit = require('express-rate-limit'); const helmet = require('helmet'); const jwt = require('jsonwebtoken'); const bcrypt = require('bcrypt'); const yup = require('yup'); const User = require('./models/user.model'); const authMiddleware = require('./authMiddleware'); require('dotenv').config(); const app = express(); const port = process.env.PORT || 5001; // Middleware app.use(cors({ origin: 'http://localhost:5173' })); app.use(express.json()); app.use( rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // limit each IP to 100 requests per windowMs }) ); app.use( mongoSanitize({ onSanitize: ({ req, key }) => { // It is a good idea to log brute forces and ban their ip console.log(`This request[${key}] is sanitized`, req); }, }), ); app.use(helmet()); // Connect to MongoDB mongoose.connect(process.env.MONGODB_URI); const db = mongoose.connection; db.on('error', console.error.bind(console, 'MongoDB connection error:')); db.once('open', function () { console.log("Successfully connected to MongoDB!"); }); // Start the server app.listen(port, () => { console.log(`Server is running on port ${port}`); });
Create the user registration route
const bcrypt = require('bcrypt'); const yup = require('yup'); const jwt = require('jsonwebtoken'); const User = require('./models/user.model'); const registrationSchema = yup .object() .shape({ firstName: yup.string().trim().required(), lastName: yup.string().trim().required(), age: yup .number() .transform((value, originalValue) => String(originalValue).trim() === '' ? undefined : value ) .required().positive().integer().max(120), gender: yup .string() .oneOf(['male', 'female', 'other']) .required(), email: yup .string().trim().email().required() // no empty spaces regex .matches(/^[\S]+$/) .transform(value => value.toLowerCase()), username: yup .string().trim().required().min(4) // no empty spaces regex .matches(/^[\S]+$/) .transform(value => value.toLowerCase()), password: yup.string().required().min(8) //Password must be at least 8 characters long, contain at least one uppercase letter, one lowercase letter, one number, one special character, and cannot contain spaces .matches( /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&_.+-])[^\s]+$/, ), }).required(); app.post('/register', async (req, res) => { try { // Validate the request body const validatedData = await registrationSchema.validate(req.body, { abortEarly: false, }); // Check if the email or username already exists const existingUser = await User.findOne({ $or: [{ email: validatedData.email }, { username: validatedData.username }], }); if (existingUser) { return res.status(400).json({ error: 'Email or username already exists' }); } // Hash the password const salt = await bcrypt.genSalt(10); const hashedPassword = await bcrypt.hash(validatedData.password, salt); // Create a new user const newUser = new User({ ...validatedData, password: hashedPassword, }); // Save the new user to the database await newUser.save(); const token = jwt.sign( { userId: newUser._id }, process.env.JWT_SECRET, { expiresIn: '1h' } ); res.status(201).json({ message: 'User registered successfully', token }); } catch (error) { console.error(error); return res.status(500).json({ error: 'Server error' }); } });
Here's what's happening:
- We validate the user's input data using the
registrationSchema
we defined.
- We check if the email or username already exists in our database. If it does, we return an error.
- If the email and username are unique, we hash the user's password using
bcrypt
. The salt is a random string of characters that is combined with the user's password to produce the final hash and the argument passed is the cost factor, which determines the computational complexity. This will make passwords that are equal in value will not be equal in visual representation at the db.
- We create a new
User
instance with the validated data and replace the text-based password for the created hashed password.
- We save the new user to our MongoDB database.
- We generate a JSON Web Token (JWT) for the user using the
jsonwebtoken
library. This token will be used for authentication later.
- We send a success response with the JWT.
Create the user login route
const loginSchema = yup.object().shape({ username: yup.string().trim().min(4).required().matches(/^[\S]+$/).transform(value => value.toLowerCase()), password: yup.string().required().matches( /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&_.+-])[^\s]+$/, ), }); app.post('/login', async (req, res) => { try { const validatedData = await loginSchema.validate(req.body, { abortEarly: false, }); const { username, password } = validatedData; // Find the user by username const user = await User.findOne({ username }); if (!user) { return res.status(400).json({ error: 'Invalid username or password' }); } // Compare the provided password with the hashed password const isPasswordValid = await bcrypt.compare(password, user.password); if (!isPasswordValid) { return res.status(400).json({ error: 'Invalid username or password' }); } // Generate a JSON Web Token const token = jwt.sign( { userId: user._id }, process.env.JWT_SECRET, { expiresIn: '1h', } ); res.json({ token }); } catch (error) { res.status(500).json({ error: 'Server error' }); } });
Here's what's happening:
- We validate the user's input data using the
loginSchema
we defined.
- We query the user in our database by their username with mongoose.
- If the user doesn't exist, we return an error.
- We compare the provided password with the hashed password stored in the database using
bcrypt.compare
.
- If the passwords don't match, we return an error.
- If the username and password are valid, we generate a new JWT for the user.
- We send the JWT in the response.
Validate the user's JWT
// authMiddleware.js const jwt = require('jsonwebtoken'); const authMiddleware = () => { return (req, res, next) => { const authHeader = req.headers.authorization; if (!authHeader) { return res.status(401).json({ error: 'No token provided' }); } const parts = authHeader.split(' '); if (parts.length !== 2 || parts[0] !== 'Bearer') { return res.status(401).json({ error: 'Token error' }); } const token = parts[1]; try { req.user = jwt.verify(token, process.env.JWT_SECRET); next(); } catch (error) { if (error instanceof jwt.TokenExpiredError) { return res.status(401).json({ error: 'Token expired' }); } else if (error instanceof jwt.JsonWebTokenError) { return res.status(403).json({ error: 'Invalid token' }); } else { next(); } } }; }; module.exports = authMiddleware; // validate-token route app.get('/validate-token', authMiddleware(), (req, res) => { // If the token is valid, authMiddleware will allow reaching this point res.json({ valid: true, user: req.user }); });
This concludes the server-side implementation of our registration and login system. We used
Node.js
and Express
to build our backend API, and connected it to a MongoDB database using Mongoose
.We implemented
JSON Web Tokens
(JWTs) for secure user authentication and authorization. JWTs are a popular and industry-standard way of transmitting information between parties securely. They allow us to verify that the user is authenticated and authorised to access certain routes or resources.We also used the
yup
library for schema validation, ensuring that user input data conforms to our defined rules and constraints. This helps us prevent invalid or malicious data from being stored in our database.To securely store user passwords, we used the
bcrypt
library for hashing passwords before saving them to the database. Bcrypt is a recommended and secure way of storing passwords, as it uses a salt and a key derivation function to make the hashing process more computationally expensive and resistant to brute-force attacks.For added security and performance, we implemented several middleware functions:
cors
: Allowed us to control which origins (e.g., our React app) can make requests to our backend.
express-mongo-sanitize
: Prevented MongoDB operator injection by sanitizing user input data.
express-rate-limit
: Limited the number of requests from a single IP address to prevent DDoS attacks.
helmet
: Set various security headers to enhance the security of our Express app.
We are ready to go into our frontend and build the forms that will interact with the server and the server is ready to interact with the mongoDB database. We will be able to try and exploit the server and understand better how a real server behaves against requests. We handled vulnerabilities, errors that would cause a crash and interrupt our development, with that said, let’s build some forms.
Setting up the Frontend
We'll cover the design and development of the user interface components, form validation, handling user input, and integrating with the backend API we built earlier.
Create a new Vite Project
npm create vite@latest my-secure-app-frontend -- --template react
Pages and Routing
Let’s start by building out our pages and secure routes we want to force the user to be logged in to see any info from the page. Let’s use react-router-dom setup the router for our pages and setup a component ProtectedRoute.jsx to ensure access is only given to authorised users.
// App.jsx import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; import ProtectedRoute from './components/ProtectedRoute.jsx'; import LoginPage from './pages/LoginPage.jsx'; import Dashboard from './pages/Dashboard.jsx'; import RegisterPage from './pages/RegisterPage.jsx'; const App = () => { return ( <Router> <Routes> <Route path="/" element={<LoginPage />} /> <Route path="/register" element={<RegisterPage />} /> <Route path="/dashboard" element={ <ProtectedRoute> <Dashboard /> </ProtectedRoute> } /> </Routes> </Router> ); }; export default App;
// LoginPage.tsx import { Link } from 'react-router-dom'; import LoginForm from '../components/LoginForm.jsx'; import useAuthNavigation from '../lib/useAuthNavigation.js'; import useTokenValidation from '../lib/useTokenValidation.js'; const LoginPage = () => { const { isLoading, isValidToken } = useTokenValidation(); useAuthNavigation(isValidToken, '/dashboard'); if (isLoading) return <></>; return ( <section> <h1>Welcome</h1> <LoginForm /> <p> Don't have an account? <Link to="/register">Register here</Link> </p> </section> ); }; export default LoginPage;
// RegisterPage.jsx import { Link } from 'react-router-dom'; import RegistrationForm from '../components/RegistrationForm.jsx'; import useAuthNavigation from '../lib/useAuthNavigation.js'; import useTokenValidation from '../lib/useTokenValidation.js'; const RegisterPage = () => { const { isLoading, isValidToken } = useTokenValidation(); useAuthNavigation(isValidToken, '/dashboard'); if (isLoading) return <></>; return ( <section> <h1>Welcome</h1> <RegistrationForm /> <p> Already have an account? <Link to="/">Login here</Link> </p> </section> ); }; export default RegisterPage;
// Dashboard.jsx const Dashboard = () => { return ( <section> <h1>Hello</h1> </section> ); }; export default Dashboard;
ProtectedRoute component
Let's now think about the
ProtectedRoute
component. We want to ensure that no data is accessible to the client without validating the token first. Only after successful token validation will the client be granted access. For this, we need to handle the loading state and prevent access to the page information while loading or validating the token.In the login and register pages, we want to check if the user is already logged in (i.e., has a valid token). If they do, we need to redirect them to the protected routes.
To handle token validation and navigation, we will create two custom hooks:
useTokenValidation
: This hook will handle token validation and update the isLoading and isValidToken states accordingly.
useAuthNavigation
: This hook will navigate to a specific route (e.g., /dashboard) if the token is valid. It will be used in the LoginPage and RegisterPage components to redirect the user to the protected routes if they already have a valid token.
// useTokenValidation.js import { useState, useEffect } from 'react'; import axios from 'axios'; const useTokenValidation = () => { const [isLoading, setIsLoading] = useState(true); const [isValidToken, setIsValidToken] = useState(false); useEffect(() => { const validateToken = async () => { const token = localStorage.getItem('authToken'); if (!token) { setIsLoading(false); setIsValidToken(false); return; } axios .get('http://localhost:5001/validate-token', { headers: { Authorization: `Bearer ${token}` }, }) .then(() => { setIsValidToken(true); }) .catch((error) => { console.error('Validation error:', error); setIsValidToken(false); localStorage.removeItem('authToken'); // Remove the invalid token }) .finally(() => { setIsLoading(false); }); }; validateToken(); }, []); return { isLoading, isValidToken }; }; export default useTokenValidation;
import { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; const useAuthNavigation = (isValidToken, navigateTo = '/dashboard') => { const navigate = useNavigate(); useEffect(() => { if (isValidToken) { navigate(navigateTo); } }, [isValidToken, navigate, navigateTo]); }; export default useAuthNavigation;
// ProtectedRoute.jsx import { Navigate } from 'react-router-dom'; import useTokenValidation from '../lib/useTokenValidation.js'; const ProtectedRoute = ({ children }) => { const { isLoading, isValidToken } = useTokenValidation(); if (isLoading) { return <></>; } if (!isValidToken) { // the replace prop ensures that the redirect doesn't create a new entry in the history stack return <Navigate to="/" replace />; } return children; }; export default ProtectedRoute;
And now in our login and register pages we will want to check if the user already have a token and if it is valid and handle redirect.
const { isLoading, isValidToken } = useTokenValidation(); useAuthNavigation(isValidToken, '/dashboard'); if(isLoading) return <></>
This route handling is a fast way of handling and protecting out routes and will let us test build and manage our forms and their respective responses.
Login and Registration Forms
In the LoginForm and RegistrationForm components, we will handle the navigation to the protected routes after successful authentication and token storage.
Registration Form
Let’s use the
react-hook-form
library along with yup
for form validation and do our Registration Form:// RegistrationForm.jsx import { useCallback, useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import axios from 'axios'; import { yupResolver } from '@hookform/resolvers/yup'; import { useNavigate } from 'react-router-dom'; import * as yup from 'yup'; const registrationSchema = yup .object() .shape({ firstName: yup.string().trim().required('First name is required'), lastName: yup.string().trim().required('Last name is required'), age: yup .number() // we transform to fix the strange error of yup.number() that throws // age must be a `number` type, but the final value was: `NaN` (cast from the value `""`). // this way skips to the .required() validation .transform((value, originalValue) => String(originalValue).trim() === '' ? undefined : value ) .required('Age is required') .positive('Age must be greater than zero') .integer('Age must be an number') .max(120, 'Age must be less than 120'), gender: yup .string() .oneOf(['male', 'female', 'other'], 'Invalid gender selection') .required('Gender is required'), email: yup .string() .trim() .email('Invalid email address') .required('Email is required') .matches(/^[\S]+$/, 'Email cannot contain spaces'), username: yup .string() .trim() .required('Username is required') .min(4, 'Username must be at least 4 characters long') .matches(/^[\S]+$/, 'Username cannot contain spaces'), password: yup .string() .required('Password is required') .min(8, 'Password must be at least 8 characters long') .matches( /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&_.+-])[^\s]+$/, 'Password must be at least 8 characters long, contain at least one uppercase letter, one lowercase letter, one number, one special character, and cannot contain spaces' ), }) .required(); const RegistrationForm = () => { const navigate = useNavigate(); const [serverError, setServerError] = useState(''); const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ resolver: yupResolver(registrationSchema), }); useEffect(() => { const firstNameInput = document.getElementById('firstName'); if (firstNameInput) { firstNameInput.focus(); } }, []); const onSubmit = async (data) => { try { const response = await axios.post('http://localhost:5001/register', data); const { token } = response.data; localStorage.setItem('authToken', token); console.log('User registered successfully'); navigate('/dashboard'); } catch (error) { const errorMessage = error?.response?.data?.error ? `${error.response.data.error}` : 'Try again'; setServerError(errorMessage); } }; const clearServerError = useCallback(() => { setServerError(''); }, []); return ( <form id="register" onSubmit={handleSubmit(onSubmit)} onFocus={clearServerError} noValidate aria-labelledby="registration-form-title" > {/* hidden h2 for screen readers */} <h2 id="registration-form-title" style={{ visibility: 'hidden' }}> Registration Form </h2> {serverError && ( <div role="alert" aria-live="assertive" style={{ color: 'red' }}> {serverError} </div> )} <div> <label htmlFor="firstName"> First Name <input id="firstName" aria-invalid={errors.firstName ? 'true' : 'false'} aria-describedby="firstName-error" autoComplete="cc-name" {...register('firstName')} /> </label> {errors.firstName && ( <div id="firstName-error" role="alert" aria-live="assertive" className="error"> {errors.firstName.message} </div> )} </div> <div> <label htmlFor="lastName"> Last Name <input id="lastName" aria-invalid={errors.lastName ? 'true' : 'false'} aria-describedby="lastName-error" autoComplete="cc-family-name" {...register('lastName')} /> </label> {errors.lastName && ( <div id="lastName-error" role="alert" aria-live="assertive" className="error"> {errors.lastName.message} </div> )} </div> <div> <label htmlFor="age"> Age <input id="age" type="number" aria-invalid={errors.age ? 'true' : 'false'} aria-describedby="age-error" {...register('age')} /> </label> {errors.age && ( <div id="age-error" role="alert" aria-live="assertive" className="error"> {errors.age.message} </div> )} </div> <div> <label htmlFor="gender"> Gender <select id="gender" aria-invalid={errors.gender ? 'true' : 'false'} aria-describedby="gender-error" {...register('gender')} > <option value="" aria-label="Selection of gender options"> Select gender </option> <option value="male" aria-label="Male"> Male </option> <option value="female" aria-label="Female"> Female </option> <option value="other" aria-label="Other"> Other </option> </select> </label> {errors.gender && ( <div id="gender-error" role="alert" aria-live="assertive" className="error"> {errors.gender.message} </div> )} </div> <div> <label htmlFor="email"> Email <input id="email" type="email" aria-invalid={errors.email ? 'true' : 'false'} aria-describedby="email-error" autoComplete="email" {...register('email')} /> </label> {errors.email && ( <div id="email-error" role="alert" aria-live="assertive" className="error"> {errors.email.message} </div> )} </div> <div> <label htmlFor="username"> Username <input id="username" aria-invalid={errors.username ? 'true' : 'false'} aria-describedby="username-error" autoComplete="username" {...register('username')} /> </label> {errors.username && ( <div id="username-error" role="alert" aria-live="assertive" className="error"> {errors.username.message} </div> )} </div> <div> <label htmlFor="password"> Password <input id="password" type="password" aria-invalid={errors.password ? 'true' : 'false'} aria-describedby="password-error" autoComplete="new-password" {...register('password')} /> </label> {errors.password && ( <div id="password-error" role="alert" aria-live="assertive" className="error"> {errors.password.message} </div> )} </div> <button type="submit" disabled={isSubmitting} aria-disabled={isSubmitting}> {isSubmitting ? 'Registering...' : 'Register Account'} </button> </form> ); }; export default RegistrationForm;
Let's break down what's happening in the RegistrationForm component:
- We define a
registrationSchema
usingyup
to specify the validation rules.
- The
useForm
hook fromreact-hook-form
is initialised with theyupResolver
and theregistrationSchema
.
- The
onSubmit
function is called when the form is submitted. It makes a POST request to the/register
endpoint with the form data.
- If the registration is successful, the server responds with a token, which is stored in the browser's local storage using
localStorage.setItem('authToken', token)
.
- The user is then navigated to the
/dashboard
route usingnavigate('/dashboard')
.
- If there's an error from server during the registration process, an error message is set in the
serverError
state, which is displayed in the form.
- The
clearServerError
function is used to clear theserverError
state when the user interacts with the form (using theonFocus
event).
- The form renders the various input fields (firstName, lastName, age, gender, email, username, password) with their respective validation error messages (if any) leveraging the errors from react-hook-form.
- We are disabling the Register button when submitting the form.
Login Form
Let’s use the
react-hook-form
library along with yup
for form validation and do our Login Form:// LoginForm.jsx import { useCallback, useEffect, useState } from 'react'; import axios from 'axios'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; import * as yup from 'yup'; import { yupResolver } from '@hookform/resolvers/yup'; const loginSchema = yup .object() .shape({ username: yup.string().required('Username is required'), password: yup.string().required('Password is required'), }) .required(); const LoginForm = () => { const navigate = useNavigate(); const [serverError, setServerError] = useState(''); const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ resolver: yupResolver(loginSchema), }); useEffect(() => { const usernameInput = document.getElementById('username'); if (usernameInput) { usernameInput.focus(); } }, []); const onSubmit = async (data) => { try { const response = await axios.post('http://localhost:5001/login', data); const { token } = response.data; localStorage.setItem('authToken', token); console.log('Login successful'); navigate('/dashboard'); } catch (error) { const errorMessage = error?.response?.data?.error ? `${error.response.data.error}` : 'Try again'; setServerError(errorMessage); } }; const clearServerError = useCallback(() => { setServerError(''); }, []); return ( <form id="login" onSubmit={handleSubmit(onSubmit)} onFocus={clearServerError} noValidate aria-labelledby="login-form-title" > {/* hidden h2 for screen readers */} <h2 id="login-form-title" style={{ visibility: 'hidden' }}> Login Form </h2> {serverError && ( <div role="alert" aria-live="assertive" style={{ color: 'red' }}> {serverError} </div> )} <div> <label htmlFor="username"> Username <input id="username" type="text" aria-invalid={errors.username ? 'true' : 'false'} aria-describedby="username-error" autoComplete="username" {...register('username')} /> </label> {errors.username && ( <div id="username-error" role="alert" aria-live="assertive" style={{ color: 'red' }}> {errors.username.message} </div> )} </div> <div> <label htmlFor="password"> Password <input id="password" type="password" aria-invalid={errors.password ? 'true' : 'false'} aria-describedby="password-error" autoComplete="current-password" {...register('password')} /> </label> {errors.password && ( <div id="password-error" role="alert" aria-live="assertive" style={{ color: 'red' }}> {errors.password.message} </div> )} </div> <button type="submit" disabled={isSubmitting} aria-disabled={isSubmitting}> {isSubmitting ? 'Logging in...' : 'Log in'} </button> </form> ); }; export default LoginForm;
Let's break down what's happening in the LoginForm component:
- We define a
loginSchema
usingyup
to specify the validation rules.
- The
useForm
hook fromreact-hook-form
is initialised with theyupResolver
and theloginSchema
.
- The
onSubmit
function is called when the form is submitted. It makes a POST request to the/login
endpoint with the form data.
- If the login is successful, the server responds with a token, which is stored in the browser's local storage using
localStorage.setItem('authToken', token)
.
- The user is then navigated to the
/dashboard
route usingnavigate('/dashboard')
.
- If there's an error from server during the login process, an error message is set in the
serverError
state, which is displayed in the form.
- The
clearServerError
function is used to clear theserverError
state when the user interacts with the form (using theonFocus
event).
- The form renders the username and password fields with their respective validation error messages (if any).
- We are disabling the Login button when submitting the form.
Both components follow a similar pattern: validate the form data using
react-hook-form
and yup
, handle form submission by making a POST request to the server, store the token in local storage upon successful authentication, navigate to the /dashboard
route, and display error messages if any occur during the process.Let’s discuss some of the things we are doing on this form in detail:
Benefits of using react-hook-form and yup:
react-hook-form:
- Provides a simple and efficient way to manage form state and validation in React applications.
- Offers better performance compared to other form libraries by leveraging React's built-in state management and reducing unnecessary re-renders.
- Supports advanced features like form validation, error handling, and form submission handling out of the box.
- Integrates well with other libraries and tools, such as
yup
for schema validation.
yup:
- Offers a simple and expressive way to define and validate data structures.
- Provides a user-friendly error messaging system, making it easier to provide meaningful validation feedback to users.
- Supports complex validation scenarios, including nested data structures, conditional logic, and custom validation rules.
- Integrates well with
react-hook-form
through theyupResolver
provided by the@hookform/resolvers/yup
package.
Handling
serverError
and clearing error messages:- The
serverError
state is used to store any error messages received from the server during the authentication process.
- If an error occurs, the
setServerError
function is called with the error message received from the server's response (error.response.data.error
).
- To clear the
serverError
message, aclearServerError
function is defined using theuseCallback
hook. This function simply sets theserverError
state to an empty string (setServerError('')
).
- The
clearServerError
function is called when the user interacts with the form by attaching it to theonFocus
event of the form (onFocus={clearServerError}
). This way, any error messages displayed in the form will be cleared when the user starts interacting with the form again.
- User experience: By providing clear validation error messages and clearing them when the user interacts with the form again, the application offers a better user experience and guides users in correcting any errors.
Implementing accessibility:
- Proper labeling: The form fields are wrapped in
<label>
elements, ensuring that screen readers can correctly associate the labels with their respective input fields.
- ARIA attributes: Additional ARIA attributes are used to improve accessibility:
aria-describedby
: Associates the input field with its corresponding error message element, enabling screen readers to read both the input field and its error message.role="alert"
: Marks the error message elements as live regions, allowing screen readers to announce any changes to the error message content.aria-live="assertive"
: Instructs screen readers to immediately announce any changes to the live region (error message).Using
yupResolver
from @hookform/resolvers/yup
:yupResolver
is a resolver provided by the@hookform/resolvers/yup
package, which integratesyup
withreact-hook-form
.
- It allows you to define validation schemas using
yup
and leverage those schemas withinreact-hook-form
for form validation.
- By passing the
yupResolver
and theyup
schema to theuseForm
hook,react-hook-form
can automatically handle validation based on the defined schema.
- This integration simplifies the process of defining and applying validation rules in your React forms, while benefiting from the powerful validation capabilities of
yup
.
Wrap up what we have done till now
In this project, we successfully built a secure registration and login system using React Vite for the frontend, on our backend Node.js, Express.js, with moongose to connect to our mongoDB. We implemented various security measures and best practices to protect user data and prevent common vulnerabilities.
- We successfully implemented a server using
Node.js
andExpress.js
that interacts with a MongoDB database.
- We successfully defined a
user model schema
to ensure that user data adheres to our requirements and implemented routes for user registration and login.
- We successfully implemented
JSON Web Tokens
(JWTs) for secure user authentication and authorization, allowing us to verify that the user is authenticated.
- We successfully implemented additional security by setting up
cors
, sanitization of data withexpress-mongo-sanitize
, reduce the risk of DDoS attacks withexpress-rate-limit
, set various security headers withhelmet
.
- We successfully implemented a frontend with
React Vite
to build our user interface components, including the registration and login forms.
- We successfully implemented
react-hook-form
library along withyup
for form validation and handling user input
- We successfully implemented navigation and access control using
React Router
andcustom hooks
to ensure that only authenticated users can access protected routes.
- We successfully implemented a secure user authentication system using JSON Web Tokens (JWTs). Based on the user's authentication status, we navigate them to the appropriate pages.
- We successfully implemented accessibility features, such as proper labeling, ARIA attributes, and live regions, to ensure that our application is inclusive and usable for all users.
- We successfully built forms that are focused on providing a good user experience by displaying clear validation error messages and guiding users to correct any errors.
By following best practices and implementing security measures at every step, we have created a robust and secure registration and login system that is expandable and can serve as a foundation for building a more complex application.
Unit Testing with Vitest and React Testing Library
In this section, we'll explore how to set up unit testing for a React application using Vitest and React Testing Library.
Setting up Vitest
Let's configure Vitest by creating a
vitest.config.js
file at the root of your project.import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], test: { globals: true, environment: 'jsdom', setupFiles: './setupTests.js', css: true, }, });
This configuration specifies the use of the React plugin, enabling support for JSX in our tests. We also set the testing environment to
jsdom
, which simulates a browser environment for our tests to run in. Additionally, the globals: true
option makes Vitest's functions globally available in our tests, avoiding the need for imports. The setupFiles
option points to a script that runs before our tests, allowing us to globally set up necessary imports or configurations.// setupTests.js import '@testing-library/jest-dom';
The
setupTests.js
file is straightforward, importing @testing-library/jest-dom
to extend Vitest with custom DOM element matchers.Testing the LoginForm component
// LoginForm.test.jsx import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { createMemoryHistory } from 'history'; import { Router } from 'react-router-dom'; import LoginForm from './LoginForm'; import axios from 'axios'; vi.mock('axios'); const textVariables = { inputLabelText: { username: 'Username', password: 'Password', }, loginFormButtonText: { submit: 'Log in', }, errors: { noInput: { username: 'Username is required', password: 'Password is required', }, server: { genericMockedServerError: 'Try again later', }, }, }; describe('LoginForm', () => { let history; let usernameInput, passwordInput, submitButton; beforeEach(() => { history = createMemoryHistory(); axios.post.mockResolvedValue({ data: { token: 'mocked-token' } }); render( <Router location={history.location} navigator={history}> <LoginForm /> </Router> ); usernameInput = screen.getByLabelText(textVariables.inputLabelText.username); passwordInput = screen.getByLabelText(textVariables.inputLabelText.password); submitButton = screen.getByRole('button', { name: textVariables.loginFormButtonText.submit, }); }); afterEach(() => { vi.resetAllMocks(); }); test('renders form elements correctly', () => { expect(usernameInput).toBeInTheDocument(); expect(passwordInput).toBeInTheDocument(); expect(submitButton).toBeInTheDocument(); }); test('shows error messages for invalid inputs', async () => { await userEvent.click(submitButton); await waitFor(() => { expect(screen.getByText(textVariables.errors.noInput.username)).toBeInTheDocument(); expect(screen.getByText(textVariables.errors.noInput.password)).toBeInTheDocument(); }); }); test('calls login API on successful submission', async () => { await userEvent.type(usernameInput, 'testuser'); await userEvent.type(passwordInput, 'testpassword'); await userEvent.click(submitButton); await waitFor(() => { expect(axios.post).toHaveBeenCalledWith('http://localhost:5001/login', { username: 'testuser', password: 'testpassword', }); }); }); test('stores token in localStorage and navigates to dashboard on successful login', async () => { const setItemSpy = vi.spyOn(Storage.prototype, 'setItem'); await userEvent.type(usernameInput, 'testuser'); await userEvent.type(passwordInput, 'testpassword'); await userEvent.click(submitButton); await waitFor(() => { expect(setItemSpy).toHaveBeenCalledWith('authToken', 'mocked-token'); expect(history.location.pathname).toBe('/dashboard'); }); expect(setItemSpy).toHaveBeenCalledTimes(1); }); test('shows server error message on failed login', async () => { await axios.post.mockRejectedValueOnce({ response: { data: { error: textVariables.errors.server.genericMockedServerError }, status: 400, statusText: 'Bad Request', }, }); await userEvent.type(usernameInput, 'testuser'); await userEvent.type(passwordInput, 'testpassword'); await userEvent.click(submitButton); // Wait for the serverError state to be updated await waitFor(() => { expect(screen.getByRole('alert')).toHaveTextContent(textVariables.errors.server.genericMockedServerError); }); }); });
The
LoginForm
test suite includes the following test cases:- Renders form elements correctly: This test ensures that the login form renders all the necessary elements (username input, password input, and login button).
- Shows error messages for invalid inputs: This test verifies that the appropriate error messages are displayed when the form is submitted without entering any values.
- Calls login API on successful submission: This test mocks the
axios
library and ensures that the correct API endpoint (http://localhost:5001/login
) is called with the correct payload when the form is submitted with valid inputs.
- Stores token in localStorage and navigates to dashboard on successful login: This test mocks the
localStorage
object and thehistory
object fromreact-router-dom
. It checks that the authentication token is stored inlocalStorage
and that the user is navigated to the dashboard after a successful login.
- Shows server error message on failed login: This test simulates a failed login attempt by mocking the
axios
library to return an error response. It verifies that the appropriate server error message is displayed when the login fails.
Testing the RegistrationForm component
// RegistrationForm.test.jsx import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import RegistrationForm from './RegistrationForm'; import axios from 'axios'; import { createMemoryHistory } from 'history'; import { Router } from 'react-router-dom'; vi.mock('axios'); const textVariables = { inputLabelText: { firstName: 'First Name', lastName: 'Last Name', age: 'Age', gender: 'Gender', email: 'Email', username: 'Username', password: 'Password', }, registerFormButtonText: { submit: 'Register Account', }, errors: { noInput: { firstName: 'First name is required', lastName: 'Last name is required', age: 'Age is required', gender: 'Invalid gender selection', email: 'Email is required', username: 'Username is required', password: 'Password is required', }, server: { genericMockServerError: 'Try again', }, }, }; // Utility function to fill out the registration form async function fillOutRegistrationForm(formData) { const { firstName, lastName, age, gender, email, username, password } = formData; /* * We are simulating user keystrokes with userEvent.type and users can only input text (which is treated as strings) into form fields * formData.age is sent to API as a number, but typed on the form as a string */ const ageString = age.toString(); await userEvent.type(screen.getByLabelText(textVariables.inputLabelText.firstName), firstName); await userEvent.type(screen.getByLabelText(textVariables.inputLabelText.lastName), lastName); await userEvent.type(screen.getByLabelText(textVariables.inputLabelText.age), ageString); await userEvent.selectOptions(screen.getByLabelText(textVariables.inputLabelText.gender), gender); await userEvent.type(screen.getByLabelText(textVariables.inputLabelText.email), email); await userEvent.type(screen.getByLabelText(textVariables.inputLabelText.username), username); await userEvent.type(screen.getByLabelText(textVariables.inputLabelText.password), password); } describe('RegistrationForm', () => { let history; let firstNameInput, lastNameInput, ageInput, genderSelect, emailInput, usernameInput, passwordInput, submitButton; beforeEach(() => { history = createMemoryHistory(); axios.post.mockResolvedValue({ data: { token: 'mocked-token' } }); render( <Router location={history.location} navigator={history}> <RegistrationForm /> </Router> ); firstNameInput = screen.getByLabelText(textVariables.inputLabelText.firstName); lastNameInput = screen.getByLabelText(textVariables.inputLabelText.lastName); ageInput = screen.getByLabelText(textVariables.inputLabelText.age); genderSelect = screen.getByLabelText(textVariables.inputLabelText.gender); emailInput = screen.getByLabelText(textVariables.inputLabelText.email); usernameInput = screen.getByLabelText(textVariables.inputLabelText.username); passwordInput = screen.getByLabelText(textVariables.inputLabelText.password); submitButton = screen.getByRole('button', { name: textVariables.registerFormButtonText.submit, }); }); afterEach(() => { vi.resetAllMocks(); }); test('renders form elements correctly', () => { expect(firstNameInput).toBeInTheDocument(); expect(lastNameInput).toBeInTheDocument(); expect(ageInput).toBeInTheDocument(); expect(genderSelect).toBeInTheDocument(); expect(emailInput).toBeInTheDocument(); expect(usernameInput).toBeInTheDocument(); expect(passwordInput).toBeInTheDocument(); expect(submitButton).toBeInTheDocument(); }); test('shows error messages for invalid inputs', async () => { await userEvent.click(submitButton); expect(await screen.findByText(textVariables.errors.noInput.firstName)).toBeInTheDocument(); expect(screen.getByText(textVariables.errors.noInput.lastName)).toBeInTheDocument(); expect(screen.getByText(textVariables.errors.noInput.age)).toBeInTheDocument(); expect(screen.getByText(textVariables.errors.noInput.gender)).toBeInTheDocument(); expect(screen.getByText(textVariables.errors.noInput.email)).toBeInTheDocument(); expect(screen.getByText(textVariables.errors.noInput.username)).toBeInTheDocument(); expect(screen.getByText(textVariables.errors.noInput.password)).toBeInTheDocument(); }); test('calls registration API on successful submission', async () => { const formData = { firstName: 'John', lastName: 'Doe', age: 30, gender: 'male', email: 'johndoe@example.com', username: 'johndoe', password: 'Password123!', }; await fillOutRegistrationForm(formData); await userEvent.click(submitButton); await waitFor(() => { expect(axios.post).toHaveBeenCalledWith('http://localhost:5001/register', formData); }); }); test('stores token in localStorage and navigates to dashboard on successful registration', async () => { const setItemSpy = vi.spyOn(Storage.prototype, 'setItem'); const formData = { firstName: 'John', lastName: 'Doe', age: 30, gender: 'male', email: 'johndoe@example.com', username: 'johndoe', password: 'Password123!', }; await fillOutRegistrationForm(formData); await userEvent.click(submitButton); await waitFor(() => { expect(setItemSpy).toHaveBeenCalledWith('authToken', 'mocked-token'); expect(history.location.pathname).toBe('/dashboard'); }); expect(setItemSpy).toHaveBeenCalledTimes(1); }); test('shows server error message on failed registration', async () => { axios.post.mockRejectedValueOnce({ response: { data: { error: textVariables.errors.server.genericMockServerError } }, }); const formData = { firstName: 'John', lastName: 'Doe', age: 30, gender: 'male', email: 'johndoe@example.com', username: 'johndoe', password: 'Password123!', }; await fillOutRegistrationForm(formData); await userEvent.click(submitButton); expect( await screen.findByText(textVariables.errors.server.genericMockServerError) ).toBeInTheDocument(); }); });
The
RegistrationForm
test suite includes similar test cases to the LoginForm
suite, with a few additions:- Renders form elements correctly: This test ensures that the registration form renders all the necessary elements (first name input, last name input, age input, gender select, email input, username input, password input, and register button).
- Shows error messages for invalid inputs: This test verifies that the appropriate error messages are displayed when the form is submitted with invalid or missing inputs.
- Calls registration API on successful submission: This test mocks the
axios
library and ensures that the correct API endpoint is called with the correct payload when the form is submitted with valid inputs.
- Stores token in localStorage and navigates to dashboard on successful registration: This test mocks the
localStorage
object and thehistory
object fromreact-router-dom
. It checks that the authentication token is stored inlocalStorage
and that the user is navigated to the dashboard after a successful registration.
- Shows server error message on failed registration: This test simulates a failed registration attempt by mocking the
axios
library to return an error response. It verifies that the appropriate server error message is displayed when the registration fails.
End-To-End testing with Cypress
Before we dive into the details of our Cypress tests, it's important to note that for simplicity, we'll be connecting directly to our MongoDB database from the frontend to register and delete test users. While this approach is not recommended for production applications due to security concerns, it allows us to avoid the need for creating separate testing routes or extra endpoints in our backend API.
With that said, let's take a closer look at our Cypress configuration and the scripts responsible for managing test user data.
Installing and Setting Up Cypress
Let’s install cypress
npm install --save-dev cypress
Next, we'll need to set up our Cypress configuration and scripts. In the
cypress.config.js
file, we define our Cypress configuration and specify tasks for setting up test data in our MongoDB database. We'll use these tasks to create and clean up a test user before and after each test suite.// cypress.config.js import {defineConfig} from 'cypress'; import cleanupDbTestUser from './cypress/scripts/cleanupDbTestUser.js'; import generateRandomUser from './cypress/scripts/generateRandomUser.js'; import registerDbTestUser from './cypress/scripts/registerDbTestUser.js'; export default defineConfig({ e2e: { setupNodeEvents(on, config) { on('task', { async cleanupDbTestUser() { const randomUser = generateRandomUser(); const messages = await cleanupDbTestUser(randomUser.EMAIL_FIXED); return { messages, cleanedDbUser: randomUser }; }, }); on('task', { async registerDbTestUser() { const randomUser = generateRandomUser(); const messages = await registerDbTestUser(randomUser); return { messages, registeredDbUser: randomUser }; }, }); return config; }, trashAssetsBeforeRuns: false, }, component: { devServer: { framework: 'react', bundler: 'vite', }, }, });
The
cleanupDbTestUser.js
and registerDbTestUser.js
scripts handle the creation and deletion of test users in our MongoDB database, while generateRandomUser.js
generates random user data for our tests.// generateRandomUser.js import { faker } from '@faker-js/faker'; faker.seed(123); function generateRandomUser() { const USERNAME_FIXED = '7a59f3c8add07cdb20996662c3c7795dc6c203285945a5e1c011bbc082d3a4827ee16a6dd060e71bbb2d089a3f02c4b13174bba15af44b0102ed9650368fe88d'; const EMAIL_FIXED = '7a59f3c8add07cdb20996662c3c7795dc6c203285945a5e1c011bbc082d3a4827ee16a6dd060e71bbb2d089a3f02c4b13174bba15af44b0102ed9650368fe88d@gmail.com'; const PASSWORD_FIXED = 'MyVerySecurePassword_123'; const randomUser = { firstName: faker.person.firstName(), lastName: faker.person.lastName(), age: faker.number.int({ min: 1, max: 120 }), gender: 'male', EMAIL_FIXED: EMAIL_FIXED, USERNAME_FIXED: USERNAME_FIXED, PASSWORD_FIXED: PASSWORD_FIXED, }; return randomUser; } export default generateRandomUser;
In this generateRandomUser we are actually using fixed values for username, email and password. This serves more as a proof of concept and ability to use random data and implement it now. Generating random user data is very useful and will prove to be even more useful when you are ready to expand the tests to a more complex level.
// cleanupDbTestUser.js import mongoose from 'mongoose'; import dotenv from 'dotenv'; import User from '../../src/models/user.model.js'; dotenv.config(); async function cleanupDbTestUser(email) { console.log('cleanupDatabase STARTED'); let messages = []; if (!email) { console.log('no email provided', email); return []; } try { await mongoose.connect(process.env.MONGODB_URI); console.log('Connected to MongoDB'); messages.push('Connected to MongoDB'); const result = await User.deleteOne({ email }); console.log(`Deleted ${result.deletedCount} user with email ${email}`); messages.push(`Deleted ${result.deletedCount} user with email ${email}`); } catch (error) { messages.push(`Error cleaning up database: ${error.message}`); console.error('Error cleaning up database:', error); } finally { await mongoose.disconnect(); console.log('Disconnected from MongoDB'); messages.push('Disconnected from MongoDB'); } return messages; } export default cleanupDbTestUser;
This script connects to the MongoDB database, deletes a user with the specified email address, and returns messages about the operation's status. It's used to clean up any existing test user data before running tests. We will not be using the messages by now but there is a possibility of using them, for example, on a CI environment would be very useful.
// registerDbTestUser.js import mongoose from 'mongoose'; import dotenv from 'dotenv'; import User from '../../src/models/user.model.js'; dotenv.config(); async function registerDbTestUser(randomUser) { console.log('registerDbTestUser STARTED'); let messages = []; try { await mongoose.connect(process.env.MONGODB_URI); console.log('Connected to MongoDB'); messages.push('Connected to MongoDB'); // Check if a user with the same email or username already exists const existingUserEmail = await User.findOne({ email: randomUser.EMAIL_FIXED }); const existingUserUsername = await User.findOne({ username: randomUser.USERNAME_FIXED, }); if (existingUserEmail || existingUserUsername) { const errorMessage = `User with email ${existingUserEmail ? existingUserEmail.email : ''} or username ${existingUserUsername ? existingUserUsername.username : ''} already exists.`; console.log(errorMessage); messages.push(errorMessage); return messages; } const newUser = new User({ firstName: randomUser.firstName, lastName: randomUser.lastName, age: randomUser.age, gender: randomUser.gender, email: randomUser.EMAIL_FIXED, username: randomUser.USERNAME_FIXED, password: randomUser.password, }); const result = await newUser.save(); console.log(`Created user with email ${result.email}`); messages.push(`Created user with email ${result.email}`); } catch (error) { messages.push(`Error registering user: ${error.message}`); console.error('Error registering user:', error); } finally { await mongoose.disconnect(); console.log('Disconnected from MongoDB'); messages.push('Disconnected from MongoDB'); } return messages; } export default registerDbTestUser;
This script connects to the MongoDB database, checks if a user with the same email or username already exists, and creates a new user if not. It returns messages about the operation's status. It's used to set up a test user before running tests. We will not be using the messages by now but there is a possibility of using them, for example, on a CI environment would be very useful.
Writing Tests with Cypress
With Cypress set up, we can start writing our end-to-end tests. In our project, we have two main test suites: one for user registration (
registration.cy.js
) and one for user login (login.cy.js
).// registration.cy.js let cleanedUser = {}; describe('Registration', () => { before(() => { cy.task('cleanupDbTestUser').then(({ messages, cleanedDbUser }) => { expect(messages).to.include('Connected to MongoDB'); expect(messages.some((message) => message.startsWith('Deleted'))).to.be.true; expect(messages.some((message) => message.startsWith('Disconnected'))).to.be.true; expect(messages.some((message) => message.includes('Error'))).to.be.false; cleanedUser = cleanedDbUser; }); }); beforeEach(() => { cy.visit('localhost:5173/register'); cy.get('input#firstName').type(cleanedUser.firstName); cy.get('input#lastName').type(cleanedUser.lastName); cy.get('input#age').type(cleanedUser.age); cy.get('select#gender').select(cleanedUser.gender); cy.get('input#email').type(cleanedUser.EMAIL_FIXED); cy.get('input#username').type(cleanedUser.USERNAME_FIXED); cy.get('input#password').type(cleanedUser.PASSWORD_FIXED); cy.get('form').submit(); }); it('should register a new user, redirect to dashboard and store token in local storage', () => { // Assert that the user is registered successfully cy.url().should('include', '/dashboard'); cy.window().should((window) => { const token = window.localStorage.getItem('authToken'); expect(token).not.to.be.empty; expect(token).to.be.a('string'); }); }); it('registering with already registered email should not register', () => { cy.get('div[role="alert"]').contains('Email or username already exists').should('be.visible'); }); });
let registeredUser = {}; describe('Login', () => { before(() => { cy.task('registerDbTestUser').then(({ registeredDbUser }) => { registeredUser = registeredDbUser; }); }); beforeEach(() => { cy.visit('http://localhost:5173/'); cy.get('input#username').type(registeredUser.USERNAME_FIXED); cy.get('input#password').type(registeredUser.PASSWORD_FIXED); cy.get('form').submit(); cy.window().should((window) => { const token = window.localStorage.getItem('authToken'); expect(token).to.not.be.empty; }); }); it('should redirect to the dashboard if the user is already logged in', () => { cy.visit('http://localhost:5173/'); cy.url().should('include', '/dashboard'); cy.visit('http://localhost:5173/register'); cy.url().should('include', '/dashboard'); }); it('Removing token and should be able to visit login and register', () => { cy.visit('http://localhost:5173/dashboard'); cy.url().should('include', '/dashboard'); { /* Simulate a logout */ } cy.window().should((window) => { window.localStorage.removeItem('authToken'); expect(window.localStorage.getItem('authToken')).to.be.null; }); cy.visit('http://localhost:5173/'); cy.url().should('equal', 'http://localhost:5173/'); cy.visit('http://localhost:5173/register'); cy.url().should('equal', 'http://localhost:5173/register'); }); it('Manipulate Token does not allow to visit dashboard', () => { { /* Manipulate while maintaining same length */ } cy.window().should((window) => { const originalToken = window.localStorage.getItem('authToken'); window.localStorage.setItem('authToken', 'a' + originalToken.slice(1)); const manipulatedToken = window.localStorage.getItem('authToken'); expect(manipulatedToken.length).to.eq(originalToken.length); }); cy.visit('http://localhost:5173/dashboard'); cy.url().should('not.include', '/dashboard'); cy.visit('http://localhost:5173/'); cy.url().should('equal', 'http://localhost:5173/'); cy.visit('http://localhost:5173/register'); cy.url().should('equal', 'http://localhost:5173/register'); }); });
This tests handle some of the common scenarios on the login and registration process, including, route navigation and route protection, JWT token manipulation and route protection.
Script to CLI run cypress tests on every available browser
Now, let’s make something really cool, let’s automate the process of using our cypress tests and automatically run our cypress tests for all available browsers that we have installed on out local environment.
We will start by using
npx cypress info
and we will read that info from the console, we will parse that info and find out what available browsers we have on our system.// run-tests-on-all-browsers.js import { exec as execCallback } from 'child_process'; import { promisify } from 'util'; const exec = promisify(execCallback); function parseBrowsers(output) { const browserLines = output.split('\n').filter((line) => line.trim().startsWith('- Name:')); return browserLines.map((line) => line.split(': ')[1]); } async function runCypressTests(browser) { console.log(`Running tests on ${browser}...`); try { const { stdout } = await exec(`npx cypress run --browser ${browser}`); console.log(`Tests completed on ${browser}:\n`, stdout); } catch (error) { console.error(`Error running tests on ${browser}:`, error.stderr); } } async function getCypressInfo() { try { const { stdout } = await exec('npx cypress info'); return stdout; } catch (error) { console.error('Error getting Cypress info:', error.stderr); throw error; } } async function runTestsOnAllBrowsers() { try { const cypressInfoOutput = await getCypressInfo(); const browsers = parseBrowsers(cypressInfoOutput); console.log('Detected browsers:', browsers); browsers.push('electron'); console.log('Starting tests on:', browsers); for (const browser of browsers) { await runCypressTests(browser); } } catch (error) { console.error('Failed to run tests on all browsers:', error); } } runTestsOnAllBrowsers();
Here is what is happening here:
- The
child_process
module allows executing shell commands, whileutil
provides thepromisify
utility to convert callback-based functions to Promise-based functions.
getCypressInfo
function: This function will read the info from the console by executingnpx cypress info
and return it as a string.
parseBrowsers
function: This function parses the string to extract the names of the available browsers. It filters the output lines that start with "- Name:" splits and extract the browser name.
runCypressTests
function: This function runs the Cypress tests on a specific browser. It executes thenpx cypress run --browser <browser>
command usingchild_process.exec
and logs the output to the console.
runTestsOnAllBrowsers
function: This is the main function that orchestrates this symphony of test execution on all available browsers. It does the following:
We are adding the "electron" browser to the list, since it's bundled with Cypress CLI by default.
Conclusion
We have explored the process of building a secure registration and login system for a web application using React Vite for the frontend and Node.js, Express.js, and MongoDB for the backend. We've covered various aspects of the project, including implementing security measures, defining user models and routes, integrating JSON Web Tokens (JWTs) for authentication and authorization, and setting up additional security features like CORS, data sanitization, rate limiting, and security headers.
We have implemented form validation using the react-hook-form library and yup, ensuring that user input is properly validated before submission. Additionally, we've incorporated React Router and custom hooks to handle navigation and access control, ensuring that only authenticated users can access protected routes.
By combining a secure backend, a user-friendly frontend, and thorough testing, we've created a reliable and scalable registration and login system that can be expanded and integrated into larger applications. The principles and techniques demonstrated in this project can be applied to a wide range of web development projects, ensuring that security and accessibility remain at the forefront of the development process.