diff --git a/package.json b/package.json index f9532ca..41cf92b 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,10 @@ "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,scss,html}\"", "format:check": "prettier --list-different \"src/**/*.{js,jsx,ts,tsx,css,scss,html}\"", "kill:port": "npx kill-port 8000", - "lint": "eslint **/*.ts", + "lint": "eslint --fix --ext .ts,.tsx,.js,.jsx .", "start": "NODE_ENV=production node dist/index.js", - "test": "jest", - "test:ci": "jest --ci --reporters=default --reporters=jest-junit", + "test": "jest --runInBand", + "test:ci": "jest --ci --reporters=default --reporters=jest-junit --runInBand", "tsc": "tsc --noEmit" }, "dependencies": { diff --git a/prisma/schema/schema.prisma b/prisma/schema/schema.prisma index 7df96a9..37e4e7b 100644 --- a/prisma/schema/schema.prisma +++ b/prisma/schema/schema.prisma @@ -6,7 +6,8 @@ generator client { provider = "prisma-client-js" - previewFeatures = ["prismaSchemaFolder"] + previewFeatures = ["prismaSchemaFolder", "metrics"] + } datasource db { diff --git a/src/controllers/__mocks__/user.ts b/src/controllers/__mocks__/user.ts index ec7219f..9c23d00 100644 --- a/src/controllers/__mocks__/user.ts +++ b/src/controllers/__mocks__/user.ts @@ -1,9 +1,19 @@ import { Role, User } from '@prisma/client'; -export const user: Omit = { +type UserWithoutPrismaKeys = Omit; + +export const user: UserWithoutPrismaKeys = { email: 'testuser@test.com', firstName: 'test', lastName: 'user', password: 'password', role: Role.USER, }; + +export const user2: UserWithoutPrismaKeys = { + email: 'marblestest@test.com', + firstName: 'marbles', + lastName: 'cat', + password: 'password12345', + role: Role.USER, +}; diff --git a/src/controllers/__tests__/authController.test.ts b/src/controllers/__tests__/authController.test.ts index 6ea39e1..8bbf7cf 100644 --- a/src/controllers/__tests__/authController.test.ts +++ b/src/controllers/__tests__/authController.test.ts @@ -1,5 +1,6 @@ import supertest from 'supertest'; import { v4 } from 'uuid'; +import { db } from '../../db/prisma'; import { LoginRequest, RegisterRequest } from '../../requests/auth'; import server from '../../server'; @@ -34,7 +35,10 @@ describe('auth', () => { lastName: 'test', password: 'password', }; - await supertest(app).post('/api/auth/register').send(user); + + await db.user.create({ + data: user, + }); const { body, statusCode } = await supertest(app) .post('/api/auth/register') @@ -171,7 +175,6 @@ describe('auth', () => { password: 'password', }; await supertest(app).post('/api/auth/register').send(user); - await supertest(app).post('/api/auth/login').send({ email: user.email, password: user.password, diff --git a/src/controllers/__tests__/petController.test.ts b/src/controllers/__tests__/petController.test.ts index 2e28233..610456f 100644 --- a/src/controllers/__tests__/petController.test.ts +++ b/src/controllers/__tests__/petController.test.ts @@ -2,7 +2,7 @@ import supertest from 'supertest'; import { db } from '../../db/prisma'; import server from '../../server'; import { pets } from '../__mocks__/pet'; -import { user } from '../__mocks__/user'; +import { user, user2 } from '../__mocks__/user'; describe('pet', () => { const app = server.init(); @@ -68,7 +68,224 @@ describe('pet', () => { expect(statusCode).toBe(200); }); - // describe('createPet', () => { - // test('', () => {}); - // }); + describe('createPet', () => { + test('unauthenticated user cannot create pet', async () => { + const userCookie = ''; + + const { statusCode, body } = await supertest(app) + .post('/api/pets') + .set('Cookie', userCookie) + .send(pets[0]); + + expect(statusCode).toEqual(401); + + expect(body).toEqual({ + code: 'Forbidden', + errors: [], + message: 'You are not authorized to perform this action', + statusCode: 401, + title: 'Forbidden', + type: 'Forbidden', + }); + }); + + test('authenticated user can create pet', async () => { + await supertest(app).post('/api/auth/register').send(user); + + const { headers, statusCode } = await supertest(app) + .post('/api/auth/login') + .send({ + email: user.email, + password: user.password, + }); + + expect(statusCode).toBe(200); + + const cookieValue = headers['set-cookie'][0].split(';')[0].split('=')[1]; + const cookieName = 'connect.sid'; + + const { body, statusCode: petStatusCode } = await supertest(app) + .post('/api/pets') + .set('Cookie', `${cookieName}=${cookieValue}`) + .send(pets[0]); + + expect(petStatusCode).toBe(201); + expect(body).toEqual({ + ...pets[0], + id: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), + creatorId: expect.any(String), + }); + }); + }); + + describe('updatePet', () => { + test('authenticated user can update their pet', async () => { + await supertest(app).post('/api/auth/register').send(user); + + const { headers, statusCode } = await supertest(app) + .post('/api/auth/login') + .send({ + email: user.email, + password: user.password, + }); + + expect(statusCode).toBe(200); + + const cookieValue = headers['set-cookie'][0].split(';')[0].split('=')[1]; + const cookieName = 'connect.sid'; + + const { body: createdPet } = await supertest(app) + .post('/api/pets') + .set('Cookie', `${cookieName}=${cookieValue}`) + .send(pets[0]); + + const { statusCode: updatedPetStatusCode, body: updatedPetBody } = + await supertest(app) + .put(`/api/pets/${createdPet.id}`) + .set('Cookie', `${cookieName}=${cookieValue}`) + .send({ + ...pets[0], + name: 'Marbles', + }); + + expect(updatedPetStatusCode).toBe(200); + expect(updatedPetBody).toEqual({ + ...pets[0], + id: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), + creatorId: createdPet.creatorId, + name: 'Marbles', + }); + }); + + test("authenticated user cannot update another user's pet", async () => { + // user 1 + await supertest(app).post('/api/auth/register').send(user); + + const { headers, statusCode } = await supertest(app) + .post('/api/auth/login') + .send({ + email: user.email, + password: user.password, + }); + + expect(statusCode).toBe(200); + + const cookieValue = headers['set-cookie'][0].split(';')[0].split('=')[1]; + const cookieName = 'connect.sid'; + + // create a pet authenticated as user1 + const { body: createdPet } = await supertest(app) + .post('/api/pets') + .set('Cookie', `${cookieName}=${cookieValue}`) + .send(pets[0]); + + // user 2 + await supertest(app).post('/api/auth/register').send(user2); + + const { headers: headers2, statusCode: statusCode2 } = await supertest( + app, + ) + .post('/api/auth/login') + .send({ + email: user2.email, + password: user2.password, + }); + expect(statusCode2).toBe(200); + + const cookieValue2 = headers2['set-cookie'][0] + .split(';')[0] + .split('=')[1]; + + const { statusCode: updatedStatusCode, body: updatedBody } = + await supertest(app) + .put(`/api/pets/${createdPet.id}`) + .set('Cookie', `${cookieName}=${cookieValue2}`) + .send({ + ...pets[0], + name: `Updated by ${user2.firstName}`, + }); + + expect(updatedStatusCode).toBe(401); + expect(updatedBody).toEqual({ + code: 'forbidden', + errors: [], + message: 'You are not authorized to perform this action', + statusCode: 401, + title: 'You are not authorized to perform this action', + type: 'Forbidden', + }); + }); + }); + + describe('deletePet', () => { + test('unautheticated user cannot delete pet', async () => { + await supertest(app).post('/api/auth/register').send(user); + + const { headers, statusCode } = await supertest(app) + .post('/api/auth/login') + .send({ + email: user.email, + password: user.password, + }); + + expect(statusCode).toBe(200); + + const cookieValue = headers['set-cookie'][0].split(';')[0].split('=')[1]; + const cookieName = 'connect.sid'; + + // create a pet authenticated as user1 + const { body: createdPet } = await supertest(app) + .post('/api/pets') + .set('Cookie', `${cookieName}=${cookieValue}`) + .send(pets[0]); + + const { body, statusCode: deleteStatusCode } = await supertest( + app, + ).delete(`/api/pets/${createdPet.id}`); + + expect(deleteStatusCode).toBe(401); + + expect(body).toEqual({ + code: 'Forbidden', + errors: [], + message: 'You are not authorized to perform this action', + statusCode: 401, + title: 'Forbidden', + type: 'Forbidden', + }); + }); + + test('authenticated user can delete their own pet', async () => { + await supertest(app).post('/api/auth/register').send(user); + + const { headers, statusCode } = await supertest(app) + .post('/api/auth/login') + .send({ + email: user.email, + password: user.password, + }); + + expect(statusCode).toBe(200); + + const cookieValue = headers['set-cookie'][0].split(';')[0].split('=')[1]; + const cookieName = 'connect.sid'; + + // create a pet authenticated as user1 + const { body: createdPet } = await supertest(app) + .post('/api/pets') + .set('Cookie', `${cookieName}=${cookieValue}`) + .send(pets[0]); + + const { body, statusCode: deleteStatusCode } = await supertest(app) + .delete(`/api/pets/${createdPet.id}`) + .set('Cookie', `${cookieName}=${cookieValue}`); + + expect(deleteStatusCode).toBe(200); + expect(body).toEqual(''); + }); + }); }); diff --git a/src/controllers/petController.ts b/src/controllers/petController.ts index 47f8560..afd3121 100644 --- a/src/controllers/petController.ts +++ b/src/controllers/petController.ts @@ -31,21 +31,20 @@ export default class PetController { statusCode: 404, }); } - return res.status(200).json(pet); } async createPet(req: CreatePetRequest, res: Response) { - const pet = req.body; - const newPet = await this.petService.createPet(pet, req.session.userId); - + const newPet = await this.petService.createPet( + req.body, + req.session.userId, + ); return res.status(201).json(newPet); } async updatePet(req: UpdatePetRequest, res: Response) { const pet = req.body; const updatedPet = await this.petService.updatePet(req.params.id, pet); - return res.status(200).json(updatedPet); } diff --git a/src/errors/ForbiddenError.ts b/src/errors/ForbiddenError.ts new file mode 100644 index 0000000..1caa76c --- /dev/null +++ b/src/errors/ForbiddenError.ts @@ -0,0 +1,15 @@ +import ApiError from './ApiError'; + +export default class ForbiddenError extends ApiError { + constructor(err?: ApiError) { + super({ + title: err?.title ?? 'You are not authorized to perform this action', + type: 'Forbidden', + statusCode: 401, + code: err?.code ?? 'Forbidden', + message: err?.message ?? 'You are not authorized to perform this action', + errors: err?.errors ?? [], + stack: process.env.NODE_ENV === 'development' ? err?.stack : undefined, + }); + } +} diff --git a/src/errors/errorHandler.ts b/src/errors/errorHandler.ts index 98370be..4214be5 100644 --- a/src/errors/errorHandler.ts +++ b/src/errors/errorHandler.ts @@ -2,6 +2,7 @@ import { ErrorRequestHandler } from 'express'; import logger from '../utils/logger'; import ApiError from './ApiError'; import BadRequestError from './BadRequestError'; +import ForbiddenError from './ForbiddenError'; import InternalServerError from './InternalServerError'; import NotFoundError from './NotFoundError'; @@ -39,6 +40,16 @@ const errorHandler: ErrorRequestHandler<{}, ApiError | string> = ( }); } + if (err instanceof ForbiddenError) { + return new ApiError({ + title: 'You are not authorized to perform this action', + statusCode: 401, + message: 'You are not authorized to perform this action', + type: 'Forbidden', + code: 'Forbidden', + }); + } + logger.error(`An unexpected error occurred: err -> ${err}`); return new InternalServerError(); diff --git a/src/errors/pet/index.ts b/src/errors/pet/index.ts new file mode 100644 index 0000000..2aa6f52 --- /dev/null +++ b/src/errors/pet/index.ts @@ -0,0 +1,7 @@ +export const petErrorCodes = { + PetNotFound: 'PetNotFound', + PetsNotFound: 'PetsNotFound', + PetUpdateNotAuthorised: 'PetUpdateNotAuthorised', +} as const; + +export type PetErrorCode = (typeof petErrorCodes)[keyof typeof petErrorCodes]; diff --git a/src/middleware/isAuth.ts b/src/middleware/isAuth.ts index 4f9c5f6..b26af64 100644 --- a/src/middleware/isAuth.ts +++ b/src/middleware/isAuth.ts @@ -1,9 +1,16 @@ import { NextFunction, Request, Response } from 'express'; +import ForbiddenError from '../errors/ForbiddenError'; const isAuth = () => { return (req: Request, res: Response, next: NextFunction) => { if (!req.session.userId) { - return res.status(401).json({ message: 'Unauthorized' }); + throw new ForbiddenError({ + message: 'You are not authorized to perform this action', + code: 'Forbidden', + statusCode: 401, + title: 'Forbidden', + errors: [], + }); } next(); // eslint-disable-next-line consistent-return, no-useless-return diff --git a/src/middleware/isPetOwner.ts b/src/middleware/isPetOwner.ts index 5de8acd..4126831 100644 --- a/src/middleware/isPetOwner.ts +++ b/src/middleware/isPetOwner.ts @@ -1,50 +1,46 @@ -/** - * Middleware to validate if the user is the owner of the pet - */ - -import { Pet } from '@prisma/client'; import { NextFunction, Request, Response } from 'express'; import { db } from '../db/prisma'; -import NotFoundError from '../errors/NotFoundError'; +import { petErrorCodes } from '../errors/pet'; import logger from '../utils/logger'; -const isPetOwner = (pet: Pet, userId: string) => { - return async (req: Request, res: Response, next: NextFunction) => { +/** + * Middleware to validate if the user is the owner of the pet + */ +const isPetOwner = () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + // eslint-disable-next-line consistent-return + return async (req: TRequest, res: Response, next: NextFunction) => { try { - const p = await db.pet.findFirst({ + const pet = await db.pet.findFirst({ where: { - id: pet.id, + id: req.params.id, }, }); - if (!p) { - throw new NotFoundError({ - message: 'Pet not found', - code: 'not_found', - statusCode: 404, - title: 'Pet not found', - errors: [], - }); + if (!pet) { + return false; } - if (p.creatorId !== userId) { - return res.status(403).json({ - status: 'error', + if (pet.creatorId !== req.session.userId) { + logger.info( + `${petErrorCodes.PetUpdateNotAuthorised} triggered. User ${req.session.userId} is not authorised to perform operations on ${pet.id}`, + ); + + return res.status(401).json({ + code: 'forbidden', message: 'You are not authorized to perform this action', + statusCode: 401, + title: 'You are not authorized to perform this action', + type: 'Forbidden', errors: [], }); } - logger.info('[isPetOwner] User is the owner of the pet'); + logger.info('[isPetOwner]: validation passed'); next(); - // eslint-disable-next-line consistent-return, no-useless-return - return; - } catch (e) { - return res.status(403).json({ - status: 'error', - message: 'You are not authorized to perform this action', - errors: [], - }); + } catch (error) { + logger.warn('isPetOwner exception caught', error); } }; }; diff --git a/src/routes/health/index.ts b/src/routes/health/index.ts index d57419b..436fa75 100644 --- a/src/routes/health/index.ts +++ b/src/routes/health/index.ts @@ -15,5 +15,9 @@ export default class HealthRoutes { this.app.get('/api/healthcheck', (req, res) => { return this.healthController.health(req, res); }); + + this.app.head('/', (req, res) => { + return res.status(200).send(); + }); } } diff --git a/src/routes/pet/index.ts b/src/routes/pet/index.ts index a423aa2..4d744a6 100644 --- a/src/routes/pet/index.ts +++ b/src/routes/pet/index.ts @@ -51,17 +51,18 @@ export default class PetRoutes { '/api/pets/:id', isAuth(), validateResource(updatePetSchema), - (req: UpdatePetRequest, res: Response) => { - isPetOwner(req.body, req.session.user.id); + isPetOwner(), + async (req: UpdatePetRequest, res: Response) => { return this.petController.updatePet(req, res); }, ); this.app.delete( '/api/pets/:id', + isAuth(), validateResource(deletePetSchema), - (req: DeletePetReqeust, res: Response) => { - isPetOwner(req.body, req.session.user.id); + isPetOwner(), + async (req: DeletePetReqeust, res: Response) => { return this.petController.deletePet(req, res); }, ); diff --git a/src/server.ts b/src/server.ts index 704e0bf..15ecddd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -40,12 +40,6 @@ class CreateServer { }); } - private handleHead() { - this.app.head('/', (req, res) => { - return res.status(200).send(); - }); - } - public init() { // *order is important* @@ -86,7 +80,6 @@ class CreateServer { path: '/', signed: this.isProduction(), sameSite: 'lax', - priority: 'high', }, }), ); @@ -97,9 +90,6 @@ class CreateServer { // global 404 this.handleNotFound(); - // head - this.handleHead(); - // global error handler this.app.use(errorHandler); diff --git a/src/services/__tests__/authService.test.ts b/src/services/__tests__/authService.test.ts index faeb88d..7e08171 100644 --- a/src/services/__tests__/authService.test.ts +++ b/src/services/__tests__/authService.test.ts @@ -35,6 +35,7 @@ describe('AuthService', () => { ...user, }, }); + await authService.register(user); const result = await authService.register(user); expect(result).toEqual(authErrorCodes.EmailAlreadyExists); }); diff --git a/src/services/petService.ts b/src/services/petService.ts index 69d4c36..43b79c1 100644 --- a/src/services/petService.ts +++ b/src/services/petService.ts @@ -30,7 +30,6 @@ export default class PetService { creatorId: userId, }, }); - return newPet; } diff --git a/src/test/setupGlobals.js b/src/test/setupGlobals.js index 706d683..e7d3da9 100644 --- a/src/test/setupGlobals.js +++ b/src/test/setupGlobals.js @@ -1,6 +1,6 @@ import { db } from '../db/prisma'; -jest.setTimeout(10000); +jest.setTimeout(30000); beforeAll(async () => { await db.$connect();