diff --git a/.eslintrc b/.eslintrc index e3b530e..cb26edb 100644 --- a/.eslintrc +++ b/.eslintrc @@ -144,6 +144,14 @@ "modifiers": [ "requiresQuotes" ] + }, + { + "selector": "objectLiteralProperty", + "filter": { + "regex": "^_id$", + "match": true + }, + "format": null } ] } diff --git a/serverless.ts b/serverless.ts index a293b48..e609c25 100644 --- a/serverless.ts +++ b/serverless.ts @@ -17,6 +17,9 @@ import updateBuyIns from '@functions/update-buy-ins'; import getBuyIns from '@functions/get-buy-ins'; import notifyByEmail from '@functions/notify-by-email'; import verifyEmail from '@functions/verify-email'; +import deleteUser from '@functions/delete'; +import userExists from '@functions/user-exists'; +import interestForm from '@functions/interest-form'; import * as path from 'path'; import * as dotenv from 'dotenv'; @@ -60,6 +63,9 @@ const serverlessConfiguration: AWS = { getBuyIns, notifyByEmail, verifyEmail, + deleteUser, + userExists, + interestForm, }, package: { individually: true, patterns: ['!.env*', '.env.vault'] }, custom: { diff --git a/src/config.ts b/src/config.ts index 036533f..62b4e1c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,2 +1,2 @@ -export const registrationStart = '01/01/25'; -export const registrationEnd = '02/03/25'; +export const registrationStart = '1/01/25'; +export const registrationEnd = '10/06/25'; diff --git a/src/functions/create/handler.ts b/src/functions/create/handler.ts index ac63f5d..030ab8b 100644 --- a/src/functions/create/handler.ts +++ b/src/functions/create/handler.ts @@ -7,6 +7,8 @@ import schema from './schema'; import { MongoDB } from '../../util'; import * as config from '../../config'; +import { validateEmail } from '../../helper'; + import * as path from 'path'; import * as dotenv from 'dotenv'; dotenv.config({ path: path.resolve(process.cwd(), '.env') }); @@ -25,6 +27,15 @@ const create: ValidatedEventAPIGatewayProxyEvent = async (event) } const uEmail = event.body.email.toLowerCase(); + if (!validateEmail(uEmail)) { + return { + statusCode: 403, + body: JSON.stringify({ + statusCode: 403, + message: 'Improper Email format', + }), + }; + } let password = event.body.password; try { diff --git a/src/functions/delete/handler.ts b/src/functions/delete/handler.ts new file mode 100644 index 0000000..166fc31 --- /dev/null +++ b/src/functions/delete/handler.ts @@ -0,0 +1,79 @@ +import type { ValidatedEventAPIGatewayProxyEvent } from '@libs/api-gateway'; +import { middyfy } from '@libs/lambda'; +import schema from './schema'; +import { MongoDB, validateToken, ensureRoles, UserDoc } from '../../util'; +import * as path from 'path'; +import * as dotenv from 'dotenv'; + +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + +const deleteUser: ValidatedEventAPIGatewayProxyEvent = async (event) => { + try { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { auth_email, auth_token, user_email } = event.body; + + // 1. Validate auth token + const tokenValid = validateToken(auth_token, process.env.JWT_SECRET, auth_email); + if (!tokenValid) { + return { + statusCode: 401, + body: JSON.stringify({ statusCode: 401, message: 'Unauthorized' }), + }; + } + + // 2. Connect to MongoDB + const db = MongoDB.getInstance(process.env.MONGO_URI); + await db.connect(); + const users = db.getCollection('users'); + + // 3. Check target user exists + const target = await users.findOne({ email: user_email }); + if (!target) { + return { + statusCode: 404, + body: JSON.stringify({ statusCode: 404, message: 'User not found' }), + }; + } + + // 4. Verify auth user exists + const authUser = await users.findOne({ email: auth_email }); + if (!authUser) { + return { + statusCode: 404, + body: JSON.stringify({ statusCode: 404, message: 'Auth user not found' }), + }; + } + + // 5. Ensure auth user role + if (!ensureRoles(authUser.role, ['director', 'organizer'])) { + return { + statusCode: 401, + body: JSON.stringify({ statusCode: 401, message: 'Only directors/organizers can call this endpoint.' }), + }; + } + + // 6. Delete the user + const result = await users.deleteOne({ email: user_email }); + if (result.deletedCount !== 1) { + // Shouldn't happen since we checked existence + return { + statusCode: 500, + body: JSON.stringify({ statusCode: 500, message: 'Internal server error' }), + }; + } + + // 7. Success + return { + statusCode: 200, + body: JSON.stringify({ statusCode: 200, message: `Deleted ${user_email} successfully` }), + }; + } catch (error) { + console.error('Error deleting user:', error); + return { + statusCode: 500, + body: JSON.stringify({ statusCode: 500, message: 'Internal server error' }), + }; + } +}; + +export const main = middyfy(deleteUser); diff --git a/src/functions/delete/index.ts b/src/functions/delete/index.ts new file mode 100644 index 0000000..b70520d --- /dev/null +++ b/src/functions/delete/index.ts @@ -0,0 +1,20 @@ +import { handlerPath } from '@libs/handler-resolver'; +import schema from './schema'; + +export default { + handler: `${handlerPath(__dirname)}/handler.main`, + events: [ + { + http: { + method: 'post', + path: 'delete', + cors: true, + request: { + schemas: { + 'application/json': schema, + }, + }, + }, + }, + ], +}; diff --git a/src/functions/delete/schema.ts b/src/functions/delete/schema.ts new file mode 100644 index 0000000..a43c1e7 --- /dev/null +++ b/src/functions/delete/schema.ts @@ -0,0 +1,9 @@ +export default { + type: 'object', + properties: { + user_email: { type: 'string', format: 'email' }, + auth_email: { type: 'string', format: 'email' }, + auth_token: { type: 'string' }, + }, + required: ['user_email', 'auth_token', 'auth_email'], +} as const; diff --git a/src/functions/index.ts b/src/functions/index.ts index a30a938..c8ad166 100644 --- a/src/functions/index.ts +++ b/src/functions/index.ts @@ -15,3 +15,6 @@ export { default as updateBuyIns } from './update-buy-ins'; export { default as getBuyIns } from './get-buy-ins'; export { default as notifyByEmail } from './notify-by-email'; export { default as verifyEmail } from './verify-email'; +export { default as delete } from './delete'; +export { default as userExists } from './user-exists'; +export { default as interestForm } from './interest-form'; diff --git a/src/functions/interest-form/handler.ts b/src/functions/interest-form/handler.ts new file mode 100644 index 0000000..a9e0d0b --- /dev/null +++ b/src/functions/interest-form/handler.ts @@ -0,0 +1,87 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { ValidatedEventAPIGatewayProxyEvent } from '@libs/api-gateway'; +import { middyfy } from '@libs/lambda'; +import { MongoDB } from '../../util'; +import * as path from 'path'; +import * as dotenv from 'dotenv'; +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + +import schema from './schema'; + +const submitInterestForm: ValidatedEventAPIGatewayProxyEvent = async (event) => { + try { + // Destructure all the new fields from event.body + const { + firstName, + lastName, + age, + phoneNumber, + email, + school, + levelOfStudy, + countryOfResidence, + linkedInUrl, + mlh_code_of_conduct, + mlh_privacy_policy, + mlh_terms_and_conditions, + } = event.body; + + // Validate LinkedIn URL format if provided + if (linkedInUrl && linkedInUrl.trim() !== '') { + const linkedInRegex = /^https?:\/\/(www\.)?linkedin\.com\/in\/[a-zA-Z0-9-]+\/?$/; + if (!linkedInRegex.test(linkedInUrl.trim())) { + return { + statusCode: 422, + body: JSON.stringify({ + statusCode: 422, + message: 'Please provide a valid LinkedIn profile URL (e.g., https://linkedin.com/in/yourname)', + }), + }; + } + } + + // Connect to database + const db = MongoDB.getInstance(process.env.MONGO_URI); + await db.connect(); + const interestFormsCollection = db.getCollection('interest-forms'); // Target collection + + // Create the document to insert + const docToInsert = { + firstName, + lastName, + age: age, + phoneNumber, + email, + school, + levelOfStudy, + countryOfResidence, + linkedInUrl, + mlh_code_of_conduct, + mlh_privacy_policy, + mlh_terms_and_conditions, + }; + + const result = await interestFormsCollection.insertOne(docToInsert); + + // Return success + return { + statusCode: 200, + body: JSON.stringify({ + message: 'Successful Form Submission', + submissionId: result.insertedId, + }), + }; + } catch (error) { + console.error('Error submitting interest form:', error); + return { + statusCode: 500, + body: JSON.stringify({ + statusCode: 500, + message: 'Internal Server Error', + error: error.message, + }), + }; + } +}; + +export const main = middyfy(submitInterestForm); diff --git a/src/functions/interest-form/index.ts b/src/functions/interest-form/index.ts new file mode 100644 index 0000000..1c3e476 --- /dev/null +++ b/src/functions/interest-form/index.ts @@ -0,0 +1,20 @@ +import { handlerPath } from '@libs/handler-resolver'; +import schema from './schema'; + +export default { + handler: `${handlerPath(__dirname)}/handler.main`, + events: [ + { + http: { + method: 'post', + path: 'interest-form', + cors: true, + request: { + schemas: { + 'application/json': schema, + }, + }, + }, + }, + ], +}; diff --git a/src/functions/interest-form/schema.ts b/src/functions/interest-form/schema.ts new file mode 100644 index 0000000..ba4fad0 --- /dev/null +++ b/src/functions/interest-form/schema.ts @@ -0,0 +1,30 @@ +export default { + type: 'object', + properties: { + firstName: { type: 'string' }, + lastName: { type: 'string' }, + age: { type: 'number' }, + phoneNumber: { type: 'string' }, + email: { type: 'string', format: 'email' }, + school: { type: 'string' }, + levelOfStudy: { type: 'string' }, + countryOfResidence: { type: 'string' }, + linkedInUrl: { type: 'string' }, + mlh_code_of_conduct: { type: 'boolean' }, + mlh_privacy_policy: { type: 'boolean' }, + mlh_terms_and_conditions: { type: 'boolean' }, + }, + required: [ + 'firstName', + 'lastName', + 'age', + 'phoneNumber', + 'email', + 'school', + 'levelOfStudy', + 'countryOfResidence', + 'mlh_code_of_conduct', + 'mlh_privacy_policy', + 'mlh_terms_and_conditions', + ], +} as const; diff --git a/src/functions/read/handler.ts b/src/functions/read/handler.ts index 284c442..7ad369e 100644 --- a/src/functions/read/handler.ts +++ b/src/functions/read/handler.ts @@ -50,7 +50,13 @@ const read: ValidatedEventAPIGatewayProxyEvent = async (event) => } const lookupEmail = event.body.email.toLowerCase(); - if (!authUser.role['director'] && !authUser.role['organizer'] && authUser.email !== lookupEmail) { + + // Ensures user can only look up their own information + if ( + !authUser.role['director'] && + !authUser.role['organizer'] && + (authUser.email !== lookupEmail || event.body.all) + ) { return { statusCode: 403, body: JSON.stringify({ @@ -62,22 +68,41 @@ const read: ValidatedEventAPIGatewayProxyEvent = async (event) => // Find the user // eslint-disable-next-line @typescript-eslint/naming-convention - const lookUpUser = await users.findOne({ email: lookupEmail }, { projection: { password: 0, _id: 0 } }); // exclude password and id - if (!lookUpUser) { + if (!event.body.all) { + const lookUpUser = await users.findOne({ email: lookupEmail }, { projection: { password: 0, _id: 0 } }); // exclude password and id + if (!lookUpUser) { + return { + statusCode: 404, + body: JSON.stringify({ + statusCode: 404, + message: 'Look-up user not found.', + }), + }; + } + + // Return user data return { - statusCode: 404, - body: JSON.stringify({ + statusCode: 200, + body: JSON.stringify(lookUpUser), + }; + } else { + const lookUpAllUsers = await users.find({}, { projection: { password: 0, _id: 0 } }).toArray(); // exclude password and id + if (!lookUpAllUsers) { + return { statusCode: 404, - message: 'Look-up user not found.', - }), + body: JSON.stringify({ + statusCode: 404, + message: 'Look-up all users not found.', + }), + }; + } + + // Return user data + return { + statusCode: 200, + body: JSON.stringify(lookUpAllUsers), }; } - - // Return user data - return { - statusCode: 200, - body: JSON.stringify(lookUpUser), - }; } catch (error) { console.error('Error reading user:', error); return { diff --git a/src/functions/read/schema.ts b/src/functions/read/schema.ts index 4f00ad4..6cc3d52 100644 --- a/src/functions/read/schema.ts +++ b/src/functions/read/schema.ts @@ -4,6 +4,7 @@ export default { auth_email: { type: 'string', format: 'email' }, auth_token: { type: 'string' }, email: { type: 'string', format: 'email' }, + all: { type: 'boolean', default: false }, }, required: ['auth_email', 'auth_token', 'email'], } as const; diff --git a/src/functions/update-buy-ins/handler.ts b/src/functions/update-buy-ins/handler.ts index b047998..fa4f269 100644 --- a/src/functions/update-buy-ins/handler.ts +++ b/src/functions/update-buy-ins/handler.ts @@ -79,6 +79,32 @@ const updateBuyIns: ValidatedEventAPIGatewayProxyEvent = async (e }; } + //validate point update + for (const userBuyIn of userBuyInsSorted) { + const value = userBuyIn.buy_in; + if (value === '') userBuyIn.buy_in = 0; + + const numVal = parseInt(value, 10); + if (Number.isNaN(numVal)) { + return { + statusCode: 403, + body: JSON.stringify({ + statusCode: 403, + message: 'Requested point change is not a valid integer input', + }), + }; + } + if (numVal >= 1000 || numVal <= -1000) { + return { + statusCode: 403, + body: JSON.stringify({ + statusCode: 403, + message: 'Requested point change is not in a valid point range', + }), + }; + } + } + //update the buy_ins array await pointCollection.updateOne({ email: event.body.email }, { $set: { buy_ins: event.body.buy_ins } }); return { diff --git a/src/functions/update/handler.ts b/src/functions/update/handler.ts index 2262233..c5e1c00 100644 --- a/src/functions/update/handler.ts +++ b/src/functions/update/handler.ts @@ -4,6 +4,8 @@ import { middyfy } from '@libs/lambda'; import schema from './schema'; +import { validateEmail } from '../../helper'; + import { MongoDB, validateToken, ensureRoles } from '../../util'; import * as path from 'path'; import * as dotenv from 'dotenv'; @@ -214,6 +216,9 @@ function validateUpdates(updates: Updates, registrationStatus?: string, user?: W return 'Missing required fields'; } else return true; } + + if ('email' in setUpdates) if (!validateEmail(setUpdates.email)) return 'Improper Email format'; + if ( ['_id', 'password', 'discord', 'created_at', 'registered_at', 'email_verified'].some( (lockedProp) => lockedProp in setUpdates diff --git a/src/functions/user-exists/handler.ts b/src/functions/user-exists/handler.ts new file mode 100644 index 0000000..5f73afb --- /dev/null +++ b/src/functions/user-exists/handler.ts @@ -0,0 +1,70 @@ +import type { ValidatedEventAPIGatewayProxyEvent } from '@libs/api-gateway'; +import { middyfy } from '@libs/lambda'; +import schema from './schema'; +import { MongoDB, validateToken } from '../../util'; +import * as path from 'path'; +import * as dotenv from 'dotenv'; +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + +const userExists: ValidatedEventAPIGatewayProxyEvent = async (event) => { + try { + //Check token validity + const isValidToken = validateToken(event.body.auth_token, process.env.JWT_SECRET, event.body.auth_email); + if (!isValidToken) { + return { + statusCode: 401, + body: JSON.stringify({ + statuscode: 401, + message: 'Unauthorized', + }), + }; + } + + //Connect to DB + const db = MongoDB.getInstance(process.env.MONGO_URI); + await db.connect(); + const users = db.getCollection('users'); + + const authUser = await users.findOne({ email: event.body.auth_email }); + if (!authUser) { + return { + statusCode: 404, + body: JSON.stringify({ + statuscode: 404, + message: 'Auth user not found.', + }), + }; + } + + //Check if user being looked up exists + const lookupUser = await users.findOne( + { email: event.body.email.toLowerCase() }, + { projection: { password: 0, _id: 0 } } + ); + if (!lookupUser) { + return { + statusCode: 404, + body: JSON.stringify({ + statusCode: 404, + message: 'Look-up user was not found', + }), + }; + } + //return that user exists + return { + statusCode: 200, + body: JSON.stringify('User exists'), + }; + } catch (error) { + console.error('Error reading user:', error); + return { + statusCode: 500, + body: JSON.stringify({ + statusCode: 500, + message: 'Internal server error.', + error, + }), + }; + } +}; +export const main = middyfy(userExists); diff --git a/src/functions/user-exists/index.ts b/src/functions/user-exists/index.ts new file mode 100644 index 0000000..d7eb33e --- /dev/null +++ b/src/functions/user-exists/index.ts @@ -0,0 +1,20 @@ +import { handlerPath } from '@libs/handler-resolver'; +import schema from './schema'; + +export default { + handler: `${handlerPath(__dirname)}/handler.main`, + events: [ + { + http: { + method: 'post', + path: 'user-exists', + cors: true, + request: { + schemas: { + 'application/json': schema, + }, + }, + }, + }, + ], +}; diff --git a/src/functions/user-exists/schema.ts b/src/functions/user-exists/schema.ts new file mode 100644 index 0000000..4f00ad4 --- /dev/null +++ b/src/functions/user-exists/schema.ts @@ -0,0 +1,9 @@ +export default { + type: 'object', + properties: { + auth_email: { type: 'string', format: 'email' }, + auth_token: { type: 'string' }, + email: { type: 'string', format: 'email' }, + }, + required: ['auth_email', 'auth_token', 'email'], +} as const; diff --git a/src/helper.ts b/src/helper.ts new file mode 100644 index 0000000..eda2531 --- /dev/null +++ b/src/helper.ts @@ -0,0 +1,4 @@ +export function validateEmail(email) { + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailPattern.test(email); +} diff --git a/tests/create.test.ts b/tests/create.test.ts index f42b548..57de15e 100644 --- a/tests/create.test.ts +++ b/tests/create.test.ts @@ -48,6 +48,17 @@ describe('Create endpoint', () => { expect(res.statusCode).toBe(200); expect(JSON.parse(res.body).message).toBe('User created!'); }); + it('Email entry is invalid', async () => { + const mockEvent = createEvent({ email: 'notValidFormat', password: 'testPassword123' }, '/create', 'POST'); + + const mockCallback = jest.fn(); + + (bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword'); + const res = await main(mockEvent, mockContext, mockCallback); + + expect(res.statusCode).toBe(403); + expect(JSON.parse(res.body).message).toBe('Improper Email format'); + }); it('Registration time has passed', async () => { jest.useFakeTimers(); jest.setSystemTime(new Date('01/01/2100')); diff --git a/tests/delete.test.ts b/tests/delete.test.ts new file mode 100644 index 0000000..fcf81d7 --- /dev/null +++ b/tests/delete.test.ts @@ -0,0 +1,105 @@ +jest.mock('../src/util', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + MongoDB: { + getInstance: jest.fn().mockReturnValue({ + connect: jest.fn(), + disconnect: jest.fn(), + getCollection: jest.fn().mockReturnValue({ + findOne: jest.fn(), + deleteOne: jest.fn(), + }), + }), + }, + validateToken: jest.fn().mockReturnValueOnce(false).mockReturnValue(true), + ensureRoles: jest.fn((roleDict, validRoles) => { + return validRoles.some((role) => roleDict[role]); + }), +})); + +import { main } from '../src/functions/delete/handler'; +import { createEvent, mockContext } from './helper'; +import * as util from '../src/util'; + +// Ensure util mocks are applied before any tests + +describe('Delete Tests', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const userData = { + auth_email: 'director@test.org', + auth_token: 'mockToken', + user_email: 'target@test.org', + }; + const path = '/delete'; + const httpMethod = 'POST'; + + const findOneMock = util.MongoDB.getInstance('uri').getCollection('users').findOne as jest.Mock; + const deleteOneMock = util.MongoDB.getInstance('uri').getCollection('users').deleteOne as jest.Mock; + + it('auth token is not valid', async () => { + const mockEvent = createEvent(userData, path, httpMethod); + + const result = await main(mockEvent, mockContext, undefined); + + expect(result.statusCode).toBe(401); + expect(JSON.parse(result.body).message).toBe('Unauthorized'); + }); + + it('user does not exist', async () => { + findOneMock.mockReturnValueOnce(null); + const mockEvent = createEvent(userData, path, httpMethod); + + const result = await main(mockEvent, mockContext, undefined); + + expect(result.statusCode).toBe(404); + expect(JSON.parse(result.body).message).toBe('User not found'); + }); + + it('Auth user does not have director/organizer role', async () => { + findOneMock.mockReturnValueOnce({}).mockReturnValueOnce({ + role: { + hacker: false, + volunteer: true, + judge: false, + sponsor: false, + mentor: false, + organizer: false, + director: false, + }, + }); + const mockEvent = createEvent(userData, path, httpMethod); + + const result = await main(mockEvent, mockContext, undefined); + + expect(result.statusCode).toBe(401); + expect(JSON.parse(result.body).message).toBe('Only directors/organizers can call this endpoint.'); + }); + + it('returns 500 when deleteOne does not delete', async () => { + findOneMock + .mockReturnValueOnce({ email: userData.user_email }) + .mockReturnValueOnce({ role: { hacker: false, organizer: false, director: true } }); + deleteOneMock.mockReturnValueOnce({ deletedCount: 0 }); + const mockEvent = createEvent(userData, path, httpMethod); + + const result = await main(mockEvent, mockContext, undefined); + + expect(result.statusCode).toBe(500); + expect(JSON.parse(result.body).message).toBe('Internal server error'); + }); + + it('deletes user successfully', async () => { + findOneMock + .mockReturnValueOnce({ email: userData.user_email }) + .mockReturnValueOnce({ role: { hacker: false, volunteer: true, organizer: true, director: false } }); + deleteOneMock.mockReturnValueOnce({ deletedCount: 1 }); + const mockEvent = createEvent(userData, path, httpMethod); + + const result = await main(mockEvent, mockContext, undefined); + + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body).message).toBe(`Deleted ${userData.user_email} successfully`); + }); +}); diff --git a/tests/interest-form.test.ts b/tests/interest-form.test.ts new file mode 100644 index 0000000..9bde9be --- /dev/null +++ b/tests/interest-form.test.ts @@ -0,0 +1,179 @@ +import { main } from '../src/functions/interest-form/handler'; +import { createEvent, mockContext } from './helper'; + +// Mock the MongoDB utility +jest.mock('../src/util', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + MongoDB: { + getInstance: jest.fn().mockReturnValue({ + connect: jest.fn(), + disconnect: jest.fn(), + getCollection: jest.fn().mockReturnValue({ + insertOne: jest.fn(), + }), + }), + }, +})); + +// Mock environment variables +jest.mock('dotenv', () => ({ + config: jest.fn(), +})); + +jest.mock('path', () => ({ + resolve: jest.fn().mockReturnValue('.env'), +})); + +import * as util from '../src/util'; + +describe('Submit Interest Form tests', () => { + beforeEach(() => { + jest.clearAllMocks(); + process.env.MONGO_URI = 'mongodb://test-uri'; + }); + + const validFormData = { + firstName: 'John', + lastName: 'Doe', + age: 20, + phoneNumber: '+1234567890', + email: 'john.doe@test.org', + school: 'Test University', + levelOfStudy: 'Undergraduate', + countryOfResidence: 'United States', + linkedInUrl: 'https://linkedin.com/in/johndoe', + mlh_code_of_conduct: true, + mlh_privacy_policy: true, + mlh_terms_and_conditions: true, + }; + + const path = 'interest-form'; + const httpMethod = 'POST'; + + // Mock references for easier testing + const connectMock = util.MongoDB.getInstance('uri').connect as jest.Mock; + const getCollectionMock = util.MongoDB.getInstance('uri').getCollection as jest.Mock; + const insertOneMock = util.MongoDB.getInstance('uri').getCollection('interest-forms').insertOne as jest.Mock; + const mockCallback = jest.fn(); + + it('successfully submits interest form', async () => { + const mockInsertResult = { + insertedId: 'mock-object-id-123', + }; + + insertOneMock.mockResolvedValue(mockInsertResult); + + const mockEvent = createEvent(validFormData, path, httpMethod); + const result = await main(mockEvent, mockContext, mockCallback); + + expect(connectMock).toHaveBeenCalled(); + expect(getCollectionMock).toHaveBeenCalledWith('interest-forms'); + expect(insertOneMock).toHaveBeenCalledWith(validFormData); + + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body).message).toBe('Successful Form Submission'); + expect(JSON.parse(result.body).submissionId).toBe('mock-object-id-123'); + }); + + it('successfully submits form with different valid data', async () => { + const differentValidData = { + firstName: 'Jane', + lastName: 'Smith', + age: 22, + phoneNumber: '+9876543210', + email: 'jane.smith@university.edu', + school: 'Different University', + levelOfStudy: 'Graduate', + countryOfResidence: 'Canada', + linkedInUrl: 'https://linkedin.com/in/janesmith', + mlh_code_of_conduct: true, + mlh_privacy_policy: true, + mlh_terms_and_conditions: false, + }; + + const mockInsertResult = { + insertedId: 'mock-object-id-456', + }; + + insertOneMock.mockResolvedValue(mockInsertResult); + + const mockEvent = createEvent(differentValidData, path, httpMethod); + const result = await main(mockEvent, mockContext, mockCallback); + + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body).message).toBe('Successful Form Submission'); + expect(insertOneMock).toHaveBeenCalledWith(differentValidData); + }); + + it('successfully submits form with minimal required fields', async () => { + const minimalFormData = { + firstName: 'Bob', + lastName: 'Johnson', + age: 19, + phoneNumber: '+1555123456', + email: 'bob.johnson@college.edu', + school: 'Community College', + levelOfStudy: 'Undergraduate', + countryOfResidence: 'United States', + linkedInUrl: '', // Optional field - empty string + mlh_code_of_conduct: true, + mlh_privacy_policy: true, + mlh_terms_and_conditions: true, + }; + + const mockInsertResult = { + insertedId: 'mock-object-id-minimal', + }; + + insertOneMock.mockResolvedValue(mockInsertResult); + + const mockEvent = createEvent(minimalFormData, path, httpMethod); + const result = await main(mockEvent, mockContext, mockCallback); + + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body).message).toBe('Successful Form Submission'); + expect(insertOneMock).toHaveBeenCalledWith(minimalFormData); + }); + + it('handles invalid data causing runtime error', async () => { + // Simulate invalid data that somehow bypasses schema validation + const invalidFormData = { + firstName: 'Test', + lastName: 'Test', + age: 20, + phoneNumber: '+1234567890', + email: 'test@test.org', + school: 'Test School', + levelOfStudy: 'Undergraduate', + countryOfResidence: 'Test Country', + linkedInUrl: 'https://linkedin.com/in/HelloWorld', + mlh_code_of_conduct: 'HI', // Invalid boolean value + mlh_privacy_policy: true, + mlh_terms_and_conditions: true, + }; + + // Mock insertOne to throw an error due to invalid data + insertOneMock.mockRejectedValue(new Error('Invalid data format')); + + const mockEvent = createEvent(invalidFormData, path, httpMethod); + const result = await main(mockEvent, mockContext, mockCallback); + + expect(result.statusCode).toBe(500); + expect(JSON.parse(result.body).message).toBe('Internal Server Error'); + expect(JSON.parse(result.body).error).toBe('Invalid data format'); + }); + it('successfully submits form with valid LinkedIn URL', async () => { + const mockInsertResult = { + insertedId: 'mock-object-id-123', + }; + + insertOneMock.mockResolvedValue(mockInsertResult); + + const mockEvent = createEvent(validFormData, path, httpMethod); + const result = await main(mockEvent, mockContext, mockCallback); + + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body).message).toBe('Successful Form Submission'); + expect(insertOneMock).toHaveBeenCalledWith(validFormData); + }); +}); diff --git a/tests/read.test.ts b/tests/read.test.ts index cdf6591..e19285d 100644 --- a/tests/read.test.ts +++ b/tests/read.test.ts @@ -11,6 +11,9 @@ jest.mock('../src/util', () => ({ disconnect: jest.fn(), getCollection: jest.fn().mockReturnValue({ findOne: jest.fn(), + find: jest.fn().mockReturnValue({ + toArray: jest.fn(), + }), }), }), }, @@ -29,6 +32,7 @@ describe('/read endpoint', () => { const findOneMock = util.MongoDB.getInstance('uri').getCollection('users').findOne as jest.Mock; const mockCallback = jest.fn(); + const findMock = util.MongoDB.getInstance('uri').getCollection('users').find as jest.Mock; it('invalid token', async () => { const userData = { @@ -148,6 +152,35 @@ describe('/read endpoint', () => { expect(res.body).toBeDefined(); }); + it('success case for all lookup', async () => { + // For the "all" branch, auth user must have director or organizer role. + findOneMock.mockReturnValueOnce({ + email: 'director@hackru.org', + role: { + hacker: true, + organizer: false, + director: true, + }, + }); + const allUsers = [ + { email: 'user1@hackru.org', name: 'User One' }, + { email: 'user2@hackru.org', name: 'User Two' }, + ]; + const toArrayMock = jest.fn().mockResolvedValue(allUsers); + findMock.mockReturnValueOnce({ toArray: toArrayMock }); + const userData = { + auth_email: 'director@hackru.org', + auth_token: 'mockToken', + email: 'anyemail@hackru.org', // email is not used in the lookup when all=true + all: true, + }; + const mockEvent = createEvent(userData, path, httpMethod); + const res = await main(mockEvent, mockContext, mockCallback); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toEqual(allUsers); + }); + it('internal server error', async () => { findOneMock.mockImplementationOnce(() => { throw new Error('Database error'); diff --git a/tests/update-buy-ins.test.ts b/tests/update-buy-ins.test.ts index 39449e5..b54e04d 100644 --- a/tests/update-buy-ins.test.ts +++ b/tests/update-buy-ins.test.ts @@ -96,8 +96,45 @@ describe('Update-Buy-Ins tests', () => { expect(result.statusCode).toBe(403); expect(JSON.parse(result.body).message).toBe('Points distributed exceed user point total.'); }); + //case 5 + it('non-numeric value entered for input', async () => { + findOneMock.mockReturnValueOnce({ + email: userData.email, + total_points: 30, + balance: 2, + buy_ins: [ + { prize_id: 'prize1', buy_in: 'random' }, + { prize_id: 'prize2', buy_in: 20 }, + ], + }); - // case 5 + const mockEvent = createEvent(userData, path, httpMethod); + + const result = await main(mockEvent, mockContext, mockCallback); + + expect(result.statusCode).toBe(403); + expect(JSON.parse(result.body).message).toBe('Requested point change is not a valid integer input'); + }); + // case 6 + it('Buy-in points out of range', async () => { + findOneMock.mockReturnValueOnce({ + email: userData.email, + total_points: 30, + balance: 2, + buy_ins: [ + { prize_id: 'prize1', buy_in: 1500 }, + { prize_id: 'prize2', buy_in: 20 }, + ], + }); + + const mockEvent = createEvent(userData, path, httpMethod); + + const result = await main(mockEvent, mockContext, mockCallback); + + expect(result.statusCode).toBe(403); + expect(JSON.parse(result.body).message).toBe('Requested point change is not in a valid point range'); + }); + // case 7 it('successfully update user point balance', async () => { findOneMock.mockReturnValueOnce({ email: userData.email, diff --git a/tests/update.test.ts b/tests/update.test.ts index cf038e1..c77426b 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -261,6 +261,7 @@ describe('/update endpoint', () => { }, }, }; + jest.clearAllMocks(); findOneMock.mockReturnValue({ //successful update @@ -298,4 +299,21 @@ describe('/update endpoint', () => { const res = await main(mockEvent, mockContext, mockCallback); expect(res.statusCode).toBe(200); }); + //case 9 + it('Invalid email format', async () => { + const completeUserData = { + user_email: 'test@test.org', + auth_email: 'testAuth@test.org', + auth_token: 'sampleAuthToken', + updates: { + $set: { + email: 'randomVal', + }, + }, + }; + const mockEvent = createEvent(completeUserData, '/update', 'POST'); + const res = await main(mockEvent, mockContext, mockCallback); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('Improper Email format'); + }); }); diff --git a/tests/user-exists.test.ts b/tests/user-exists.test.ts new file mode 100644 index 0000000..15bf04c --- /dev/null +++ b/tests/user-exists.test.ts @@ -0,0 +1,116 @@ +import { main } from '../src/functions/user-exists/handler'; + +import { createEvent, mockContext } from './helper'; +import * as util from '../src/util'; + +jest.mock('../src/util', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + MongoDB: { + getInstance: jest.fn().mockReturnValue({ + connect: jest.fn(), + disconnect: jest.fn(), + getCollection: jest.fn().mockReturnValue({ + findOne: jest.fn(), + find: jest.fn().mockReturnValue({ + toArray: jest.fn(), + }), + }), + }), + }, + validateToken: jest.fn().mockReturnValueOnce(false).mockReturnValue(true), +})); + +describe('/user-exists endpoint', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + const path = '/user-exists'; + const httpMethod = 'POST'; + + const findOneMock = util.MongoDB.getInstance('uri').getCollection('users').findOne as jest.Mock; + const mockCallback = jest.fn(); + + it('invalid token', async () => { + const userData = { + auth_email: 'hacker@hackru.org', + auth_token: 'mockToken', + email: 'hacker@hackru.org', + }; + const mockEvent = createEvent(userData, path, httpMethod); + + const res = await main(mockEvent, mockContext, mockCallback); + expect(res.statusCode).toBe(401); + expect(JSON.parse(res.body).message).toBe('Unauthorized'); + }); + + it('auth user not found', async () => { + findOneMock.mockReturnValueOnce(null); + const userData = { + auth_email: 'non-existent-user@hackru.org', + auth_token: 'mockToken', + email: 'hacker@hackru.org', + }; + const mockEvent = createEvent(userData, path, httpMethod); + + const res = await main(mockEvent, mockContext, mockCallback); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body).message).toBe('Auth user not found.'); + }); + + it('look-up user not found', async () => { + findOneMock + .mockReturnValueOnce({ + email: 'hackerCheck@hackru.org', + }) + .mockReturnValueOnce(null); + const userData = { + auth_email: 'hackerCheck@hackru.org', + auth_token: 'mockToken', + email: 'non-existent-user@hackru.org', + }; + const mockEvent = createEvent(userData, path, httpMethod); + + const res = await main(mockEvent, mockContext, mockCallback); + + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body).message).toBe('Look-up user was not found'); + }); + + it('success case', async () => { + //can check even if you don't have the admin role + findOneMock + .mockReturnValueOnce({ + email: 'hackerCheck@hackru.org', + }) + .mockReturnValueOnce({}); + const userData = { + auth_email: 'hackerCheck@hackru.org', + auth_token: 'mockToken', + email: 'hacker@hackru.org', + }; + const mockEvent = createEvent(userData, path, httpMethod); + + const res = await main(mockEvent, mockContext, mockCallback); + + expect(res.statusCode).toBe(200); + expect(res.body).toBeDefined(); + }); + + it('success case for all lookup', async () => { + findOneMock + .mockReturnValueOnce({ + email: 'hackerCheck@hackru.org', + }) + .mockReturnValueOnce({ email: 'targetHacker@hackru.org' }); + const userData = { + auth_email: 'hackerCheck@hackru.org', + auth_token: 'mockToken', + email: 'anyemail@hackru.org', + }; + const mockEvent = createEvent(userData, path, httpMethod); + const res = await main(mockEvent, mockContext, mockCallback); + console.log(res.body); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toEqual('User exists'); + }); +});