From 3247ca53157dd6cce92d05b1d901251704012b4e Mon Sep 17 00:00:00 2001 From: Collin Duncan <3679940+cgduncan7@users.noreply.github.com> Date: Sun, 23 Oct 2022 11:55:47 +0200 Subject: [PATCH] Lots of changes to project structure First integration of requester and scheduler workers Added password hashing functions Adding initial database integration --- docker/docker-compose.yml | 7 + src/common/database/index.ts | 75 +++++++ src/common/database/sql.ts | 11 ++ src/common/password.ts | 52 +++++ src/common/request.ts | 78 ++------ src/common/reservation.ts | 152 ++++++++++++++- src/common/runner.ts | 36 ++-- src/common/schedule.ts | 17 -- src/local.ts | 14 +- src/workers/requester/index.ts | 87 ++++++++- src/workers/scheduler/index.ts | 48 ++--- tests/common/logger.test.ts | 27 ++- tests/common/password.test.ts | 27 +++ tests/common/request.test.ts | 183 +++++------------- tests/common/reservation.test.ts | 53 ++++- tests/common/schedule.test.ts | 13 -- .../__snapshots__/scheduler.test.ts.snap | 138 +++++++++++++ tests/workers/scheduler.test.ts | 80 +++++--- 18 files changed, 775 insertions(+), 323 deletions(-) create mode 100644 docker/docker-compose.yml create mode 100644 src/common/database/index.ts create mode 100644 src/common/database/sql.ts create mode 100644 src/common/password.ts delete mode 100644 src/common/schedule.ts create mode 100644 tests/common/password.test.ts delete mode 100644 tests/common/schedule.test.ts create mode 100644 tests/workers/__snapshots__/scheduler.test.ts.snap diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..f36393b --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,7 @@ +services: + database: + image: mysql:latest + restart: always + env_file: ./database/.env + ports: + - 3306:3306 diff --git a/src/common/database/index.ts b/src/common/database/index.ts new file mode 100644 index 0000000..27f08e3 --- /dev/null +++ b/src/common/database/index.ts @@ -0,0 +1,75 @@ +import mysql, { Connection, ConnectionConfig, FieldInfo } from 'mysql' +import { readFile } from 'fs/promises' +import { resolve } from 'path' +import { TABLE_reservations } from './sql' + +const createConnectionConfig = async (): Promise => { + const user = await readFile(resolve('.', './secrets/dbUser')) + const password = await readFile(resolve('.', './secrets/dbPassword')) + return { + user: user.toString(), + password: password.toString(), + database: 'autobaan', + } +} + +let connection: Connection + +export const getConnection = async (): Promise => { + if (!connection) { + const config = await createConnectionConfig() + connection = mysql.createConnection(config) + } + return connection +} + +export type ResultSet = T[] + +export const connect = async () => { + return new Promise((res, rej) => + getConnection().then((cn) => { + cn.connect((err) => { + if (err) { + rej(err) + } + res() + }) + }) + ) +} + +export const disconnect = async () => { + return new Promise((res, rej) => + getConnection().then((cn) => { + cn.end((err) => { + if (err) { + rej(err) + } + res() + }) + }) + ) +} + +export const query = async ( + sql: string, + values?: V +): Promise<{ results: ResultSet; fields?: FieldInfo[] }> => { + return new Promise((res, rej) => { + connection.query({ sql, values }, (err, results, fields) => { + if (err) { + rej(err) + } + res({ results, fields }) + }) + }) +} + +export const init = async () => { + try { + await connect() + await query(TABLE_reservations) + } catch (err: any) { + console.error(err) + } +} diff --git a/src/common/database/sql.ts b/src/common/database/sql.ts new file mode 100644 index 0000000..ac641ed --- /dev/null +++ b/src/common/database/sql.ts @@ -0,0 +1,11 @@ +export const TABLE_reservations = ` +CREATE TABLE reservations ( + id INT unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT, + username VARCHAR(64) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL + date_range_start DATETIME NOT NULL, + date_range_end DATETIME NOT NULL, + opponent_id VARCHAR(32) NOT NULL, + opponent_name VARCHAR(255) NOT NULL +); +` diff --git a/src/common/password.ts b/src/common/password.ts new file mode 100644 index 0000000..e918fa7 --- /dev/null +++ b/src/common/password.ts @@ -0,0 +1,52 @@ +import argon2 from 'argon2' +import crypto from 'crypto' +import { Logger } from './logger' + +const SALT_LENGTH = Number.parseInt(process.env.SALT_LENGTH || '32', 10) + +const randomFillPromise = (buffer: Buffer) => { + return new Promise((res, rej) => { + crypto.randomFill(buffer, (err, buff) => { + if (err) { + rej(err) + } + res(buff) + }) + }) +} + +export const generateSalt = async () => { + const saltBuffer = Buffer.alloc(SALT_LENGTH) + return randomFillPromise(saltBuffer) +} + +export const generateHash = async (password: string, saltBuffer: Buffer) => { + const hashOptions: argon2.Options & { raw: false } = { + hashLength: 32, + parallelism: 1, + memoryCost: 1 << 14, + timeCost: 2, + type: argon2.argon2id, + salt: saltBuffer, + saltLength: saltBuffer.length, + raw: false, + } + + const hash = await argon2.hash(password, hashOptions) + return hash +} + +export const hashPassword = async (password: string) => { + try { + const saltBuffer = await generateSalt() + const hash = await generateHash(password, saltBuffer) + return hash + } catch (err: any) { + Logger.error('Error hashing and salting password', { message: err.message }) + throw err + } +} + +export const verifyPassword = async (hash: string, password: string) => { + return await argon2.verify(hash, password) +} diff --git a/src/common/request.ts b/src/common/request.ts index 583ccd4..068e4df 100644 --- a/src/common/request.ts +++ b/src/common/request.ts @@ -1,15 +1,9 @@ import dayjs from 'dayjs' import isSameOrBefore from 'dayjs/plugin/isSameOrBefore' +import { hashPassword } from './password' dayjs.extend(isSameOrBefore) -import { DateRange, Opponent } from './reservation' - -export interface ReservationRequest extends Record { - username: string - password: string - dateRange: DateRange - opponent: Opponent -} +import { DateRange, Opponent, Reservation } from './reservation' export enum ValidationErrorCode { UNDEFINED_REQUEST_BODY, @@ -28,30 +22,19 @@ export class ValidationError extends Error { this.code = code } } -/** - * Validates an incoming request body and converts to ReservationRequest - * @param body String of request body - * @returns ReservationRequest - */ -export const validateStringRequest = (body: string): ReservationRequest => { - const request = validateRequestBody(body) - validateRequestDateRange(request.dateRange) - validateRequestOpponent(request.opponent) - return request -} -export const validateJSONRequest = ( +export const validateJSONRequest = async ( body: Record -): ReservationRequest => { - const request = validateRequestBody(body) +): Promise => { + const request = await validateRequestBody(body) validateRequestDateRange(request.dateRange) validateRequestOpponent(request.opponent) return request } -const validateRequestBody = ( - body?: string | Record -): ReservationRequest => { +const validateRequestBody = async ( + body?: Record +): Promise => { if (body === undefined) { throw new ValidationError( 'Invalid request', @@ -59,22 +42,7 @@ const validateRequestBody = ( ) } - let jsonBody: ReservationRequest - if (typeof body === 'string') { - jsonBody = transformRequestBody(body) - } else { - const { username, password, dateRange, opponent } = body - jsonBody = { - username: username as string, - password: password as string, - dateRange: convertDateRangeStringToObject( - dateRange as { start: string; end: string } - ), - opponent: opponent as Opponent, - } - } - - const { username, password, opponent, dateRange } = jsonBody + const { username, password, dateRange, opponent }: Record = body if ( !username || @@ -93,28 +61,14 @@ const validateRequestBody = ( ) } - return jsonBody -} + const hashedPassword = await hashPassword(password) + const reservation = new Reservation( + { username, password: hashedPassword }, + convertDateRangeStringToObject(dateRange), + opponent + ) -const transformRequestBody = (body: string): ReservationRequest => { - let json - try { - json = JSON.parse(body) - } catch (err) { - throw new ValidationError( - 'Invalid request', - ValidationErrorCode.INVALID_JSON - ) - } - const start = json.dateRange?.start ?? 'invalid' - const end = json.dateRange?.end ?? 'invalid' - const dateRange: DateRange = convertDateRangeStringToObject({ start, end }) - return { - username: json.username, - password: json.password, - dateRange, - opponent: json.opponent, - } + return reservation } const convertDateRangeStringToObject = ({ diff --git a/src/common/reservation.ts b/src/common/reservation.ts index 038b5b7..cd0cdc5 100644 --- a/src/common/reservation.ts +++ b/src/common/reservation.ts @@ -1,7 +1,13 @@ import dayjs, { Dayjs } from 'dayjs' import isSameOrBefore from 'dayjs/plugin/isSameOrBefore' +import { query } from './database' dayjs.extend(isSameOrBefore) +export interface User { + username: string + password: string +} + export interface Opponent { id: string name: string @@ -15,15 +21,22 @@ export interface DateRange { const RESERVATION_AVAILABLE_WITHIN_DAYS = 7 export class Reservation { + public readonly user: User public readonly dateRange: DateRange public readonly opponent: Opponent public readonly possibleDates: Dayjs[] public booked = false - constructor(dateRange: DateRange, opponent: Opponent) { + constructor( + user: User, + dateRange: DateRange, + opponent: Opponent, + possibleDates?: Dayjs[] + ) { + this.user = user this.dateRange = dateRange this.opponent = opponent - this.possibleDates = this.createPossibleDates() + this.possibleDates = possibleDates || this.createPossibleDates() } private createPossibleDates(): Dayjs[] { @@ -51,15 +64,146 @@ export class Reservation { ) } - public format(): unknown { + public getAllowedReservationDate(): Dayjs { + return this.dateRange.start + .hour(0) + .minute(0) + .second(0) + .millisecond(0) + .subtract(RESERVATION_AVAILABLE_WITHIN_DAYS, 'days') + } + + public toString() { + return JSON.stringify(this.format()) + } + + public format() { return { + user: { + username: this.user.username, + password: this.user.password ? '?' : null, + }, opponent: this.opponent, booked: this.booked, possibleDates: this.possibleDates.map((date) => date.format()), dateRange: { start: this.dateRange.start.format(), end: this.dateRange.end.format(), - } + }, } } + + public serializeToJson(): SerializedReservation { + return { + user: this.user, + opponent: this.opponent, + booked: this.booked, + possibleDates: this.possibleDates.map((date) => date.format()), + dateRange: { + start: this.dateRange.start.format(), + end: this.dateRange.end.format(), + }, + } + } + + public static deserializeFromJson( + serializedData: SerializedReservation + ): Reservation { + const start = dayjs(serializedData.dateRange.start) + const end = dayjs(serializedData.dateRange.end) + return new Reservation( + serializedData.user, + { start, end }, + serializedData.opponent, + Reservation.deserializePossibleDates(serializedData.possibleDates) + ) + } + + public static deserializePossibleDates(dates: string[]): Dayjs[] { + return dates.map((date) => dayjs(date)) + } + + public static async save(res: Reservation) { + await query( + ` + INSERT INTO reservations + ( + username, + password, + date_range_start, + date_range_end, + opponent_id, + opponent_name + ) + VALUES + ( + ?, + ?, + ?, + ?, + ?, + ? + ) + `, + [ + res.user.username, + res.user.password, + res.dateRange.start, + res.dateRange.end, + res.opponent.id, + res.opponent.name, + ] + ) + } + + public static async fetch(id: number): Promise { + const response = await query( + ` + SELECT * + FROM reservations + WHERE id = ? + `, + [id] + ) + + if (response.results.length === 1) { + const sqlReservation = response.results[0] + const res = new Reservation( + { + username: sqlReservation.username, + password: sqlReservation.password, + }, + { + start: dayjs(sqlReservation.date_range_start), + end: dayjs(sqlReservation.date_range_end), + }, + { id: sqlReservation.opponent_id, name: sqlReservation.opponent_name } + ) + return res + } + + return null + } +} + +export interface SerializedDateRange { + start: string + end: string +} + +export interface SerializedReservation { + user: User + opponent: Opponent + booked: boolean + possibleDates: string[] + dateRange: SerializedDateRange +} + +export interface SqlReservation { + username: string + password: string + date_range_start: string + date_range_end: string + opponent_id: string + opponent_name: string } diff --git a/src/common/runner.ts b/src/common/runner.ts index a036014..6a1a661 100644 --- a/src/common/runner.ts +++ b/src/common/runner.ts @@ -33,7 +33,7 @@ export class Runner { BrowserLaunchArgumentOptions & BrowserConnectOptions ): Promise { - Logger.debug('Launching browser'); + Logger.debug('Launching browser') this.browser = await puppeteer.launch(options) this.page = await this.browser?.newPage() await this.login() @@ -41,7 +41,7 @@ export class Runner { } private async login() { - Logger.debug('Logging in'); + Logger.debug('Logging in') await this.page?.goto('https://squashcity.baanreserveren.nl/') await this.page ?.waitForSelector('input[name=username]') @@ -54,7 +54,7 @@ export class Runner { private async makeReservations(): Promise { for (let i = 0; i < this.reservations.length; i++) { - Logger.debug('Making reservation', this.reservations[i].format()); + Logger.debug('Making reservation', this.reservations[i].format()) await this.makeReservation(this.reservations[i]) } @@ -69,31 +69,37 @@ export class Runner { await this.confirmReservation() reservation.booked = true } catch (err) { - Logger.error('Error making reservation', reservation.format()); + Logger.error('Error making reservation', reservation.format()) } } private getLastVisibleDay(): Dayjs { - const lastDayOfMonth = dayjs().add(1, 'month').set('date', 0); - let daysToAdd = 0; + const lastDayOfMonth = dayjs().add(1, 'month').set('date', 0) + let daysToAdd = 0 switch (lastDayOfMonth.day()) { - case 0: daysToAdd = 0; break; - default: daysToAdd = 7 - lastDayOfMonth.day(); break; + case 0: + daysToAdd = 0 + break + default: + daysToAdd = 7 - lastDayOfMonth.day() + break } - return lastDayOfMonth.add(daysToAdd, 'day'); + return lastDayOfMonth.add(daysToAdd, 'day') } private async navigateToDay(date: Dayjs): Promise { - Logger.debug(`Navigating to ${date.format()}`); + Logger.debug(`Navigating to ${date.format()}`) if (this.getLastVisibleDay().isBefore(date)) { - Logger.debug('Date is on different page, increase month'); - await this.page?.waitForSelector('td.month.next').then((d) => d?.click()); + Logger.debug('Date is on different page, increase month') + await this.page?.waitForSelector('td.month.next').then((d) => d?.click()) } await this.page ?.waitForSelector( - `td#cal_${date.get('year')}_${date.get('month') + 1}_${date.get('date')}` + `td#cal_${date.get('year')}_${date.get('month') + 1}_${date.get( + 'date' + )}` ) .then((d) => d?.click()) await this.page?.waitForSelector( @@ -104,7 +110,7 @@ export class Runner { } private async selectAvailableTime(res: Reservation): Promise { - Logger.debug('Selecting available time', res.format()); + Logger.debug('Selecting available time', res.format()) let freeCourt: ElementHandle | null | undefined let i = 0 while (i < res.possibleDates.length && !freeCourt) { @@ -124,7 +130,7 @@ export class Runner { } private async selectOpponent(opponent: Opponent): Promise { - Logger.debug('Selecting opponent', opponent); + Logger.debug('Selecting opponent', opponent) const player2Search = await this.page?.waitForSelector( 'tr.res-make-player-2 > td > input' ) diff --git a/src/common/schedule.ts b/src/common/schedule.ts deleted file mode 100644 index c10c3a6..0000000 --- a/src/common/schedule.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Dayjs } from 'dayjs' - -/** - * - * @param requestedDate - * @returns - */ -export const scheduleDateToRequestReservation = ( - requestedDate: Dayjs -): Dayjs => { - return requestedDate - .hour(0) - .minute(0) - .second(0) - .millisecond(0) - .subtract(7, 'days') -} diff --git a/src/local.ts b/src/local.ts index 0a1a1de..d9671fe 100644 --- a/src/local.ts +++ b/src/local.ts @@ -6,9 +6,9 @@ import { Reservation } from './common/reservation' import { Runner } from './common/runner' const run = async (request: ReservationRequest) => { - Logger.instantiate('local', v4(), LogLevel.DEBUG); + Logger.instantiate('local', v4(), LogLevel.DEBUG) const { username, password, dateRange, opponent } = request - const reservation = new Reservation(dateRange, opponent) + const reservation = new Reservation({ username, password }, dateRange, opponent) const runner = new Runner(username, password, [reservation]) await runner.run({ headless: false }) @@ -51,5 +51,11 @@ run({ id: opponentId, }, }) - .then(() => console.log('Success')) - .catch((e) => console.error(e)) + .then(() => { + console.log('Success') + process.exit(0) + }) + .catch((e) => { + console.error(e) + process.exit(1) + }) diff --git a/src/workers/requester/index.ts b/src/workers/requester/index.ts index cf08041..2e442a1 100644 --- a/src/workers/requester/index.ts +++ b/src/workers/requester/index.ts @@ -1,5 +1,86 @@ -import { Worker } from '../types' +import http from 'http' +import { Readable } from 'stream' +import { v4 } from 'uuid' +import { Logger, LogLevel } from '../../common/logger' +import { work as schedule } from '../../workers/scheduler' -export const work: Worker = async (): Promise => { - return +Logger.instantiate('requester', 'start-up', LogLevel.INFO) + +process.on('unhandledRejection', (reason) => { + Logger.error('unhandled rejection', { reason }) +}) + +process.on('uncaughtException', (error, origin) => { + Logger.error('uncaught exception', { error, origin }) +}) + +const parseJson = async >( + length: number, + encoding: BufferEncoding, + readable: Readable +) => { + return new Promise((res, rej) => { + let jsonBuffer: Buffer + try { + jsonBuffer = Buffer.alloc(length, encoding) + readable.setEncoding(encoding) + } catch (error: any) { + rej(error) + } + + readable.on('data', (chunk) => { + try { + jsonBuffer.write(chunk, encoding) + } catch (error: any) { + rej(error) + } + }) + + readable.on('end', () => { + try { + const jsonObject = JSON.parse(jsonBuffer.toString()) + res(jsonObject) + } catch (error: any) { + rej(error) + } + }) + }) } + +// Handles POST requests to /reservations +const server = http.createServer(async (req, res) => { + const { url, method } = req + + Logger.instantiate('requester', v4(), LogLevel.DEBUG) + Logger.debug('Incoming request') + + if ( + !url || + !method || + !/^\/reservations$/.test(url) || + method.toLowerCase() !== 'post' + ) { + Logger.info('Bad request') + res.writeHead(400, 'Bad request') + res.end() + return + } + + try { + const length = Number.parseInt(req.headers['content-length'] || '0') + const encoding = req.readableEncoding || 'utf8' + const json = await parseJson(length, encoding, req) + await schedule(json) + } catch (error: any) { + Logger.error('Failed to parse body', { error }) + res.writeHead(400, 'Bad request') + res.end() + return + } + + res.end() +}) + +const port = process.env.SERVER_PORT || 3000 + +server.listen(port, () => Logger.info('server ready and listening', { port })) diff --git a/src/workers/scheduler/index.ts b/src/workers/scheduler/index.ts index 3ab9563..469c8b2 100644 --- a/src/workers/scheduler/index.ts +++ b/src/workers/scheduler/index.ts @@ -3,61 +3,53 @@ import { v4 } from 'uuid' import { Logger, LogLevel } from '../../common/logger' import { Reservation } from '../../common/reservation' -import { ReservationRequest, validateJSONRequest } from '../../common/request' -import { scheduleDateToRequestReservation } from '../../common/schedule' +import { validateJSONRequest } from '../../common/request' import { Worker } from '../types' -export interface ScheduledReservationRequest { - reservationRequest: ReservationRequest +export interface ScheduledReservation { + reservation: Reservation scheduledFor?: Dayjs } export interface SchedulerResult { - scheduledReservationRequest?: ScheduledReservationRequest + scheduledReservation?: ScheduledReservation } -export interface SchedulerInput extends Omit { - dateRange: { start: string; end: string } -} +export type SchedulerInput = Record export const work: Worker = async ( payload: SchedulerInput ): Promise => { - Logger.instantiate('reservationScheduler', v4(), LogLevel.DEBUG) + Logger.instantiate('scheduler', v4(), LogLevel.DEBUG) + + // TODO: obfuscate payload Logger.debug('Handling reservation', { payload }) - let reservationRequest: ReservationRequest + let reservation: Reservation try { - reservationRequest = validateJSONRequest(payload) + reservation = await validateJSONRequest(payload) } catch (err) { Logger.error('Failed to validate request', { err }) throw err } - Logger.debug('Successfully validated request', { reservationRequest }) + Logger.debug('Successfully validated request', { + reservation: reservation.format(), + }) - const res = new Reservation( - reservationRequest.dateRange, - reservationRequest.opponent - ) - - if (!res.isAvailableForReservation()) { - Logger.debug('Reservation date is more than 7 days away') - const scheduledDay = scheduleDateToRequestReservation( - reservationRequest.dateRange.start - ) - Logger.info( - `Scheduling reservation request for ${scheduledDay.format('YYYY-MM-DD')}` + if (!reservation.isAvailableForReservation()) { + Logger.debug( + 'Reservation date is more than 7 days away; saving for later reservation' ) return { - scheduledReservationRequest: { - reservationRequest, - scheduledFor: scheduledDay, + scheduledReservation: { + reservation, + scheduledFor: reservation.getAllowedReservationDate(), }, } } Logger.info('Reservation request can be performed now') return { - scheduledReservationRequest: { reservationRequest }, + scheduledReservation: { reservation }, } } diff --git a/tests/common/logger.test.ts b/tests/common/logger.test.ts index 87883ed..502fce1 100644 --- a/tests/common/logger.test.ts +++ b/tests/common/logger.test.ts @@ -21,24 +21,39 @@ describe('Logger', () => { expect(consoleLogSpy).toHaveBeenCalledTimes(2) expect(consoleLogSpy).toHaveBeenNthCalledWith( - 1, '<%s> [%s] %s: %s', 'tag', 'abc', 'DEBUG', 'first' + 1, + '<%s> [%s] %s: %s', + 'tag', + 'abc', + 'DEBUG', + 'first' ) expect(consoleLogSpy).toHaveBeenNthCalledWith( - 2, '<%s> [%s] %s: %s', 'tag', 'abc', 'INFO', 'second' + 2, + '<%s> [%s] %s: %s', + 'tag', + 'abc', + 'INFO', + 'second' ) expect(consoleErrorSpy).toHaveBeenCalledTimes(1) expect(consoleErrorSpy).toHaveBeenCalledWith( - '<%s> [%s] %s: %s - %O', 'tag', 'abc', 'ERROR', 'third', { "errorMessage": "test" } + '<%s> [%s] %s: %s - %O', + 'tag', + 'abc', + 'ERROR', + 'third', + { errorMessage: 'test' } ) }) test('should log only when level is >= LogLevel of LoggerInstance', () => { const consoleLogSpy = jest.fn() jest.spyOn(console, 'log').mockImplementationOnce(consoleLogSpy) - + Logger.instantiate('tag', 'abc', LogLevel.INFO) - Logger.debug('should\'t appear') + Logger.debug("should't appear") expect(consoleLogSpy).not.toHaveBeenCalled() }) -}) \ No newline at end of file +}) diff --git a/tests/common/password.test.ts b/tests/common/password.test.ts new file mode 100644 index 0000000..88bd728 --- /dev/null +++ b/tests/common/password.test.ts @@ -0,0 +1,27 @@ +import * as password from '../../src/common/password' + +describe('password', () => { + describe('generateSalt', () => { + test('should generate salt of 32 bytes', async () => { + const saltBuffer = await password.generateSalt() + expect(saltBuffer.length).toEqual(32) + }) + }) + + describe('generateHash', () => { + test('should generate a hash of 64 bytes', async () => { + const saltBuffer = Buffer.alloc(32, 1) + const hash = await password.generateHash('abc123', saltBuffer) + expect(hash).toEqual( + '$argon2id$v=19$m=16384,t=2,p=1$AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE$jF3SXC/JRI9d1jr48kQkvWaSVlf3XGNSRUCNnNp5IaI' + ) + }) + }) + + describe('hashPassword', () => { + test('it should create salt and hash password', async () => { + const hash = await password.hashPassword('abc123') + expect(hash).toMatch(/^\$argon2id\$v=19\$m=16384,t=2,p=1\$.+$/) + }) + }) +}) diff --git a/tests/common/request.test.ts b/tests/common/request.test.ts index b0f4059..e556c2a 100644 --- a/tests/common/request.test.ts +++ b/tests/common/request.test.ts @@ -2,141 +2,13 @@ import dayjs from 'dayjs' import { validateJSONRequest, - validateStringRequest, ValidationError, ValidationErrorCode, } from '../../src/common/request' describe('request', () => { - const testDate = dayjs().add(1, 'day') - describe('validateStringRequest', () => { - test('should return ReservationRequest', () => { - const body = JSON.stringify({ - username: 'collin', - password: '123abc', - dateRange: { - start: testDate.clone().toISOString(), - end: testDate.add(15, 'minutes').toISOString(), - }, - opponent: { - id: '123', - name: 'collin', - } - }) - - expect(() => validateStringRequest(body)).not.toThrow() - }) - - test('should fail for undefined body', () => { - expect(() => validateStringRequest(undefined)).toThrowError(new ValidationError('Invalid request', ValidationErrorCode.UNDEFINED_REQUEST_BODY)) - }) - - test('should fail for invalid json', () => { - const body = `A{ - username: 'collin', - password: '123abc', - dateRange: { - start: '2021-12-25T12:34:56Z', - end: '2021-12-25T12:45:56Z' - }, - opponent: { - id: '123', - name: 'collin', - } - }` - - expect(() => validateStringRequest(body)).toThrowError(new ValidationError('Invalid request', ValidationErrorCode.INVALID_JSON)) - }) - - test.each([ - { username: '', password: '1qaz2wsx', dateRange: { start: '1', end: '1' }, opponent: { id: '123', name: 'abc' } }, - { password: '1qaz2wsx', dateRange: { start: '1', end: '1' }, opponent: { id: '123', name: 'abc' } }, - { username: 'collin', password: '', dateRange: { start: '1', end: '1' }, opponent: { id: '123', name: 'abc' } }, - { username: 'collin', dateRange: { start: '1', end: '1' }, opponent: { id: '123', name: 'abc' } }, - { username: 'collin', password: '1qaz2wsx', dateRange: {}, opponent: { id: '123', name: 'abc' } }, - { username: 'collin', password: '1qaz2wsx', dateRange: { start: '1' }, opponent: { id: '123', name: 'abc' } }, - { username: 'collin', password: '1qaz2wsx', dateRange: { end: '1' }, opponent: { id: '123', name: 'abc' } }, - { username: 'collin', password: '1qaz2wsx', opponent: { id: '123', name: 'abc' } }, - { username: 'collin', password: '1qaz2wsx', dateRange: { start: '1', end: '1' }, opponent: { id: '', name: 'abc' } }, - { username: 'collin', password: '1qaz2wsx', dateRange: { start: '1', end: '1' }, opponent: { name: 'abc' } }, - { username: 'collin', password: '1qaz2wsx', dateRange: { start: '1', end: '1' }, opponent: { id: '123', name: '' } }, - { username: 'collin', password: '1qaz2wsx', dateRange: { start: '1', end: '1' }, opponent: { id: '123' } }, - ])('should fail for body missing required values', (body) => { - expect(() => validateStringRequest(JSON.stringify(body))).toThrowError(new ValidationError('Invalid request', ValidationErrorCode.INVALID_REQUEST_BODY)) - }) - - test('should fail for invalid date range', () => { - const body = JSON.stringify({ - username: 'collin', - password: '123abc', - dateRange: { - start: 'monkey', - end: testDate.add(15, 'minutes').toISOString(), - }, - opponent: { - id: '123', - name: 'collin', - } - }) - - expect(() => validateStringRequest(body)).toThrowError(new ValidationError('Invalid request', ValidationErrorCode.INVALID_DATE_RANGE)) - }) - - test.each([ - { start: dayjs().subtract(1, 'hour').toString(), end: dayjs().add(1, 'hour').toString() }, - { start: dayjs().add(2, 'hour').toString(), end: dayjs().add(1, 'hour').toString() }, - { start: dayjs().toString(), end: dayjs().add(1, 'day').toString() } - ])('should fail for improper start or end dates', (dateRange) => { - const body = JSON.stringify({ - username: 'collin', - password: '123abc', - dateRange: [ - dateRange - ], - opponent: { - id: '123', - name: 'collin', - } - }) - - expect(() => validateStringRequest(body)).toThrowError(new ValidationError('Invalid request', ValidationErrorCode.INVALID_START_OR_END_DATE)) - }) - - test('should not fail if no opponent is provided', () => { - const body = JSON.stringify({ - username: 'collin', - password: '123abc', - dateRange: { - start: testDate.clone().toISOString(), - end: testDate.add(15, 'minutes').toISOString() - }, - }) - - expect(() => validateStringRequest(body)).not.toThrow() - }) - - test.each([ - { id: '-50', name: 'collin' }, - { id: 'abc', name: 'collin' }, - { id: '-1', name: '*!@#' }, - { id: '123', name: '!@#' }, - ])('should fail for invalid opponent $id, $name', (opponent) => { - const body = JSON.stringify({ - username: 'collin', - password: '123abc', - dateRange: { - start: testDate.clone().toISOString(), - end: testDate.add(15, 'minutes').toISOString(), - }, - opponent, - }) - - expect(() => validateStringRequest(body)).toThrowError(new ValidationError('Invalid request', ValidationErrorCode.INVALID_OPPONENT)) - }) - }) - describe('validateJSONRequest', () => { test('should return ReservationRequest', () => { const body = { @@ -144,15 +16,64 @@ describe('request', () => { password: '123abc', dateRange: { start: testDate.clone().toISOString(), - end: testDate.add(15, 'minutes').toISOString() + end: testDate.add(15, 'minutes').toISOString(), }, opponent: { id: '123', name: 'collin', - } + }, } expect(() => validateJSONRequest(body)).not.toThrow() }) + + test('should throw error for undefined body', async () => { + // @ts-expect-error undefined body + expect(() => validateJSONRequest(undefined)).rejects.toThrowError( + ValidationError + ) + }) + + test('should throw error for invalid body', () => { + expect(() => + validateJSONRequest({ username: '', password: '' }) + ).rejects.toThrowError(ValidationError) + }) + + test('should throw error for invalid date range', () => { + expect(() => + validateJSONRequest({ + username: 'test', + password: 'test', + dateRange: { start: 'a', end: 'a' }, + opponent: { id: 1, name: 'test' }, + }) + ).rejects.toThrowError(ValidationError) + }) + + test('should throw error for incorrect date range', () => { + expect(() => + validateJSONRequest({ + username: 'test', + password: 'test', + dateRange: { start: '2022-01-01', end: '2021-01-01' }, + opponent: { id: 1, name: 'test' }, + }) + ).rejects.toThrowError(ValidationError) + }) + + test('should throw error for incorrect date range', () => { + expect(() => + validateJSONRequest({ + username: 'test', + password: 'test', + dateRange: { + start: testDate.toString(), + end: testDate.add(15, 'minute').toString(), + }, + opponent: { id: 1, name: 'test' }, + }) + ).rejects.toThrowError(ValidationError) + }) }) -}) \ No newline at end of file +}) diff --git a/tests/common/reservation.test.ts b/tests/common/reservation.test.ts index 0db62cc..c7ccc76 100644 --- a/tests/common/reservation.test.ts +++ b/tests/common/reservation.test.ts @@ -1,4 +1,4 @@ -import dayjs from 'dayjs' +import dayjs, { Dayjs } from 'dayjs' import { DateRange, Reservation } from '../../src/common/reservation' describe('Reservation', () => { @@ -9,8 +9,15 @@ describe('Reservation', () => { start: startDate, end: endDate, } - const res = new Reservation(dateRange, { id: 'collin', name: 'collin' }) - + const res = new Reservation( + { username: 'collin', password: 'password' }, + dateRange, + { + id: 'collin', + name: 'collin', + } + ) + expect(res.possibleDates).toHaveLength(5) expect(res.possibleDates[0]).toEqual(startDate) @@ -24,8 +31,38 @@ describe('Reservation', () => { { reservationDate: dayjs().add(7, 'days'), expected: true }, { reservationDate: dayjs().add(1, 'days'), expected: true }, { reservationDate: dayjs().add(8, 'days'), expected: false }, - ])('will properly mark reservation availability according to date', ({ reservationDate, expected }) => { - const res = new Reservation({ start: reservationDate, end: reservationDate }, { id: 'collin', name: 'collin' }) - expect(res.isAvailableForReservation()).toBe(expected) - }) -}) \ No newline at end of file + ])( + 'will properly mark reservation availability according to date', + ({ reservationDate, expected }) => { + const res = new Reservation( + { username: 'collin', password: 'collin' }, + { start: reservationDate, end: reservationDate }, + { id: 'collin', name: 'collin' } + ) + expect(res.isAvailableForReservation()).toBe(expected) + } + ) + + const zeroTime = (date: Dayjs): Dayjs => + date.hour(0).minute(0).second(0).millisecond(0) + test.each([ + { + date: dayjs().add(8, 'days'), + expected: zeroTime(dayjs().add(1, 'days')), + }, + { + date: dayjs().add(31, 'days'), + expected: zeroTime(dayjs().add(24, 'days')), + }, + ])( + 'should return value indicating if reservation is possible now', + ({ date, expected }) => { + const res = new Reservation( + { username: 'collin', password: 'collin' }, + { start: date, end: date }, + { id: 'collin', name: 'collin' } + ) + expect(res.getAllowedReservationDate()).toStrictEqual(expected) + } + ) +}) diff --git a/tests/common/schedule.test.ts b/tests/common/schedule.test.ts deleted file mode 100644 index 2eff1ef..0000000 --- a/tests/common/schedule.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import dayjs, { Dayjs } from 'dayjs' -import { scheduleDateToRequestReservation } from '../../src/common/schedule' - -describe('scheduleDateToRequestReservation', () => { - const zeroTime = (date: Dayjs): Dayjs => date.hour(0).minute(0).second(0).millisecond(0) - - test.each([ - { date: dayjs().add(8, 'days'), expected: zeroTime(dayjs().add(1, 'days')) }, - { date: dayjs().add(31, 'days'), expected: zeroTime(dayjs().add(24, 'days')) }, - ])('should return value indicating if reservation is possible now', ({ date, expected }) => { - expect(scheduleDateToRequestReservation(date)).toStrictEqual(expected) - }) -}) \ No newline at end of file diff --git a/tests/workers/__snapshots__/scheduler.test.ts.snap b/tests/workers/__snapshots__/scheduler.test.ts.snap new file mode 100644 index 0000000..8700d47 --- /dev/null +++ b/tests/workers/__snapshots__/scheduler.test.ts.snap @@ -0,0 +1,138 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`scheduler should handle valid requests outside of reservation window 1`] = ` +Object { + "scheduledReservation": Object { + "reservation": Object { + "booked": false, + "dateRange": Object { + "end": Object { + "$D": 16, + "$H": 1, + "$L": "en", + "$M": 0, + "$W": 0, + "$d": Object {}, + "$m": 15, + "$ms": 0, + "$s": 0, + "$x": Object {}, + "$y": 2022, + }, + "start": Object { + "$D": 16, + "$H": 1, + "$L": "en", + "$M": 0, + "$W": 0, + "$d": Object {}, + "$m": 0, + "$ms": 0, + "$s": 0, + "$x": Object {}, + "$y": 2022, + }, + }, + "opponent": Object { + "id": "123", + "name": "collin", + }, + "possibleDates": Array [ + Object { + "$D": 16, + "$H": 1, + "$L": "en", + "$M": 0, + "$W": 0, + "$d": Object {}, + "$m": 0, + "$ms": 0, + "$s": 0, + "$x": Object {}, + "$y": 2022, + }, + Object { + "$D": 16, + "$H": 1, + "$L": "en", + "$M": 0, + "$W": 0, + "$d": Object {}, + "$m": 15, + "$ms": 0, + "$s": 0, + "$x": Object {}, + "$y": 2022, + }, + ], + "user": Object { + "password": Any, + "username": "collin", + }, + }, + "scheduledFor": Object { + "$D": 9, + "$H": 0, + "$L": "en", + "$M": 0, + "$W": 0, + "$d": Object {}, + "$m": 0, + "$ms": 0, + "$s": 0, + "$x": Object {}, + "$y": 2022, + }, + }, +} +`; + +exports[`scheduler should handle valid requests within reservation window 1`] = ` +Object { + "scheduledReservation": Object { + "reservation": Object { + "booked": false, + "dateRange": Object { + "end": Object { + "$D": 1, + "$H": 1, + "$L": "en", + "$M": 0, + "$W": 6, + "$d": Object {}, + "$m": 30, + "$ms": 0, + "$s": 0, + "$x": Object {}, + "$y": 2022, + }, + "start": Object { + "$D": 1, + "$H": 1, + "$L": "en", + "$M": 0, + "$W": 6, + "$d": Object {}, + "$m": 15, + "$ms": 0, + "$s": 0, + "$x": Object {}, + "$y": 2022, + }, + }, + "opponent": Object { + "id": "123", + "name": "collin", + }, + "possibleDates": Array [ + "2022-01-01T00:15:00.000Z", + "2022-01-01T00:30:00.000Z", + ], + "user": Object { + "password": Any, + "username": "collin", + }, + }, + }, +} +`; diff --git a/tests/workers/scheduler.test.ts b/tests/workers/scheduler.test.ts index 0ceb4fc..cdd93fa 100644 --- a/tests/workers/scheduler.test.ts +++ b/tests/workers/scheduler.test.ts @@ -1,8 +1,14 @@ import dayjs from 'dayjs' import { ValidationError, ValidationErrorCode } from '../../src/common/request' -import { work, SchedulerInput, SchedulerResult } from '../../src/workers/scheduler' +import { Reservation } from '../../src/common/reservation' +import { + work, + SchedulerInput, + SchedulerResult, +} from '../../src/workers/scheduler' jest.mock('../../src/common/logger') +jest.useFakeTimers().setSystemTime(new Date('2022-01-01')) describe('scheduler', () => { test('should handle valid requests within reservation window', async () => { @@ -10,44 +16,51 @@ describe('scheduler', () => { const end = start.add(15, 'minutes') const payload: SchedulerInput = { - username: "collin", - password: "password", + username: 'collin', + password: 'password', dateRange: { start: start.toISOString(), end: end.toISOString() }, - opponent: { id: "123", name: "collin" } + opponent: { id: '123', name: 'collin' }, } - await expect(work(payload)).resolves - .toMatchObject({ - scheduledReservationRequest: { - reservationRequest: { + await expect(work(payload)).resolves.toMatchSnapshot({ + scheduledReservation: { + // @ts-expect-error snapshot property matching + reservation: { + user: { username: 'collin', - password: 'password', - dateRange: { start, end }, - opponent: { id: '123', name: 'collin' }, - } - }}) + password: expect.any(String) + }, + dateRange: { start, end }, + opponent: { id: '123', name: 'collin' } + }, + }, + }) }) test('should handle valid requests outside of reservation window', async () => { const start = dayjs().add(15, 'days') const end = start.add(15, 'minutes') const payload: SchedulerInput = { - username: "collin", - password: "password", + username: 'collin', + password: 'password', dateRange: { start: start.toISOString(), end: end.toISOString() }, - opponent: { id: "123", name: "collin" } + opponent: { id: '123', name: 'collin' }, } - await expect(work(payload)).resolves.toMatchObject({ - scheduledReservationRequest: { - reservationRequest: { - username: 'collin', - password: 'password', - dateRange: { start, end }, - opponent: { id: '123', name: 'collin' }, - }, - scheduledFor: start.subtract(7, 'days').hour(0).minute(0).second(0).millisecond(0) - } + await expect(work(payload)).resolves.toMatchSnapshot({ + scheduledReservation: { + reservation: new Reservation( + { username: 'collin', password: expect.any(String) }, + { start, end }, + { id: '123', name: 'collin' } + ), + scheduledFor: start + .subtract(7, 'days') + .hour(0) + .minute(0) + .second(0) + .millisecond(0), + }, }) }) @@ -56,13 +69,16 @@ describe('scheduler', () => { const end = start.add(15, 'minutes') const payload: SchedulerInput = { - password: "password", + password: 'password', dateRange: { start: start.toISOString(), end: end.toISOString() }, - opponent: { id: "123", name: "collin" } + opponent: { id: '123', name: 'collin' }, } - await expect(work(payload)) - .rejects - .toThrowError(new ValidationError('Invalid request', ValidationErrorCode.INVALID_REQUEST_BODY)) + await expect(work(payload)).rejects.toThrowError( + new ValidationError( + 'Invalid request', + ValidationErrorCode.INVALID_REQUEST_BODY + ) + ) }) -}) \ No newline at end of file +})