typescript(lunargame/api): overall better tests (except for the validate ones)
Signed-off-by: prescientmoon <git@moonythm.dev>
This commit is contained in:
parent
ffe0ad26ea
commit
48edeb1093
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
3
typescript/lunargame/api/.gitignore
vendored
3
typescript/lunargame/api/.gitignore
vendored
|
@ -1,4 +1,5 @@
|
|||
node_modules
|
||||
.env
|
||||
test/db.sqlite
|
||||
db/db.sqlite
|
||||
db/db.sqlite
|
||||
coverage
|
|
@ -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']
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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`)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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 {
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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 {
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<HttpStatus, string> = {
|
|||
'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
|
||||
) {
|
||||
|
|
4
typescript/lunargame/api/test/utils/fakeNext.ts
Normal file
4
typescript/lunargame/api/test/utils/fakeNext.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* Factory for a quick mock of the next function required to test middlewares
|
||||
*/
|
||||
export const fakeNext = () => async () => {}
|
Loading…
Reference in a new issue