diff --git a/typescript/lunargame/api/.eslintrc.json b/typescript/lunargame/api/.eslintrc.json index aa811e8..0d4778c 100644 --- a/typescript/lunargame/api/.eslintrc.json +++ b/typescript/lunargame/api/.eslintrc.json @@ -20,6 +20,7 @@ }, "rules": { "@typescript-eslint/explicit-function-return-type": 0, - "@typescript-eslint/no-object-literal-type-assertion": 0 + "@typescript-eslint/no-object-literal-type-assertion": 0, + "@typescript-eslint/no-parameter-properties": 0 } } diff --git a/typescript/lunargame/api/.gitignore b/typescript/lunargame/api/.gitignore index 55d646f..5d42849 100644 --- a/typescript/lunargame/api/.gitignore +++ b/typescript/lunargame/api/.gitignore @@ -1,4 +1,5 @@ node_modules .env test/db.sqlite -db/db.sqlite \ No newline at end of file +db/db.sqlite +coverage \ No newline at end of file diff --git a/typescript/lunargame/api/jest.config.js b/typescript/lunargame/api/jest.config.js index dc44255..c4e5f08 100644 --- a/typescript/lunargame/api/jest.config.js +++ b/typescript/lunargame/api/jest.config.js @@ -5,5 +5,7 @@ module.exports = { }, testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - testEnvironment: 'node' + testEnvironment: 'node', + collectCoverage: true, + coverageReporters: ['json', 'html'] } diff --git a/typescript/lunargame/api/src/common/validation/middleware/validate.test.ts b/typescript/lunargame/api/src/common/validation/middleware/validate.test.ts new file mode 100644 index 0000000..bb92f22 --- /dev/null +++ b/typescript/lunargame/api/src/common/validation/middleware/validate.test.ts @@ -0,0 +1,62 @@ +import Joi from 'joi' +import { validate, validationField } from './validate' +import { Context } from 'koa' +import { fakeNext } from '../../../../test/utils/fakeNext' + +describe('The validate middleware', () => { + const schema = Joi.object({ + name: Joi.required() + }) + + const fields: validationField[] = ['body', 'params', 'query'] + + for (const field of fields) { + describe(`The request ${field} validator`, () => { + const middleware = validate(schema, field) + + const getContext = (name?: number) => { + if (field === 'body') { + return { + request: { + body: { + name + } + } + } as Context + } else { + return { + [field]: { + name + } + } as Context + } + } + + test('should throw an error if the validation fails', () => { + // arrange + const context = getContext() + + // act + const check = () => { + middleware(context, fakeNext()) + } + + // assert + expect(check).toThrow() + }) + + test('should call next if the validation passed', () => { + // arrange + const context = getContext(7) + + const next = jest.fn(fakeNext()) + + // act + middleware(context, next) + + // assert + expect(next).toBeCalled() + }) + }) + } +}) diff --git a/typescript/lunargame/api/src/common/validation/middleware/validate.ts b/typescript/lunargame/api/src/common/validation/middleware/validate.ts index 5089995..4554d10 100644 --- a/typescript/lunargame/api/src/common/validation/middleware/validate.ts +++ b/typescript/lunargame/api/src/common/validation/middleware/validate.ts @@ -2,6 +2,11 @@ import { ObjectSchema } from 'joi' import { Middleware } from 'koa' import { HttpError } from '../../../modules/network/classes/HttpError' +/** + * The field wich the validate validator can use + */ +export type validationField = 'params' | 'body' | 'query' + /** * Middlware to validate a joi schema against a request * @@ -10,15 +15,17 @@ import { HttpError } from '../../../modules/network/classes/HttpError' * * @throws HttpError if the validation fails */ -export const validate = ( - schema: ObjectSchema, - field: 'params' | 'body' | 'query' -): Middleware => async (context, next) => { - const result = schema.validate( - field === 'body' ? context.request.body : context[field] - ) +export const validate = (schema: ObjectSchema, field: validationField): Middleware => ( + context, + next +) => { + const result = schema.validate(field === 'body' ? context.request.body : context[field], { + abortEarly: true + }) - if (result.error) throw new HttpError(400, result.error.message) + if (result.error !== null) { + throw new HttpError(422, result.error.message) + } return next() } diff --git a/typescript/lunargame/api/src/modules/auth/helpers/checkPassword.test.ts b/typescript/lunargame/api/src/modules/auth/helpers/checkPassword.test.ts index 24ddc61..5358f6f 100644 --- a/typescript/lunargame/api/src/modules/auth/helpers/checkPassword.test.ts +++ b/typescript/lunargame/api/src/modules/auth/helpers/checkPassword.test.ts @@ -1,20 +1,59 @@ import { checkPassword } from './checkPassword' import { passwordEncryption } from '../types/passwordEncryption' +import { hash, genSalt } from 'bcryptjs' describe('The checkPassword helper', () => { const pass = 'this is a test password' - test("should throw an error if the encryption method doesn't exist", () => { - expect(() => { - checkPassword(pass, pass, '12212' as passwordEncryption) - }).toThrow() + test("should throw an error if the encryption method doesn't exist", async () => { + // arrange + const check = checkPassword(pass, pass, '12212' as passwordEncryption) + + // assert + await expect(check).rejects.toThrow() }) - test('should return true if the password matches the hash and the encryption = plain', () => { - expect(checkPassword(pass, pass, 'plain')).toBe(true) + describe("The 'plain' encryption", () => { + test('should return true if the password is correct', async () => { + // act + const check = await checkPassword(pass, pass, 'plain') + + // assert + expect(check).toBe(true) + }) + + test('shoud return false if the password is wrong', async () => { + // act + const check = await checkPassword(pass, pass + 'something', 'plain') + + // assert + expect(check).toBe(false) + }) }) - test('shoud return false if the password is wrong and the encryption = plain', () => { - expect(checkPassword(pass, pass + 'something', 'plain')).toBe(false) + describe("The 'bcrypt' encryption", () => { + let passwordHash: string + + beforeEach(async () => { + const salt = await genSalt(3) + + passwordHash = await hash(pass, salt) + }) + + test('should return true if the password is correct', async () => { + // act + const check = await checkPassword(passwordHash, pass, 'bcrypt') + + // assert + expect(check).toBe(true) + }) + + test('shoud return false if the password is wrong', async () => { + // act + const check = await checkPassword(passwordHash, pass + 'something', 'bcrypt') + + // assert + expect(check).toBe(false) + }) }) }) diff --git a/typescript/lunargame/api/src/modules/auth/helpers/checkPassword.ts b/typescript/lunargame/api/src/modules/auth/helpers/checkPassword.ts index bd85788..cdf589d 100644 --- a/typescript/lunargame/api/src/modules/auth/helpers/checkPassword.ts +++ b/typescript/lunargame/api/src/modules/auth/helpers/checkPassword.ts @@ -1,5 +1,6 @@ import { passwordEncryption } from '../types/passwordEncryption' import { HttpError } from '../../network/classes/HttpError' +import { compare } from 'bcryptjs' /** * Comparesa apssword with it's hash @@ -8,13 +9,15 @@ import { HttpError } from '../../network/classes/HttpError' * @param password The actual password * @param encryption The encription of the password */ -export const checkPassword = ( +export const checkPassword = async ( hash: string, password: string, encryption: passwordEncryption = 'plain' ) => { if (encryption === 'plain') { return hash === password + } else if (encryption === 'bcrypt') { + return await compare(password, hash) } else { throw new HttpError(400, `Encription ${encryption} doesn't exist`) } diff --git a/typescript/lunargame/api/src/modules/auth/helpers/encryptPassword.test.ts b/typescript/lunargame/api/src/modules/auth/helpers/encryptPassword.test.ts index c53cea4..33b380e 100644 --- a/typescript/lunargame/api/src/modules/auth/helpers/encryptPassword.test.ts +++ b/typescript/lunargame/api/src/modules/auth/helpers/encryptPassword.test.ts @@ -4,19 +4,25 @@ import { compare } from 'bcryptjs' describe('The encryptPassword helper', () => { test("should return the same password if the method is 'plain'", async () => { + // arrange const password = internet.password() + + // act const hash = await encryptPassword(password, 'plain') + // assert expect(hash).toBe(password) }) test("should return a mactching hash if the method is 'bcrypt'", async () => { + // arrange const password = internet.password() - - // the amount of rounds is small because this is just a test const hash = await encryptPassword(password, 'bcrypt', 3) + + // act const match = await compare(password, hash) + // assert expect(match).toBe(true) }) }) diff --git a/typescript/lunargame/api/src/modules/auth/middleware/isUnauthorized.test.ts b/typescript/lunargame/api/src/modules/auth/middleware/requireAnonymous.test.ts similarity index 51% rename from typescript/lunargame/api/src/modules/auth/middleware/isUnauthorized.test.ts rename to typescript/lunargame/api/src/modules/auth/middleware/requireAnonymous.test.ts index f332c5d..af4bb06 100644 --- a/typescript/lunargame/api/src/modules/auth/middleware/isUnauthorized.test.ts +++ b/typescript/lunargame/api/src/modules/auth/middleware/requireAnonymous.test.ts @@ -1,28 +1,35 @@ import { Context } from 'koa' -import { isUnauthorized } from './isUnauthorized' - -describe('The isUnauthorized middleware', () => { - const fakeNext = () => async () => {} +import { requireAnonymous } from './requireAnonymous' +import { fakeNext } from '../../../../test/utils/fakeNext' +describe('The requireAnonymous middleware', () => { test('should throw an error if the user is logged in', () => { + // act const fakeContext = ({ session: { uid: 7 } } as unknown) as Context - expect(() => isUnauthorized()(fakeContext, fakeNext())).toThrow() + // arrange + const runMiddleware = () => requireAnonymous()(fakeContext, fakeNext()) + + // assert + expect(runMiddleware).toThrow() }) test("should call next if the user isn't logged in", () => { + // arrange const fakeContext = { session: {} } as Context const next = jest.fn(fakeNext()) - isUnauthorized()(fakeContext, next) + // act + requireAnonymous()(fakeContext, next) + // assert expect(next).toBeCalled() }) }) diff --git a/typescript/lunargame/api/src/modules/auth/middleware/isUnauthorized.ts b/typescript/lunargame/api/src/modules/auth/middleware/requireAnonymous.ts similarity index 79% rename from typescript/lunargame/api/src/modules/auth/middleware/isUnauthorized.ts rename to typescript/lunargame/api/src/modules/auth/middleware/requireAnonymous.ts index 17d3021..c0be72b 100644 --- a/typescript/lunargame/api/src/modules/auth/middleware/isUnauthorized.ts +++ b/typescript/lunargame/api/src/modules/auth/middleware/requireAnonymous.ts @@ -4,7 +4,7 @@ import { HttpError } from '../../network/classes/HttpError' /** * Middleware wich throws an error if the user is logged in */ -export const isUnauthorized = (): Middleware => (context, next) => { +export const requireAnonymous = (): Middleware => (context, next) => { if (context.session.uid === undefined) { return next() } else { diff --git a/typescript/lunargame/api/src/modules/auth/middleware/loggedIn.test.ts b/typescript/lunargame/api/src/modules/auth/middleware/requireAuthenticated.test.ts similarity index 50% rename from typescript/lunargame/api/src/modules/auth/middleware/loggedIn.test.ts rename to typescript/lunargame/api/src/modules/auth/middleware/requireAuthenticated.test.ts index f9caf0f..727160a 100644 --- a/typescript/lunargame/api/src/modules/auth/middleware/loggedIn.test.ts +++ b/typescript/lunargame/api/src/modules/auth/middleware/requireAuthenticated.test.ts @@ -1,18 +1,23 @@ -import { isAuthorized } from './isAuthorized' +import { requireAuthenticated } from './requireAuthenticated' import { Context } from 'koa' +import { fakeNext } from '../../../../test/utils/fakeNext' -describe('The isAuthorized middleware', () => { - const fakeNext = () => async () => {} - +describe('The requireAuthenticated middleware', () => { test("should throw an error if the user isn't logged in", () => { + // arrange const fakeContext = { session: {} } as Context - expect(() => isAuthorized()(fakeContext, fakeNext())).toThrow() + // arrange + const runMiddleware = () => requireAuthenticated()(fakeContext, fakeNext()) + + // assert + expect(runMiddleware).toThrow() }) test('should call next if the user is logged in', () => { + // arrange const fakeContext = ({ session: { uid: Math.random() @@ -21,8 +26,10 @@ describe('The isAuthorized middleware', () => { const next = jest.fn(fakeNext()) - isAuthorized()(fakeContext, next) + // act + requireAuthenticated()(fakeContext, next) + // assert expect(next).toBeCalled() }) }) diff --git a/typescript/lunargame/api/src/modules/auth/middleware/isAuthorized.ts b/typescript/lunargame/api/src/modules/auth/middleware/requireAuthenticated.ts similarity index 79% rename from typescript/lunargame/api/src/modules/auth/middleware/isAuthorized.ts rename to typescript/lunargame/api/src/modules/auth/middleware/requireAuthenticated.ts index 497cf3d..bdb0b8b 100644 --- a/typescript/lunargame/api/src/modules/auth/middleware/isAuthorized.ts +++ b/typescript/lunargame/api/src/modules/auth/middleware/requireAuthenticated.ts @@ -4,7 +4,7 @@ import { HttpError } from '../../network/classes/HttpError' /** * Middlware wich throws an error if the user isn't logged in */ -export const isAuthorized = (): Middleware => (context, next) => { +export const requireAuthenticated = (): Middleware => (context, next) => { if (context.session.uid !== undefined) { return next() } else { diff --git a/typescript/lunargame/api/src/modules/auth/routes/authRoute.test.ts b/typescript/lunargame/api/src/modules/auth/routes/authRoute.test.ts index 99a8126..d0b8368 100644 --- a/typescript/lunargame/api/src/modules/auth/routes/authRoute.test.ts +++ b/typescript/lunargame/api/src/modules/auth/routes/authRoute.test.ts @@ -3,93 +3,110 @@ import { app } from '../../../server' import { loggedInAgent } from '../../../../test/utils/loggedInAgent' import { mockAccounts } from '../../../../test/seeds/01_create-account' import { random, internet } from 'faker' -import { LoginReponseBody } from '../types/LoginReponseBody' import { defaultEncryptionMethod } from '../constants' describe('The /auth route', () => { // used to make requests - let request = supertest(app.callback()) - - describe(`The POST method on the /login subroute`, () => { - test('should throw an error if the password field is empty', async () => { - const response = await request.post('/auth/login').send({ - name: mockAccounts[0].name - }) - - expect(response.status).not.toBe(200) - }) - - test('should throw an error if the name field is empty', async () => { - const response = await request.post('/auth/login').send({ - password: mockAccounts[0].password - }) - - expect(response.status).not.toBe(200) - }) - - test('should throw an error if the password is wrong', async () => { - const response = await request.post('/auth/login').send({ - name: mockAccounts[0].name, - password: mockAccounts[0].password + 'something' - }) - - expect(response.status).not.toBe(200) - }) - - test('should work just fine when the password is correct', async () => { - for (const account of mockAccounts) { - const response = await request.post('/auth/login').send({ - email: account.email, - password: account.password - }) - - // i'm making a separate constant for vsc to help me - const body: LoginReponseBody = response.body - - expect(response.status).toBe(200) - expect(body.uid).not.toBe(undefined) - expect(body.encryption).toBe(account.passwordEncryption) - } - }) - }) + const request = supertest(app.callback()) describe(`The GET method on the / subroute`, () => { test('should return undefined if the user was not logged in', async () => { + // act const res = await request.get('/auth') + // assert expect(res.body.uid).toBe(undefined) }) test('should return the uid form the session while logged in', async () => { + // arrange const [agent, cookie] = await loggedInAgent(supertest.agent(app.callback()), { email: mockAccounts[0].email, password: mockAccounts[0].password }) + // act const response = await agent.get('/auth').set('cookie', cookie) + // assert expect(response.body.uid).not.toBe(undefined) }) }) + describe(`The POST method on the /login subroute`, () => { + test('should throw an error if the user is already logged in', async () => { + // arrange + const [agent, cookie] = await loggedInAgent(supertest.agent(app.callback()), { + email: mockAccounts[0].email, + password: mockAccounts[0].password + }) + + // act + const reponse = await agent.post('/auth/login').set('cookie', cookie) + + // assert + expect(reponse.status).toBe(401) + }) + + test('should throw an error if the password is wrong', async () => { + // act + const response = await request.post('/auth/login').send({ + email: mockAccounts[0].email, + password: mockAccounts[0].password + 'something' + }) + + // assert + expect(response.status).toBe(422) + expect((response.body.message as string).startsWith('child')).toBe(false) // Not JOI + }) + + test("should throw an error if the user doesn't exist", async () => { + // act + const reponse = await request.post('/auth/login').send({ + email: 'idk' + mockAccounts[0].email, + password: mockAccounts[0].password + }) + + // assert + expect(reponse.status).toBe(404) + }) + + test('should work when the password is correct', async () => { + for (const account of mockAccounts) { + // act + const response = await request.post('/auth/login').send({ + email: account.email, + password: account.password + }) + + // assert + expect(response.status).toBe(200) + expect(response.body.uid).not.toBe(undefined) + expect(response.body.encryption).toBe(account.passwordEncryption) + } + }) + }) + describe('The POST method on the /signup subroute', () => { - test('should return the email name and the encrytion', async () => { - const username = random.alphaNumeric(7) + test('should work if all fields are correct', async () => { + // arrange + const name = internet.userName() const password = random.alphaNumeric(5) const email = internet.email() - const response = await request.post('/auth/signup').send({ - name: username, + const user = { + name, email, password - }) + } - // i'm making a separate constant for vsc to help me - const body: LoginReponseBody = response.body + // act + const response = await request.post('/auth/signup').send(user) + // assert expect(response.status).toBe(200) - expect(body.uid).not.toBe(undefined) - expect(body.encryption).toBe(defaultEncryptionMethod) + expect(response.body.uid).not.toBe(undefined) + expect(response.body.encryption).toBe(defaultEncryptionMethod) }) }) }) diff --git a/typescript/lunargame/api/src/modules/auth/routes/authRoute.ts b/typescript/lunargame/api/src/modules/auth/routes/authRoute.ts index 555403f..0b49320 100644 --- a/typescript/lunargame/api/src/modules/auth/routes/authRoute.ts +++ b/typescript/lunargame/api/src/modules/auth/routes/authRoute.ts @@ -8,7 +8,7 @@ import { encryptPassword } from '../helpers/encryptPassword' import { createAccount } from '../queries/createAccount' import { defaultEncryptionMethod } from '../constants' import { LoginBodySchema } from '../schemas/LoginBody' -import { isUnauthorized } from '../middleware/isUnauthorized' +import { requireAnonymous } from '../middleware/requireAnonymous' const router = new Router() @@ -22,30 +22,28 @@ router.get('/', (context, next) => { router.post( '/login', - isUnauthorized(), + requireAnonymous(), validate(LoginBodySchema, 'body'), async (context, next) => { const { email, password } = context.request.body - const passwordData = await getPasswordByEmail(email) // in case the user doesnt exist if (!passwordData) { - throw new HttpError(400) + throw new HttpError(404) } - const match = checkPassword( + const match = await checkPassword( passwordData.password, password, passwordData.passwordEncryption ) if (!match) { - throw new HttpError(400, 'wrong password') + throw new HttpError(422, 'wrong password') } context.session.uid = passwordData.id - context.body = { encryption: passwordData.passwordEncryption, uid: passwordData.id @@ -57,7 +55,7 @@ router.post( router.post( '/signup', - isUnauthorized(), + requireAnonymous(), validate(SignupBodySchema, 'body'), async (context, next) => { const { email, name, password } = context.request.body diff --git a/typescript/lunargame/api/src/modules/network/classes/HttpError.ts b/typescript/lunargame/api/src/modules/network/classes/HttpError.ts index 104177d..e8dfd03 100644 --- a/typescript/lunargame/api/src/modules/network/classes/HttpError.ts +++ b/typescript/lunargame/api/src/modules/network/classes/HttpError.ts @@ -11,6 +11,7 @@ export enum HttpStatus { Conflict = 409, Gone = 410, PayloadTooLarge = 413, + UnprocessableEntity = 422, TooManyRequests = 429, InternalServerError = 500 } @@ -24,6 +25,7 @@ export const HTTP_REASONS: Record = { '409': 'Conflict', '410': 'Gone', '413': 'Payload too large', + '422': 'Validation error', '429': 'Too many requests', '500': 'Internal server error' } @@ -34,7 +36,7 @@ export class HttpError extends Error { // for some reason instanceof stopped working at some point public [httpSymbol] = true - constructor( + public constructor( public status: HttpStatus = HttpStatus.InternalServerError, public reason?: string ) { diff --git a/typescript/lunargame/api/test/utils/fakeNext.ts b/typescript/lunargame/api/test/utils/fakeNext.ts new file mode 100644 index 0000000..9ea4b81 --- /dev/null +++ b/typescript/lunargame/api/test/utils/fakeNext.ts @@ -0,0 +1,4 @@ +/** + * Factory for a quick mock of the next function required to test middlewares + */ +export const fakeNext = () => async () => {}