From cc81be4cceb3670caad49022743d439fae762535 Mon Sep 17 00:00:00 2001 From: David Chibueze Ndubuisi Date: Fri, 6 Oct 2023 00:47:27 +0100 Subject: [PATCH] chore: create logout developer endpoint; add test #751 (#752) * chore: create logout developer endpoint; add test #751 * chore: add logout business logic #751 * Update src/middleware/authenticated.ts Co-authored-by: Ijemma Onwuzulike * Update src/middleware/authenticated.ts Co-authored-by: Ijemma Onwuzulike * Update src/middleware/authenticated.ts Co-authored-by: Ijemma Onwuzulike * Update src/routers/router.ts Co-authored-by: Ijemma Onwuzulike * chore: sync updates #751 --------- Co-authored-by: Ijemma Onwuzulike --- __tests__/__mocks__/documentData.js | 48 +++++++++++++++++-- __tests__/auth.test.js | 55 ++++++++++++++++++++-- __tests__/shared/commands.js | 4 ++ src/controllers/{auth/login.ts => auth.ts} | 40 ++++++++++++---- src/middleware/authenticate.ts | 49 +++++++++++++++++++ src/middleware/validateApiKey.ts | 16 ++++--- src/routers/router.ts | 4 +- src/types/developer.ts | 6 ++- src/types/express.ts | 6 ++- 9 files changed, 198 insertions(+), 30 deletions(-) rename src/controllers/{auth/login.ts => auth.ts} (75%) create mode 100644 src/middleware/authenticate.ts diff --git a/__tests__/__mocks__/documentData.js b/__tests__/__mocks__/documentData.js index dd47eb88..edf91eb5 100644 --- a/__tests__/__mocks__/documentData.js +++ b/__tests__/__mocks__/documentData.js @@ -11,10 +11,41 @@ const developerData = { password: 'password', }; +// Generate a unique name using a UUID without hyphens +const generateUniqueName = () => uuid().replace(/-/g, ''); + +const generateUniqueEmail = () => { + // Generate a unique email using a UUID without hyphens and without numbers + const email = `${uuid().replace(/-/g, '')}@testing.com`; + // Remove numbers from the email + return email.replace(/\d/g, ''); +}; + +// Generate a unique password using a UUID without hyphens +const generateUniquePassword = () => uuid().replace(/-/g, ''); + const newDeveloperData = { - name: `${uuid().replace(/-/g, '')}`, - email: `${uuid().replace(/-/g, '')}@testing.com`, - password: `${uuid()}`, + name: generateUniqueName(), + email: generateUniqueEmail(), + password: generateUniquePassword(), +}; + +const developerOneData = { + name: generateUniqueName(), + email: generateUniqueEmail(), + password: generateUniquePassword(), +}; + +const developerTwoData = { + name: generateUniqueName(), + email: generateUniqueEmail(), + password: generateUniquePassword(), +}; + +const anotherDeveloperData = { + name: generateUniqueName(), + email: generateUniqueEmail(), + password: generateUniquePassword(), }; const malformedDeveloperData = { @@ -22,4 +53,13 @@ const malformedDeveloperData = { password: 'password', }; -export { wordId, exampleId, developerData, newDeveloperData, malformedDeveloperData }; +export { + wordId, + exampleId, + developerData, + newDeveloperData, + developerOneData, + developerTwoData, + anotherDeveloperData, + malformedDeveloperData, +}; diff --git a/__tests__/auth.test.js b/__tests__/auth.test.js index 49d268fb..44393c92 100644 --- a/__tests__/auth.test.js +++ b/__tests__/auth.test.js @@ -1,17 +1,64 @@ -import { newDeveloperData } from './__mocks__/documentData'; -import { createDeveloper, loginDeveloper } from './shared/commands'; +import { anotherDeveloperData, developerOneData, newDeveloperData } from './__mocks__/documentData'; +import { createDeveloper, loginDeveloper, logoutDeveloper } from './shared/commands'; describe('login', () => { it('should successfully log a developer in', async () => { - await createDeveloper(newDeveloperData); + const developer = await createDeveloper(newDeveloperData); + expect(developer.status).toEqual(200); + const data = { email: newDeveloperData.email, password: newDeveloperData.password, }; const loginRes = await loginDeveloper(data); - expect(loginRes.status).toEqual(200); expect(loginRes.body.developer).toMatchObject(loginRes.body.developer); }); + + it('should not log a developer in with an incorrect password', async () => { + const developer = await createDeveloper(developerOneData); + expect(developer.status).toEqual(200); + + const data = { + email: developerOneData.email, + password: 'incorrect', + }; + + const loginRes = await loginDeveloper(data); + expect(loginRes.status).toEqual(400); + expect(loginRes.body.error).toEqual(loginRes.body.error); + }); + + it('should not log a developer in with an non-existent email', async () => { + const data = { + email: anotherDeveloperData.email, + password: anotherDeveloperData.password, + }; + + const loginRes = await loginDeveloper(data); + expect(loginRes.status).toEqual(400); + expect(loginRes.body.error).toEqual(loginRes.body.error); + }); +}); + +describe('logout', () => { + it('should successfully log a developer out', async () => { + const developer = await createDeveloper(anotherDeveloperData); + expect(developer.status).toEqual(200); + + const data = { + email: anotherDeveloperData.email, + password: anotherDeveloperData.password, + }; + + const loginRes = await loginDeveloper(data); + expect(loginRes.status).toEqual(200); + + const logoutRes = await logoutDeveloper({ token: loginRes.body.token }); + expect(logoutRes.status).toEqual(200); + expect(logoutRes.body).toMatchObject({ + message: 'Logged out successfully', + }); + }); }); diff --git a/__tests__/shared/commands.js b/__tests__/shared/commands.js index c6449ed6..b7222ee8 100644 --- a/__tests__/shared/commands.js +++ b/__tests__/shared/commands.js @@ -80,3 +80,7 @@ export const getDeveloper = (options = {}) => /** Login a developer */ export const loginDeveloper = (data) => server.post(`${API_ROUTE}/login`).send(data); + +/** Logout a developer */ +export const logoutDeveloper = (options = {}) => + server.post(`${API_ROUTE}/logout`).set('Authorization', `Bearer ${options.token || ''}`); diff --git a/src/controllers/auth/login.ts b/src/controllers/auth.ts similarity index 75% rename from src/controllers/auth/login.ts rename to src/controllers/auth.ts index 219d6a31..37e5d1e6 100644 --- a/src/controllers/auth/login.ts +++ b/src/controllers/auth.ts @@ -1,12 +1,12 @@ import { Response } from 'express'; import jwt from 'jsonwebtoken'; import bcrypt from 'bcrypt'; -import { Developer as DeveloperType, Express } from '../../types'; -import { isProduction, isTest } from '../../config'; -import { createDbConnection, handleCloseConnection } from '../../services/database'; -import { developerSchema } from '../../models/Developer'; -import { TEST_EMAIL } from '../../shared/constants/Developers'; -import { JWT_SECRET, cookieOptions } from '../../siteConstants'; +import { Developer as DeveloperType, Express } from '../types'; +import { isProduction, isTest } from '../config'; +import { createDbConnection, handleCloseConnection } from '../services/database'; +import { developerSchema } from '../models/Developer'; +import { TEST_EMAIL } from '../shared/constants/Developers'; +import { JWT_SECRET, cookieOptions } from '../siteConstants'; /** * Compares a hashed password with a plaintext password to check for a match. @@ -29,9 +29,6 @@ const checkPassword = async (password: string, hash: string) => { * @throws {Error} If an error occurs during the token signing process. */ const signToken = (email: string) => { - console.info('JWT_SECRET'); - console.log(JWT_SECRET); - const token = jwt.sign({ email }, JWT_SECRET, { expiresIn: '1d' }); return token; }; @@ -79,7 +76,7 @@ const loginDeveloperWithEmailAndPassword = async (email: string, password: strin /** * Handles the login process for a developer. * - * @param {Express.Request} req - The Express request object. + * @param {Express.IgboAPIRequest} req - The Express request object. * @param {Express.Response} res - The Express response object. * @param {Express.NextFunction} next - The next middleware function. * @returns {Promise} A Promise that resolves when the login process is complete. @@ -104,3 +101,26 @@ export const login: Express.MiddleWare = async (req, res, next) => { return next(error); } }; + +/** + * Handles the logout process for a developer. + * @param {Express.IgboAPIRequest} req - The Express request object. + * @param {Express.Response} res - The Express response object. + * @param {Express.NextFunction} next - The next middleware function. + * @returns {Promise} A Promise that resolves when the logout process is complete. + */ +export const logout: Express.MiddleWare = async (req, res, next) => { + try { + res.cookie('jwt', '', { expires: new Date(), httpOnly: true }); + + const message = 'Logged out successfully'; + return res.status(200).send({ + message, + }); + } catch (error) { + if (!isTest) { + console.trace(error); + } + return next(error); + } +}; diff --git a/src/middleware/authenticate.ts b/src/middleware/authenticate.ts new file mode 100644 index 00000000..79fe3f69 --- /dev/null +++ b/src/middleware/authenticate.ts @@ -0,0 +1,49 @@ +import jwt from 'jsonwebtoken'; +import { JWT_SECRET } from '../siteConstants'; +import { Express } from '../types'; +import { createDbConnection, handleCloseConnection } from '../services/database'; +import { developerSchema } from '../models/Developer'; + +interface DeveloperDataType { + email: string; + iat?: number; + exp?: number; +} + +export const authenticate: Express.MiddleWare = async (req, res, next) => { + let token: string | undefined; + // Check if token is set + if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) { + [, token] = req.headers.authorization.split(' '); + } + + if (!token) { + return next(new Error('Unauthorized. Please login to continue.')); + } + + let developer: DeveloperDataType; + + // Verify token + try { + developer = jwt.verify(token, JWT_SECRET) as DeveloperDataType; + } catch (error: unknown) { + if (error instanceof Error) { + return next(new Error(error.message)); + } + return next(new Error('Invalid token')); + } + + // Check if developer still exists in the database + const connection = createDbConnection(); + const Developer = connection.model('Developer', developerSchema); + const { email } = developer; + const currentUser = await Developer.findOne({ email }); + await handleCloseConnection(connection); + if (!currentUser) { + return next(new Error('This User does not exist')); + } + + // Grant access + req.developer = currentUser; + return next(); +}; diff --git a/src/middleware/validateApiKey.ts b/src/middleware/validateApiKey.ts index 0bb0c86a..9963f5e7 100644 --- a/src/middleware/validateApiKey.ts +++ b/src/middleware/validateApiKey.ts @@ -15,13 +15,15 @@ const isSameDate = (first: Date, second: Date) => /* Increments usage count and updates usage date */ const handleDeveloperUsage = async (developer: DeveloperDocument) => { const updatedDeveloper = developer; - const isNewDay = !isSameDate(updatedDeveloper.usage.date, new Date()); - updatedDeveloper.usage.date = new Date(); + if (updatedDeveloper.usage) { + const isNewDay = !isSameDate(updatedDeveloper.usage.date || new Date(), new Date()); + updatedDeveloper.usage.date = new Date(); - if (isNewDay) { - updatedDeveloper.usage.count = 0; - } else { - updatedDeveloper.usage.count += 1; + if (isNewDay) { + updatedDeveloper.usage.count = 0; + } else { + updatedDeveloper.usage.count += 1; + } } return updatedDeveloper.save(); @@ -54,7 +56,7 @@ const validateApiKey: Express.MiddleWare = async (req, res, next) => { const developer = await findDeveloper(apiKey); if (developer) { - if (developer.usage.count >= determineLimit(apiLimit)) { + if (developer.usage!.count >= determineLimit(apiLimit)) { res.status(403); return res.send({ error: 'You have exceeded your limit of requests for the day' }); } diff --git a/src/routers/router.ts b/src/routers/router.ts index 4a1ea2e0..e5080f13 100644 --- a/src/routers/router.ts +++ b/src/routers/router.ts @@ -11,7 +11,8 @@ import validateApiKey from '../middleware/validateApiKey'; import validateAdminApiKey from '../middleware/validateAdminApiKey'; import attachRedisClient from '../middleware/attachRedisClient'; import analytics from '../middleware/analytics'; -import { login } from '../controllers/auth/login'; +import { login, logout } from '../controllers/auth'; +import { authenticate } from '../middleware/authenticate'; const router = express.Router(); @@ -35,5 +36,6 @@ router.get('/developers/account', attachRedisClient, getDeveloper); router.get('/stats', validateAdminApiKey, attachRedisClient, getStats); router.post('/login', login); +router.post('/logout', authenticate, logout); export default router; diff --git a/src/types/developer.ts b/src/types/developer.ts index 821cecc0..eaa777fc 100644 --- a/src/types/developer.ts +++ b/src/types/developer.ts @@ -5,12 +5,14 @@ export interface Developer { apiKey: string; email: string; password: string; - usage: { + usage?: { date: Date; count: number; }; + createdAt?: NativeDate; + updatedAt?: NativeDate; } export interface DeveloperDocument extends Developer, Document { - id: Types.ObjectId; + id?: Types.ObjectId; } diff --git a/src/types/express.ts b/src/types/express.ts index 61b27bed..3be6068d 100644 --- a/src/types/express.ts +++ b/src/types/express.ts @@ -1,7 +1,8 @@ import { Request as ExpressRequest, Response, NextFunction } from 'express'; import { RedisClientType } from 'redis'; +import { DeveloperDocument } from './developer'; -export type Query = { +export interface Query { dialects: string; examples: string; filter: string; @@ -13,12 +14,13 @@ export type Query = { tags: string; wordClasses: string; apiLimit: string; -}; +} export interface IgboAPIRequest extends ExpressRequest { query: Partial; isUsingMainKey?: boolean; redisClient?: RedisClientType; + developer?: DeveloperDocument; } export interface MiddleWare {