diff --git a/api/config/config.example.json b/api/config/config.example.json index adb32153e..60c2ded22 100644 --- a/api/config/config.example.json +++ b/api/config/config.example.json @@ -29,5 +29,8 @@ "officeAccessCard": { "API_KEY": "NOTHING_REALLY" }, + "LED_SIGN": { + "ENABLED": false + }, "secretKey": "super duper secret key" } \ No newline at end of file diff --git a/api/main_endpoints/routes/Advertisement.js b/api/main_endpoints/routes/Advertisement.js index 09bb7bb75..0339e481d 100644 --- a/api/main_endpoints/routes/Advertisement.js +++ b/api/main_endpoints/routes/Advertisement.js @@ -1,14 +1,12 @@ const express = require('express'); const router = express.Router(); const { OK, BAD_REQUEST, FORBIDDEN, UNAUTHORIZED, NOT_FOUND } = require('../../util/constants').STATUS_CODES; -const { - decodeToken, - checkIfTokenSent, -} = require('../util/token-functions.js'); +const { decodeToken } = require('../util/token-functions.js'); const logger = require('../../util/logger'); const Advertisement = require('../models/Advertisement'); const AuditLog = require('../models/AuditLog.js'); const AuditLogActions = require('../util/auditLogActions.js'); +const membershipState = require('../../util/constants.js').MEMBERSHIP_STATE; router.get('/', async (req, res) => { const count = await Advertisement.countDocuments(); @@ -26,10 +24,9 @@ router.get('/', async (req, res) => { router.get('/getAllAdvertisements', async (req, res) => { - if (!checkIfTokenSent(req)) { - return res.sendStatus(FORBIDDEN); - } else if (!await decodeToken(req)) { - return res.sendStatus(UNAUTHORIZED); + const decoded = await decodeToken(req, membershipState.OFFICER); + if (decoded.status !== OK) { + return res.sendStatus(decoded.status); } Advertisement.find() .sort({ createdAt: -1 }) @@ -41,13 +38,9 @@ router.get('/getAllAdvertisements', async (req, res) => { }); router.post('/createAdvertisement', async (req, res) => { - if (!checkIfTokenSent(req)) { - return res.sendStatus(FORBIDDEN); - } - - const user = await decodeToken(req); - if (!user) { - return res.sendStatus(UNAUTHORIZED); + const decoded = await decodeToken(req, membershipState.OFFICER); + if (decoded.status !== OK) { + return res.sendStatus(decoded.status); } const newAd = new Advertisement({ @@ -58,7 +51,7 @@ router.post('/createAdvertisement', async (req, res) => { try { const createdAd = await Advertisement.create(newAd); AuditLog.create({ - userId: user._id, + userId: decoded.token._id, action: AuditLogActions.CREATE_AD, details: { message: createdAd.message, @@ -75,15 +68,9 @@ router.post('/createAdvertisement', async (req, res) => { }); router.post('/deleteAdvertisement', async (req, res) => { - if (!checkIfTokenSent(req)) { - return res.sendStatus(FORBIDDEN); - } else if (!await decodeToken(req)) { - return res.sendStatus(UNAUTHORIZED); - } - - const user = await decodeToken(req); - if (!user) { - return res.sendStatus(UNAUTHORIZED); + const decoded = await decodeToken(req, membershipState.OFFICER); + if (decoded.status !== OK) { + return res.sendStatus(decoded.status); } try { @@ -94,7 +81,7 @@ router.post('/deleteAdvertisement', async (req, res) => { } AuditLog.create({ - userId: user._id, + userId: decoded.token._id, action: AuditLogActions.DELETE_AD, details: { deletedAd: { diff --git a/api/main_endpoints/routes/AuditLog.js b/api/main_endpoints/routes/AuditLog.js index edf306ab3..05b6b429b 100644 --- a/api/main_endpoints/routes/AuditLog.js +++ b/api/main_endpoints/routes/AuditLog.js @@ -3,25 +3,18 @@ const router = express.Router(); const AuditLog = require('../models/AuditLog'); const { OK, UNAUTHORIZED, SERVER_ERROR } = require('../../util/constants').STATUS_CODES; -const { OFFICER } = require('../../util/constants.js').MEMBERSHIP_STATE; +const membershipState = require('../../util/constants.js').MEMBERSHIP_STATE; -const { checkIfTokenSent, checkIfTokenValid, decodeTokenFromBodyOrQuery } = require('../util/token-functions.js'); +const { decodeToken } = require('../util/token-functions.js'); const logger = require('../../util/logger'); const User = require('../models/User.js'); let { clients } = require('../util/AuditLog.js'); router.get('/getAuditLogs', async (req, res) => { - if (!checkIfTokenSent(req)) { - logger.warn('/getAuditLogs was requested without a token'); - return res.sendStatus(UNAUTHORIZED); - } - - const isValid = checkIfTokenValid(req, OFFICER); - - if (!isValid) { - logger.warn('/getAuditLogs was requested with an invalid or unauthorized token'); - return res.sendStatus(UNAUTHORIZED); + const decoded = await decodeToken(req, membershipState.OFFICER); + if (decoded.status !== OK) { + return res.sendStatus(decoded.status); } const itemsPerPage = 50; @@ -73,9 +66,9 @@ router.get('/getAuditLogs', async (req, res) => { }); router.get('/listen', async (req, res) => { - const decoded = await decodeTokenFromBodyOrQuery(req); - if (!Object.keys(decoded) || decoded.accessLevel < OFFICER) { - return res.sendStatus(UNAUTHORIZED); + const decoded = await decodeToken(req, membershipState.OFFICER); + if (decoded.status !== OK) { + return res.sendStatus(decoded.status); } const headers = { diff --git a/api/main_endpoints/routes/Auth.js b/api/main_endpoints/routes/Auth.js index 98f945c12..12e2c4633 100644 --- a/api/main_endpoints/routes/Auth.js +++ b/api/main_endpoints/routes/Auth.js @@ -11,11 +11,7 @@ const PasswordReset = require('../models/PasswordReset.js'); const logger = require('../../util/logger'); const { registerUser, testPasswordStrength } = require('../util/userHelpers'); const { verifyCaptcha } = require('../util/captcha'); -const { - checkIfTokenSent, - checkIfTokenValid, - decodeToken -} = require('../util/token-functions'); +const { decodeToken } = require('../util/token-functions'); const jwt = require('jsonwebtoken'); const { OK, @@ -60,10 +56,9 @@ router.post('/register', async (req, res) => { }); router.post('/resendVerificationEmail', async (req, res) => { - if (!checkIfTokenSent(req)) { - return res.sendStatus(FORBIDDEN); - } else if (!checkIfTokenValid(req, membershipState.OFFICER)) { - return res.sendStatus(UNAUTHORIZED); + const decoded = await decodeToken(req, membershipState.OFFICER); + if (decoded.status !== OK) { + return res.sendStatus(decoded.status); } const maybeUser = await userWithEmailExists(req.body.email); if (!maybeUser) { @@ -236,19 +231,15 @@ router.post('/login', function(req, res) { }); }); -// Verifies the users session if they have an active jwtToken. +// Verifies the users session if they have an active jwt. // Used on the inital load of root '/' // Returns the name and accesslevel of the user w/ the given access token -router.post('/verify', function(req, res) { - if (!checkIfTokenSent(req)) { - return res.status(UNAUTHORIZED).json({}); - } - const token = decodeToken(req); - if (token === null || Object.keys(token).length === 0) { - res.status(UNAUTHORIZED).json({}); - } else { - res.status(OK).json(token); +router.post('/verify', async function(req, res) { + const decoded = await decodeToken(req); + if (decoded.status !== OK) { + return res.sendStatus(decoded.status); } + res.status(OK).json(decoded.token); }); router.post('/generateHashedId', async (req, res) => { diff --git a/api/main_endpoints/routes/Cleezy.js b/api/main_endpoints/routes/Cleezy.js index 8c2091446..e634f55f5 100644 --- a/api/main_endpoints/routes/Cleezy.js +++ b/api/main_endpoints/routes/Cleezy.js @@ -1,10 +1,7 @@ const express = require('express'); const axios = require('axios'); const router = express.Router(); -const { - decodeToken, - checkIfTokenSent, -} = require('../util/token-functions.js'); +const { decodeToken } = require('../util/token-functions.js'); const { OK, UNAUTHORIZED, @@ -15,6 +12,7 @@ const logger = require('../../util/logger'); const { Cleezy } = require('../../config/config.json'); const { ENABLED } = Cleezy; const cleezyHelpers = require('../util/cleezyHelpers.js'); +const { MEMBERSHIP_STATE } = require('../../util/constants.js'); let CLEEZY_URL = process.env.CLEEZY_URL || 'http://localhost:8000'; @@ -28,10 +26,9 @@ router.get('/list', async (req, res) => { }); } const { page = 0, search, sortColumn = 'created_at', sortOrder = 'DESC'} = req.query; - if (!checkIfTokenSent(req)) { - return res.sendStatus(FORBIDDEN); - } else if (!await decodeToken(req)) { - return res.sendStatus(UNAUTHORIZED); + const decoded = await decodeToken(req, MEMBERSHIP_STATE.OFFICER); + if (decoded.status !== OK) { + return res.sendStatus(decoded.status); } try { const returnData = await cleezyHelpers.searchCleezyUrls({ page, search, sortColumn, sortOrder }); @@ -47,10 +44,9 @@ router.get('/list', async (req, res) => { }); router.post('/createUrl', async (req, res) => { - if (!checkIfTokenSent(req)) { - return res.sendStatus(FORBIDDEN); - } else if (!await decodeToken(req)) { - return res.sendStatus(UNAUTHORIZED); + const decoded = await decodeToken(req, MEMBERSHIP_STATE.OFFICER); + if (decoded.status !== OK) { + return res.sendStatus(decoded.status); } const { url, alias, expiresAt } = req.body; let jsonbody = { url, alias: alias || null }; @@ -68,10 +64,9 @@ router.post('/createUrl', async (req, res) => { }); router.post('/deleteUrl', async (req, res) => { - if (!checkIfTokenSent(req)) { - return res.sendStatus(FORBIDDEN); - } else if (!await decodeToken(req)) { - return res.sendStatus(UNAUTHORIZED); + const decoded = await decodeToken(req, MEMBERSHIP_STATE.OFFICER); + if (decoded.status !== OK) { + return res.sendStatus(decoded.status); } const { alias } = req.body; axios diff --git a/api/main_endpoints/routes/LedSign.js b/api/main_endpoints/routes/LedSign.js index a04761432..89a309ee3 100644 --- a/api/main_endpoints/routes/LedSign.js +++ b/api/main_endpoints/routes/LedSign.js @@ -5,17 +5,17 @@ const { SERVER_ERROR, UNAUTHORIZED } = require('../../util/constants').STATUS_CODES; -const { - decodeToken, - checkIfTokenSent -} = require('../util/token-functions.js'); +const { decodeToken } = require('../util/token-functions.js'); const logger = require('../../util/logger'); const { updateSign, healthCheck, turnOffSign } = require('../util/LedSign.js'); const AuditLogActions = require('../util/auditLogActions.js'); const AuditLog = require('../models/AuditLog.js'); +const { + LED_SIGN = {} +} = require('../../config/config.json'); +const { MEMBERSHIP_STATE } = require('../../util/constants.js'); -const runningInDevelopment = process.env.NODE_ENV !== 'production' - && process.env.NODE_ENV !== 'test'; +const runningInTest = process.env.NODE_ENV === 'test'; router.get('/healthCheck', async (req, res) => { @@ -23,7 +23,8 @@ router.get('/healthCheck', async (req, res) => { * How these work with Quasar: * https://github.com/SCE-Development/Quasar/wiki/How-do-Health-Checks-Work%3F */ - if (runningInDevelopment) { + if (!LED_SIGN.ENABLED && !runningInTest) { + logger.warn('led sign is disabled, returning 200 by default'); return res.sendStatus(OK); } const dataFromSign = await healthCheck(); @@ -34,16 +35,13 @@ router.get('/healthCheck', async (req, res) => { }); router.post('/updateSignText', async (req, res) => { - if (!checkIfTokenSent(req)) { - logger.warn('/updateSignText was requested without a token'); - return res.sendStatus(UNAUTHORIZED); - } - const user = await decodeToken(req); // Store the user here - if (!user) { + const decoded = await decodeToken(req, MEMBERSHIP_STATE.OFFICER); + if (decoded.status !== OK) { logger.warn('/updateSignText was requested with an invalid token'); - return res.sendStatus(UNAUTHORIZED); + return res.sendStatus(decoded.status); } - if (runningInDevelopment) { + if (!LED_SIGN.ENABLED && !runningInTest) { + logger.warn('led sign is disabled, returning 200 by default'); return res.sendStatus(OK); } // need to make this its own api endpoint @@ -60,8 +58,8 @@ router.post('/updateSignText', async (req, res) => { status = SERVER_ERROR; } - AuditLog.create({ - userId: user._id, + await AuditLog.create({ + userId: decoded.token._id, action: AuditLogActions.UPDATE_SIGN, details: { newSignText: req.body.text, diff --git a/api/main_endpoints/routes/Messages.js b/api/main_endpoints/routes/Messages.js index 033cb3133..83e9e8183 100644 --- a/api/main_endpoints/routes/Messages.js +++ b/api/main_endpoints/routes/Messages.js @@ -11,7 +11,7 @@ const bodyParser = require('body-parser'); const User = require('../models/User.js'); const logger = require('../../util/logger'); const client = require('prom-client'); -const { decodeToken, decodeTokenFromBodyOrQuery } = require('../util/token-functions.js'); +const { decodeToken } = require('../util/token-functions.js'); const { MetricsHandler, register } = require('../../util/metrics.js'); @@ -80,11 +80,11 @@ router.post('/send', async (req, res) => { } // Assume user passed a non null/undefined token - const userObj = decodeToken(req); - if (!userObj) { + const userObj = await decodeToken(req); + if (!userObj.token) { return res.sendStatus(UNAUTHORIZED); } - nameToUse = userObj.firstName; + nameToUse = userObj.token.firstName; try { writeMessage(id, `${message}`, `${nameToUse}:`); return res.json({ status: 'Message sent' }); @@ -152,11 +152,11 @@ router.get('/listen', async (req, res) => { let filterQuery = {}; // filter to find user in the database if (token) { - let userObj = decodeTokenFromBodyOrQuery(req); - if (!Object.keys(userObj)) { + const userObj = await decodeToken(req); + if (!userObj.token) { return res.sendStatus(UNAUTHORIZED); } - filterQuery._id = userObj._id; + filterQuery._id = userObj.token._id; } else { filterQuery.apiKey = apiKey; } diff --git a/api/main_endpoints/routes/OfficeAccessCard.js b/api/main_endpoints/routes/OfficeAccessCard.js index 31531e7f0..cdd93e271 100644 --- a/api/main_endpoints/routes/OfficeAccessCard.js +++ b/api/main_endpoints/routes/OfficeAccessCard.js @@ -6,7 +6,7 @@ const { OK, FORBIDDEN, } = require('../../util/constants').STATUS_CODES; -const { OFFICER } = require('../../util/constants').MEMBERSHIP_STATE; +const membershipState = require('../../util/constants').MEMBERSHIP_STATE; const express = require('express'); const router = express.Router(); const bodyParser = require('body-parser'); @@ -14,17 +14,13 @@ const OfficeAccessCard = require('../models/OfficeAccessCard.js'); const logger = require('../../util/logger'); const { officeAccessCard = {} } = require('../../config/config.json'); const { API_KEY = 'NOTHING_REALLY' } = officeAccessCard; -const { - decodeTokenFromBodyOrQuery, - decodeToken, - checkIfTokenSent, - checkIfTokenValid -} = require('../util/token-functions.js'); +const { decodeToken } = require('../util/token-functions.js'); const ROWS_PER_PAGE = 25; const { checkIfCardExists, generateAlias, deleteCard, + editAlias, } = require('../util/OfficeAccessCard.js'); const AuditLogActions = require('../util/auditLogActions.js'); const AuditLog = require('../models/AuditLog.js'); @@ -87,10 +83,10 @@ router.get('/verify', async (req, res) => { if (apiKey !== API_KEY) { writeLogToClient(req.method, { - statusCode: UNAUTHORIZED, + statusCode: FORBIDDEN, message: `Invalid API key: ${apiKey}`, }); - return res.sendStatus(UNAUTHORIZED); + return res.sendStatus(FORBIDDEN); } const cardExists = await checkIfCardExists({ cardBytes }); @@ -142,9 +138,9 @@ router.get('/verify', async (req, res) => { }); router.post('/delete', async (req, res) => { - const decoded = decodeToken(req); - if (!decoded) { - return res.sendStatus(UNAUTHORIZED); + const decoded = await decodeToken(req, membershipState.OFFICER); + if (decoded.status !== OK) { + return res.sendStatus(decoded.status); } const { alias } = req.body; @@ -173,7 +169,7 @@ router.post('/delete', async (req, res) => { statusCode: OK, }); AuditLog.create({ - userId: decoded._id, + userId: decoded.token._id, action: AuditLogActions.DELETE_CARD, details: { alias } }); @@ -189,10 +185,9 @@ router.post('/delete', async (req, res) => { }); router.post('/getAllCards', async (req, res) => { - if (!checkIfTokenSent(req)) { - return res.sendStatus(FORBIDDEN); - } else if (!checkIfTokenValid(req)) { - return res.sendStatus(UNAUTHORIZED); + const decoded = await decodeToken(req, membershipState.OFFICER); + if (decoded.status !== OK) { + return res.sendStatus(decoded.status); } const skip = Math.max(Number(req.body.page) || 0, 0) * ROWS_PER_PAGE; @@ -215,10 +210,60 @@ router.post('/getAllCards', async (req, res) => { } }); +router.post('/edit', async (req, res) => { + const decoded = await decodeToken(req, membershipState.OFFICER); + if (decoded.status !== OK) { + return res.sendStatus(decoded.status); + } + + const { _id, alias } = req.body; + + const required = [ + { value: _id && /^[0-9a-fA-F]{24}$/.test(_id) ? _id : null, title: 'Valid, alphanumeric Card ID', }, + { value: alias?.trim(), title: 'New card alias', }, + ]; + + const missingValue = required.find(({ value }) => !value); + if (missingValue) { + writeLogToClient(req.method, { + statusCode: BAD_REQUEST, + message: `${missingValue.title} missing from request`, + }); + return res.status(BAD_REQUEST).send(`${missingValue.title} missing from request`); + } + + try { + const updatedCard = await editAlias(_id, alias); + + if (!updatedCard) { + return res.sendStatus(NOT_FOUND); + } + + // Log the edit action + AuditLog.create({ + userId: decoded.token._id, + action: AuditLogActions.EDIT_CARD, + details: { + newAlias: alias, + _id, + } + }); + + logger.info(`Card alias updated successfully for card ID: ${_id}`); + return res.status(OK).json({ + message: 'Card alias updated successfully', + card: updatedCard + }); + } catch (error) { + logger.error('Error updating card alias: ', error); + return res.status(SERVER_ERROR).send('Error updating card alias'); + } +}); + router.get('/listen', async (req, res) => { - const decoded = await decodeTokenFromBodyOrQuery(req); - if (!Object.keys(decoded) || decoded.accessLevel < OFFICER) { - return res.sendStatus(UNAUTHORIZED); + const decoded = await decodeToken(req, membershipState.OFFICER); + if (decoded.status !== OK) { + return res.sendStatus(decoded.status); } const headers = { diff --git a/api/main_endpoints/routes/Printer.js b/api/main_endpoints/routes/Printer.js index 73a5c97f7..da9c82bd1 100644 --- a/api/main_endpoints/routes/Printer.js +++ b/api/main_endpoints/routes/Printer.js @@ -8,10 +8,7 @@ const path = require('path'); const { MetricsHandler, register } = require('../../util/metrics.js'); const { cleanUpChunks, cleanUpExpiredChunks, recordPrintingFolderSize } = require('../util/Printer.js'); -const { - decodeToken, - checkIfTokenSent, -} = require('../util/token-functions.js'); +const { decodeToken } = require('../util/token-functions.js'); const { OK, UNAUTHORIZED, @@ -74,14 +71,12 @@ router.get('/healthCheck', async (req, res) => { }); router.post('/sendPrintRequest', upload.single('chunk'), async (req, res) => { - if (!checkIfTokenSent(req)) { - logger.warn('/sendPrintRequest was requested without a token'); - return res.sendStatus(UNAUTHORIZED); - } - if (!await decodeToken(req)) { + const decoded = await decodeToken(req); + if (!decoded.token) { logger.warn('/sendPrintRequest was requested with an invalid token'); - return res.sendStatus(UNAUTHORIZED); + return res.sendStatus(decoded.status); } + // this makes printing pass in unit tests, at some point need to test axios call if (!PRINTING.ENABLED) { logger.warn('Printing is disabled, returning 200 and dummy print id to mock the printing server'); return res.status(OK).send({ printId: null }); diff --git a/api/main_endpoints/routes/ShortcutSearch.js b/api/main_endpoints/routes/ShortcutSearch.js index e2e854c70..20e60503a 100644 --- a/api/main_endpoints/routes/ShortcutSearch.js +++ b/api/main_endpoints/routes/ShortcutSearch.js @@ -3,10 +3,7 @@ const express = require('express'); const router = express.Router(); const User = require('../models/User.js'); -const { - checkIfTokenSent, - checkIfTokenValid, -} = require('../util/token-functions'); +const { decodeToken } = require('../util/token-functions'); const { OK, UNAUTHORIZED, @@ -23,10 +20,9 @@ const MAX_RESULT = 5; // Search for all members using either first name, last name or email // Search for all cleezy urls using either alias or url router.post('/', async function(req, res) { - if (!checkIfTokenSent(req)) { - return res.sendStatus(FORBIDDEN); - } else if (!checkIfTokenValid(req, membershipState.OFFICER)) { - return res.sendStatus(UNAUTHORIZED); + const decoded = await decodeToken(req, membershipState.OFFICER); + if (!decoded.token) { + return res.sendStatus(decoded.status); } if (!req.body.query) { @@ -122,10 +118,14 @@ router.post('/', async function(req, res) { search: req.body.query, limit: MAX_RESULT }); + let cleezyData = []; + if (cleezyRes.data) { + cleezyData = cleezyRes.data; + } return res.status(OK).send({ items: { users, - cleezyData: cleezyRes.data, + cleezyData, } }); } catch (error) { diff --git a/api/main_endpoints/routes/User.js b/api/main_endpoints/routes/User.js index a9c0d37b5..1a48d45f7 100644 --- a/api/main_endpoints/routes/User.js +++ b/api/main_endpoints/routes/User.js @@ -9,11 +9,7 @@ const { getMemberExpirationDate, hashPassword, } = require('../util/userHelpers'); -const { - checkIfTokenSent, - checkIfTokenValid, - decodeToken, -} = require('../util/token-functions'); +const { decodeToken } = require('../util/token-functions'); const { OK, BAD_REQUEST, @@ -36,31 +32,31 @@ const ROWS_PER_PAGE = 20; // Delete a member router.post('/delete', async (req, res) => { - if (!checkIfTokenSent(req)) { - return res.sendStatus(FORBIDDEN); - } else if (!checkIfTokenValid(req)) { - return res.sendStatus(UNAUTHORIZED); + const decoded = await decodeToken(req); + if (!decoded.token) { + return res.sendStatus(decoded.status); } - const decoded = decodeToken(req); const targetUser = await User.findById(req.body._id); if (!targetUser) { return res.sendStatus(NOT_FOUND); } - // Check if req has lower privilege than the account they wish to delete - if (targetUser.accessLevel !== 'undefined') { - if (decoded.accessLevel < targetUser.accessLevel) { + + // If not officer, only allow deletion of own account + if (decoded.token.accessLevel < membershipState.OFFICER) { + if (req.body._id && req.body._id !== decoded.token._id) { return res .status(FORBIDDEN) - .json( { message: 'you must have higher privileges to delete users with lower privileges'}); + .json({ message: 'you must be an officer or admin to delete other users' }); } } - // If not officer, only allow deletion of own account - if (decoded.accessLevel < membershipState.OFFICER) { - if (req.body._id && req.body._id !== decoded._id) { + + // Check if req has lower privilege than the account they wish to delete + if (targetUser.accessLevel !== 'undefined') { + if (decoded.token.accessLevel < targetUser.accessLevel) { return res .status(FORBIDDEN) - .json({ message: 'you must be an officer or admin to delete other users' }); + .json( { message: 'you must have higher privileges to delete users with lower privileges'}); } } @@ -78,21 +74,21 @@ router.post('/delete', async (req, res) => { }); // Search for a member -router.post('/search', function(req, res) { - if (!checkIfTokenSent(req)) { - return res.sendStatus(FORBIDDEN); - } else if (!checkIfTokenValid(req, membershipState.ALUMNI)) { - return res.sendStatus(UNAUTHORIZED); +router.post('/search', async function(req, res) { + const decoded = await decodeToken(req, membershipState.OFFICER); + + if (!decoded.token) { + return res.sendStatus(decoded.status); } + User.findOne({ email: req.body.email }, function(error, result) { if (error) { - res.status(BAD_REQUEST).send({ message: 'Bad Request.' }); + return res.sendStatus(BAD_REQUEST); } if (!result) { return res - .status(NOT_FOUND) - .send({ message: `${req.body.email} not found.` }); + .sendStatus(NOT_FOUND); } const user = { @@ -121,11 +117,11 @@ router.post('/search', function(req, res) { // Search for all members router.post('/users', async function(req, res) { - if (!checkIfTokenSent(req)) { - return res.sendStatus(FORBIDDEN); - } else if (!checkIfTokenValid(req)) { - return res.sendStatus(UNAUTHORIZED); + const decoded = await decodeToken(req, membershipState.OFFICER); + if (!decoded.token) { + return res.sendStatus(decoded.status); } + let maybeOr = {}; if (req.body.query) { maybeOr = { @@ -164,136 +160,138 @@ router.post('/users', async function(req, res) { // Edit/Update a member record router.post('/edit', async (req, res) => { - if (!checkIfTokenSent(req)) { - return res.sendStatus(FORBIDDEN); - } else if (!checkIfTokenValid(req)) { - return res.sendStatus(UNAUTHORIZED); + const decoded = await decodeToken(req); + if (!decoded.token) { + return res.sendStatus(decoded.status); } - if (!req.body._id) { + const { accessLevel, _id: tokenId, email: tokenEmail } = decoded.token; + const { _id: targetId, password, numberOfSemestersToSignUpFor, ...userData } = req.body; + const isOfficer = accessLevel >= membershipState.OFFICER; + const isTargetAdmin = accessLevel === membershipState.ADMIN; + + if (!targetId) { return res.sendStatus(BAD_REQUEST); } - let decoded = decodeToken(req); - if (decoded.accessLevel < membershipState.OFFICER) { - if (req.body.email && req.body.email != decoded.email) { - return res - .status(UNAUTHORIZED) - .send('Unauthorized to edit another user'); - } - if (req.body.accessLevel && req.body.accessLevel !== decoded.accessLevel) { - return res - .status(UNAUTHORIZED) - .send('Unauthorized to change access level'); - } + const existingUser = await User.findById(targetId); + if (!existingUser) { + return res.status(NOT_FOUND).send({ message: 'User not found.' }); } - if (decoded.accessLevel === membershipState.OFFICER) { - if (req.body.accessLevel && req.body.accessLevel == membershipState.ADMIN) { - return res.sendStatus(UNAUTHORIZED); - } + if (!isOfficer && targetId.toString() !== tokenId.toString()) { + return res + .status(FORBIDDEN) + .send('Unauthorized to edit another user'); } - const query = { _id: req.body._id }; - let user = req.body; + // Members cannot change email or accessLevel + if (!isOfficer && (userData.email || userData.accessLevel)) { + return res.status(UNAUTHORIZED).send('Unauthorized to change sensitive fields'); + } - // keep track of user in db - const existingUser = await User.findById(req.body._id); - if (!existingUser) { - return res.status(NOT_FOUND).send({ message: 'User not found.' }); + // Officers cannot change accessLevel to ADMIN + if (isOfficer && userData.accessLevel === membershipState.ADMIN && !isTargetAdmin) { + return res.sendStatus(UNAUTHORIZED); } - // Track field changes + // Prepare Data for Update (Sanitization) + const allowedFields = [ + 'firstName', 'lastName', 'email', 'accessLevel', 'major', + 'discordID', 'emailOptIn', 'membershipValidUntil' + ]; + + const dataToUpdate = {}; const fieldChanges = {}; - const fieldsToTrack = ['firstName', 'lastName', 'email', 'accessLevel', 'major', 'discordID', 'emailOptIn', 'membershipValidUntil']; - fieldsToTrack.forEach(field => { - if (user[field] !== undefined && user[field] !== existingUser[field]) { - fieldChanges[field] = { - from: existingUser[field], - to: user[field] - }; + // Iterate through allowed fields and build the update object and audit log + allowedFields.forEach(field => { + // Only include the field if it was provided in the request body + if (userData[field] !== undefined) { + // Check if value actually changed for audit + if (userData[field] !== existingUser[field]) { + fieldChanges[field] = { from: existingUser[field], to: userData[field] }; + } + dataToUpdate[field] = userData[field]; } }); - if (typeof req.body.numberOfSemestersToSignUpFor !== 'undefined') { - user.membershipValidUntil = getMemberExpirationDate( - parseInt(req.body.numberOfSemestersToSignUpFor) + // Handle special membership duration field + if (typeof numberOfSemestersToSignUpFor !== 'undefined' && isOfficer) { + dataToUpdate.membershipValidUntil = getMemberExpirationDate( + parseInt(numberOfSemestersToSignUpFor) ); + // Audit the implicit change + if (existingUser.membershipValidUntil !== dataToUpdate.membershipValidUntil) { + fieldChanges.membershipValidUntil = { + from: existingUser.membershipValidUntil, + to: dataToUpdate.membershipValidUntil + }; + } } - delete user.numberOfSemestersToSignUpFor; - - if (!!user.password) { - // hash the password before storing - const result = await hashPassword(user.password); - if (!result) { + // Handle Password Hashing and Audit + if (password) { + const hashedPassword = await hashPassword(password); + if (!hashedPassword) { return res.sendStatus(SERVER_ERROR); } - user.password = result; + dataToUpdate.password = hashedPassword; - // create audit log for password change + // Create audit log for password change AuditLog.create({ - userId: decoded._id, + userId: tokenId, action: AuditLogActions.CHANGE_PW, - details: { email: existingUser.email, userId: decoded._id }, + details: { email: existingUser.email, userId: tokenId }, }).catch(logger.error); - - } else { - // omit password from the object if it is falsy - // i.e. an empty string, undefined or null - delete user.password; } - // Remove the auth token from the form getting edited - delete user.token; - - User.updateOne(query, { ...user }, function(error, result) { - if (error) { - const info = { - errorTime: new Date(), - apiEndpoint: 'user/edit', - errorDescription: error - }; - - res.status(BAD_REQUEST).send({ message: 'Bad Request.' }); - } + // If no fields are actually changing (excluding the token, which was removed above) + if (Object.keys(dataToUpdate).length === 0 && !password) { + return res.status(OK).send({ message: 'No changes submitted.' }); + } - if (result.nModified < 1) { - return res - .status(NOT_FOUND) - .send({ message: `${existingUser.email} not found.` }); + try { + const result = await User.updateOne({ _id: targetId }, dataToUpdate); + + // Check if the update actually modified a document + if (result.nModified < 1 && result.matchedCount > 0) { + // Matched but not modified means no fields actually changed. + // We can safely treat this as a success if no error occurred. + } else if (result.nModified < 1 && result.matchedCount < 1) { + return res.status(NOT_FOUND).send({ message: `${existingUser.email} not found.` }); } if (Object.keys(fieldChanges).length > 0) { - const sanitizedUser = {...user}; - if ('password' in sanitizedUser) { - sanitizedUser.password = true; - } + // Create a simplified log of what was updated + const auditDetails = { + updatedInfo: JSON.stringify({ ...dataToUpdate, password: !!password }), // true/false for password + fieldChanges: JSON.stringify(fieldChanges) + }; AuditLog.create({ - userId: decoded._id, // person who did modification + userId: tokenId, action: AuditLogActions.UPDATE_USER, - documentId: user._id, - details: { - updatedInfo: JSON.stringify(sanitizedUser), - fieldChanges: JSON.stringify(fieldChanges) - } + documentId: targetId, + details: auditDetails }).catch(logger.error); } return res.status(OK).send({ message: `${existingUser.email} was updated.`, - membershipValidUntil: user.membershipValidUntil + membershipValidUntil: dataToUpdate.membershipValidUntil || existingUser.membershipValidUntil }); - }); + + } catch (error) { + logger.error('/edit had an error:', error); + return res.status(BAD_REQUEST).send({ message: 'Bad Request: Unable to update user.' }); + } }); -router.post('/getPagesPrintedCount', (req, res) => { - if (!checkIfTokenSent(req)) { - return res.sendStatus(FORBIDDEN); - } else if (!checkIfTokenValid(req)) { - return res.sendStatus(UNAUTHORIZED); +router.post('/getPagesPrintedCount', async (req, res) => { + const decoded = await decodeToken(req); + if (!decoded.token) { + return res.sendStatus(decoded.status); } User.findOne({ email: req.body.email }, function(error, result) { if (error) { @@ -317,20 +315,30 @@ router.post('/getPagesPrintedCount', (req, res) => { }); router.post('/getUserById', async (req, res) => { - if (!checkIfTokenSent(req)) { - return res.sendStatus(FORBIDDEN); - } else if (!checkIfTokenValid(req)) { - return res.sendStatus(UNAUTHORIZED); + const decoded = await decodeToken(req); + if (!decoded.token) { + return res.sendStatus(decoded.status); } + let targetUserId = req.body.userID; + // If not officer, only allow reading of own account - let decoded = decodeToken(req); - if (decoded.accessLevel < membershipState.OFFICER) { - if (req.body.userID && req.body.userID !== decoded._id) { + if (decoded.token.accessLevel < membershipState.OFFICER) { + // 1. Force the lookup ID to be the logged-in user's ID + targetUserId = decoded.token._id; + + // 2. If the user tried to request a *different* ID, explicitly block them + if (req.body.userID && req.body.userID !== decoded.token._id) { return res .status(FORBIDDEN) .json({ message: 'you must be an officer or admin to read other users\' data' }); } } + + // If no ID was provided in the request body, use the token ID as a fallback + if (!targetUserId) { + targetUserId = decoded.token._id; + } + User.findOne({ _id: req.body.userID}, (err, result) => { if (err) { return res.sendStatus(BAD_REQUEST); @@ -392,11 +400,10 @@ router.post('/getUserDataByEmail', (req, res) => { }); // Search for all members with verified emails and subscribed -router.post('/usersSubscribedAndVerified', function(req, res) { - if (!checkIfTokenSent(req)) { - return res.sendStatus(FORBIDDEN); - } else if (!checkIfTokenValid(req)) { - return res.sendStatus(UNAUTHORIZED); +router.post('/usersSubscribedAndVerified', async function(req, res) { + const decoded = await decodeToken(req, membershipState.OFFICER); + if (!decoded.token) { + return res.sendStatus(decoded.status); } User.find({ emailVerified: true, emailOptIn: true }) .then((users) => { @@ -418,11 +425,10 @@ router.post('/usersSubscribedAndVerified', function(req, res) { }); // Search for all members with verified emails, subscribed, and not banned or pending -router.post('/usersValidVerifiedAndSubscribed', function(req, res) { - if (!checkIfTokenSent(req)) { - return res.sendStatus(FORBIDDEN); - } else if (!checkIfTokenValid(req, membershipState.OFFICER)) { - return res.sendStatus(UNAUTHORIZED); +router.post('/usersValidVerifiedAndSubscribed', async function(req, res) { + const decoded = await decodeToken(req, membershipState.OFFICER); + if (!decoded.token) { + return res.sendStatus(decoded.status); } User.find({ emailVerified: true, @@ -446,13 +452,11 @@ router.post('/usersValidVerifiedAndSubscribed', function(req, res) { // Generate an API key for the Messages API if the user does not have an API key; otherwise, return the existing API key router.post('/apikey', async (req, res) => { - if (!checkIfTokenSent(req)) { - return res.sendStatus(FORBIDDEN); - } - if (!checkIfTokenValid(req)) { - return res.sendStatus(UNAUTHORIZED); + const decoded = await decodeToken(req); + if (!decoded.token) { + return res.sendStatus(decoded.status); } - let { _id } = decodeToken(req); + let { _id } = decoded.token; User.findOne({_id}) .then((user) => { @@ -482,10 +486,9 @@ router.post('/apikey', async (req, res) => { // Finds number of those signups who've paid for annual plan // Assumes members who have paid have been assigned an expiration date router.get('/getNewPaidMembersThisSemester', async (req, res) => { - if (!checkIfTokenSent(req)) { - return res.sendStatus(FORBIDDEN); - } else if (!checkIfTokenValid(req, membershipState.OFFICER)) { - return res.sendStatus(UNAUTHORIZED); + const decoded = await decodeToken(req, membershipState.OFFICER); + if (!decoded.token) { + return res.sendStatus(decoded.status); } const today = new Date(); diff --git a/api/main_endpoints/util/LedSign.js b/api/main_endpoints/util/LedSign.js index b5cb2860e..bd3b409c2 100644 --- a/api/main_endpoints/util/LedSign.js +++ b/api/main_endpoints/util/LedSign.js @@ -29,7 +29,7 @@ let LED_SIGN_URL = process.env.LED_SIGN_URL async function updateSign(data) { return new Promise((resolve) => { axios - .post(LED_SIGN_URL + '/api/update-sign', data) + .post(LED_SIGN_URL + '/update-sign', data) .then(() => { resolve(true); }).catch((err) => { @@ -46,7 +46,7 @@ async function updateSign(data) { async function turnOffSign() { return new Promise((resolve) => { axios - .get(LED_SIGN_URL + '/api/turn-off') + .get(LED_SIGN_URL + '/turn-off') .then(() => { resolve(true); }).catch((err) => { @@ -77,7 +77,7 @@ async function turnOffSign() { async function healthCheck() { return new Promise((resolve) => { axios - .get(LED_SIGN_URL + '/api/health-check') + .get(LED_SIGN_URL + '/health-check') .then(({ data }) => { resolve(data); }).catch((err) => { diff --git a/api/main_endpoints/util/OfficeAccessCard.js b/api/main_endpoints/util/OfficeAccessCard.js index c9b31cc2b..d24866564 100644 --- a/api/main_endpoints/util/OfficeAccessCard.js +++ b/api/main_endpoints/util/OfficeAccessCard.js @@ -20,8 +20,8 @@ function checkIfCardExists({ cardBytes = null, alias = null } = {}) { return resolve(false); } if (!result) { - const { description } = body; - logger.info(`Card:${description} not found in the database`); + const description = cardBytes !== null ? cardBytes : alias; + logger.info(`Card: ${description} not found in the database`); } return resolve(result); // return the document }); @@ -87,4 +87,30 @@ function deleteCard(alias) { }); } -module.exports = { checkIfCardExists, generateAlias, deleteCard }; +function editAlias(_id, newAlias) { + return new Promise((resolve) => { + try { + OfficeAccessCard.findByIdAndUpdate( + _id, + { $set: { alias: newAlias } }, + { new: true, useFindAndModify: false }, + (error, result) => { + if (error) { + logger.error('editAlias got an error querying mongodb: ', error); + return resolve(false); + } + if (!result) { + logger.info(`Card with id ${_id} not found in the database`); + return resolve(false); + } + return resolve(result); + } + ); + } catch (error) { + logger.error('editAlias caught an error: ', error); + return resolve(false); + } + }); +} + +module.exports = { checkIfCardExists, generateAlias, deleteCard, editAlias }; diff --git a/api/main_endpoints/util/auditLogActions.js b/api/main_endpoints/util/auditLogActions.js index b1da29785..13e7a3d5b 100644 --- a/api/main_endpoints/util/auditLogActions.js +++ b/api/main_endpoints/util/auditLogActions.js @@ -14,6 +14,7 @@ const AuditLogActions = { VERIFY_CARD: 'VERIFY_CARD', ADD_CARD: 'ADD_CARD', DELETE_CARD: 'DELETE_CARD', + EDIT_CARD: 'EDIT_CARD', }; module.exports = AuditLogActions; diff --git a/api/main_endpoints/util/token-functions.js b/api/main_endpoints/util/token-functions.js index 7d7fb43d4..22db1475d 100644 --- a/api/main_endpoints/util/token-functions.js +++ b/api/main_endpoints/util/token-functions.js @@ -5,75 +5,62 @@ const membershipState = require('../../util/constants').MEMBERSHIP_STATE; require('./passport')(passport); +const { UNAUTHORIZED, OK, FORBIDDEN } = require('../../util/constants').STATUS_CODES; +const logger = require('../../util/logger'); -/** - * Check if the request body contains a token - * @param {object} request the HTTP request from the client - * @returns {boolean} if the token exists in the request body - */ -function checkIfTokenSent(request) { - try { - return !!request.headers.authorization; - } catch(_) { - return false; +class SceStatusOrToken { + constructor() { + this.token = null; + this.status = null; } } /** * @param {object} request the HTTP request from the client */ -function decodeToken(request){ +async function decodeToken(request, requiredAccessLevel = membershipState.NON_MEMBER) { + const decodedResponse = new SceStatusOrToken(); + let token = null; + try { - let decodedResponse = {}; - if (!request.headers.authorization || !request.headers.authorization.length) { + if (request.headers.authorization && request.headers.authorization.startsWith('Bearer ')) { + token = request.headers.authorization.split('Bearer ')[1]; + } else if (request.query && request.query.token) { + token = request.query.token; + } + + if (!token) { + decodedResponse.status = UNAUTHORIZED; return decodedResponse; } - const token = request.headers.authorization.split('Bearer ')[1]; + const userToken = token.replace(/^JWT\s/, ''); - jwt.verify(userToken, secretKey, function(error, decoded) { - if (!error && decoded) { - decodedResponse = decoded; - } + const decoded = await new Promise((resolve, reject) => { + jwt.verify(userToken, secretKey, (error, payload) => { + if (error || !payload) { + return reject(error || new Error('Token verification failed.')); + } + resolve(payload); + }); }); + + const hasRequiredAccess = decoded.accessLevel >= requiredAccessLevel; + + decodedResponse.status = hasRequiredAccess ? OK : FORBIDDEN; + decodedResponse.token = decoded; + + return decodedResponse; + + } catch (err) { + logger.error('Token validation failed:', err); + decodedResponse.status = FORBIDDEN; + return decodedResponse; - } catch (_) { - return null; } } -/** -* @param {object} request the HTTP request from the client -*/ -function decodeTokenFromBodyOrQuery(request){ - const token = request.body.token || request.query.token; - const userToken = token.replace(/^JWT\s/, ''); - let decodedResponse = {}; - jwt.verify(userToken, secretKey, function(error, decoded) { - if (!error && decoded) { - decodedResponse = decoded; - } - }); - return decodedResponse; -} -/** - * Checks if the request token is valid and returns either a valid response - * or undefined - * @param {object} request the HTTP request from the client - * @param {number} accessLevel the minimum access level to consider the token valid - * @param {boolean} returnDecoded optional parameter to return the decoded - * response to the user - * @returns {boolean} whether the user token is valid or not - */ -function checkIfTokenValid(request, accessLevel = membershipState.NON_MEMBER) { - let decoded = decodeToken(request); - let response = decoded && decoded.accessLevel >= accessLevel; - return response; -} module.exports = { - checkIfTokenSent, - checkIfTokenValid, decodeToken, - decodeTokenFromBodyOrQuery }; diff --git a/api/util/CreateUserScript.js b/api/util/CreateUserScript.js index 4f957548d..838a32aaa 100644 --- a/api/util/CreateUserScript.js +++ b/api/util/CreateUserScript.js @@ -66,10 +66,6 @@ inquirer name: 'Banned', value: membershipState.BANNED }, - { - name: 'Alumni', - value: membershipState.ALUMNI - } ], filter: function(val) { console.debug(val); diff --git a/api/util/SceHttpServer.js b/api/util/SceHttpServer.js index f149379e3..0d6579ee4 100644 --- a/api/util/SceHttpServer.js +++ b/api/util/SceHttpServer.js @@ -87,7 +87,9 @@ class SceHttpServer { requireList.map((route) => { try { this.app.use(this.prefix + route.endpointName, require(route.filePath)); + MetricsHandler.errorLoadingExpressRoute.labels(route.endpointName).set(0); } catch (e) { + MetricsHandler.errorLoadingExpressRoute.labels(route.endpointName).set(1); logger.error( `error importing ${route.filePath} to handle: ${route.endpointName}:`, e diff --git a/api/util/constants.js b/api/util/constants.js index f1e07e7b0..7b7e6b68a 100644 --- a/api/util/constants.js +++ b/api/util/constants.js @@ -12,7 +12,6 @@ const MEMBERSHIP_STATE = { BANNED: -2, PENDING: -1, NON_MEMBER: 0, - ALUMNI: 0.5, MEMBER: 1, OFFICER: 2, ADMIN: 3, diff --git a/api/util/metrics.js b/api/util/metrics.js index 31b17a088..bb6c75c9a 100644 --- a/api/util/metrics.js +++ b/api/util/metrics.js @@ -57,6 +57,12 @@ class MetricsHandler { help: 'Total number of bytes from expired chunks that have been deleted' }); + errorLoadingExpressRoute = new client.Gauge({ + name: 'error_loading_express_route', + help: 'Shows if all routes are fully loaded (0 = loaded, 1 = not loaded)', + labelNames: ['endpointName'] + }) + constructor() { register.setDefaultLabels({ app: 'sce-core', diff --git a/api/util/token-verification.js b/api/util/token-verification.js deleted file mode 100644 index 7f13ca9b9..000000000 --- a/api/util/token-verification.js +++ /dev/null @@ -1,11 +0,0 @@ -const { DISCORD_COREV4_KEY } = require('../config/config.json'); - -/** - * Checks API key value and return true or false depending on if it matches - * @param {String} apiKey - * @returns {boolean} whether the api key was valid or not - */ -function checkDiscordKey(apiKey) { - return apiKey === DISCORD_COREV4_KEY; -} -module.exports = { checkDiscordKey }; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index c43e53df4..5084fc350 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -48,7 +48,7 @@ services: - MAIN_ENDPOINT_URL=sce-main-endpoints-dev:8080 - CLEEZY_URL=http://host.docker.internal:8000 - PRINTER_URL=http://host.docker.internal:14000 - - LED_SIGN_URL=http://host.docker.internal:11000 + - LED_SIGN_URL=http://host.docker.internal:10000 - DISCORD_REDIRECT_URI=http://localhost/api/user/callback - MAILER_API_URL=http://sce-cloud-api-dev:8082/cloudapi - DATABASE_HOST=sce-mongodb-dev diff --git a/docker-compose.yml b/docker-compose.yml index 4f2a79e26..f9a0395bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,8 +15,11 @@ services: dockerfile: ./Dockerfile command: - --hosts - - http://host.docker.internal:11000/api/health-check + # led sign + - http://host.docker.internal:10000/health-check + # printer - http://host.docker.internal:14000/healthcheck/printer + # status page - http://host.docker.internal:17000/hello extra_hosts: - "host.docker.internal:host-gateway" @@ -44,7 +47,7 @@ services: environment: - CLEEZY_URL=http://cleezy-app.sce:8000 - PRINTER_URL=http://host.docker.internal:14000 - - LED_SIGN_URL=http://host.docker.internal:11000 + - LED_SIGN_URL=http://host.docker.internal:10000 - DISCORD_REDIRECT_URI=https://sce.sjsu.edu/api/user/callback - MAILER_API_URL=http://sce-cloud-api:8082/cloudapi - DATABASE_HOST=sce-mongodb diff --git a/package-lock.json b/package-lock.json index af1711335..eae699744 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20105,6 +20105,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-4.0.4.tgz", "integrity": "sha512-lbRVHoOMtItjWbM7TwDLdl8wug7izB0tq3/YVKhT/ahB4VDvWMyvnADfnJI8y6fSvsjh51Ix7lTGC6Tn4rMPhw==", + "license": "MIT", "dependencies": { "@types/cookie": "^0.3.3", "cookie": "^0.4.0" diff --git a/src/APIFunctions/Auth.js b/src/APIFunctions/Auth.js index ce58636a8..efe2351b8 100644 --- a/src/APIFunctions/Auth.js +++ b/src/APIFunctions/Auth.js @@ -1,5 +1,8 @@ import { UserApiResponse, ApiResponse } from './ApiResponses'; import { BASE_API_URL } from '../Enums'; +import Cookies from 'universal-cookie'; + +const cookies = new Cookies(); // cookie handler /** @@ -88,18 +91,16 @@ export async function loginUser(email, password) { } /** - * Checks if the user is signed in by evaluating a jwt token in local storage. + * Checks if the user is signed in by evaluating a jwt token in cookies. * @returns {UserApiResponse} Containing information for * whether the user is signed or not */ export async function checkIfUserIsSignedIn() { let status = new UserApiResponse(); - const token = window.localStorage - ? window.localStorage.getItem('jwtToken') - : ''; + const token = cookies.get('jwt') ?? ''; - // If there is not token in local storage, + // If there is not token in cookies, // we cant do anything and return if (!token) { status.error = true; @@ -203,3 +204,11 @@ export async function validatePasswordReset(resetToken) { } return status; } + +export function setJwtCookie(token) { + cookies.set('jwt', token, { maxAge: 60 * 60 * 2, secure: true, sameSite: 'strict' }); // expire cookies after 2 hours +} + +export function deleteJwtCookie() { + cookies.remove('jwt'); +} diff --git a/src/APIFunctions/CardReader.js b/src/APIFunctions/CardReader.js index 25c1e4a4b..1f7fdfcee 100644 --- a/src/APIFunctions/CardReader.js +++ b/src/APIFunctions/CardReader.js @@ -55,3 +55,28 @@ export async function deleteCardFromDb(token, alias) { } return status; } + +export async function editCardAlias(token, _id, alias) { + let status = new ApiResponse(); + try { + const url = new URL('/api/OfficeAccessCard/edit', BASE_API_URL); + const res = await fetch(url.href, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ _id, alias }), + }); + if (res.ok) { + const result = await res.json(); + status.responseData = result; + } else { + status.error = true; + } + } catch (err) { + status.error = true; + status.responseData = err; + } + return status; +} diff --git a/src/Components/Navbar/AdminNavbar.js b/src/Components/Navbar/AdminNavbar.js index 3221d9eb4..977855a13 100644 --- a/src/Components/Navbar/AdminNavbar.js +++ b/src/Components/Navbar/AdminNavbar.js @@ -1,5 +1,6 @@ import React from 'react'; import { useSCE } from '../context/SceContext'; +import { deleteJwtCookie } from '../../APIFunctions/Auth'; export default function UserNavBar(props) { const { user, setAuthenticated } = useSCE(); @@ -16,7 +17,7 @@ export default function UserNavBar(props) { function handleLogout() { setAuthenticated(false); - window.localStorage.removeItem('jwtToken'); + deleteJwtCookie(); window.location.reload(); } diff --git a/src/Components/Navbar/NavBarWrapper.js b/src/Components/Navbar/NavBarWrapper.js index bc096fea3..5cd82bf5f 100755 --- a/src/Components/Navbar/NavBarWrapper.js +++ b/src/Components/Navbar/NavBarWrapper.js @@ -2,6 +2,7 @@ import React from 'react'; import UserNavbar from './UserNavbar'; import AdminNavbar from './AdminNavbar'; import { useSCE } from '../context/SceContext'; +import { deleteJwtCookie } from '../../APIFunctions/Auth'; function NavBarWrapper({ enableAdminNavbar = false, @@ -13,7 +14,7 @@ function NavBarWrapper({ function handleLogout() { setAuthenticated(false); setUser({}); - window.localStorage.removeItem('jwtToken'); + deleteJwtCookie(); window.location.reload(); } diff --git a/src/Enums.js b/src/Enums.js index 71ba63922..fc9e9a6f0 100644 --- a/src/Enums.js +++ b/src/Enums.js @@ -24,7 +24,6 @@ const membershipState = { BANNED: -2, PENDING: -1, NON_MEMBER: 0, - ALUMNI: 0.5, MEMBER: 1, OFFICER: 2, ADMIN: 3, @@ -34,17 +33,12 @@ const membershipStatusArray = [ 'Ban', 'Pending', 'Nonmember', - 'Alumni', 'Member', 'Officer', 'Admin', ]; function membershipStateToString(accessLevel) { - if (accessLevel === membershipState.ALUMNI) - return 'Alumni'; - else if (accessLevel > 0) - return membershipStatusArray[accessLevel + 3]; return membershipStatusArray[accessLevel + 2]; } diff --git a/src/Pages/CardReader/CardReader.js b/src/Pages/CardReader/CardReader.js index d068ffc72..5bb60e9bc 100644 --- a/src/Pages/CardReader/CardReader.js +++ b/src/Pages/CardReader/CardReader.js @@ -1,9 +1,9 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect } from 'react'; import { BASE_API_URL } from '../../Enums'; import { useSCE } from '../../Components/context/SceContext'; -import { getAllCardsFromDb, deleteCardFromDb } from '../../APIFunctions/CardReader'; +import { getAllCardsFromDb, deleteCardFromDb, editCardAlias } from '../../APIFunctions/CardReader'; import ConfirmationModal from '../../Components/DecisionModal/ConfirmationModal'; -import { trashcanSymbol } from '../Overview/SVG'; +import { trashcanSymbol, pencilSymbol, checkSymbol, cancelSymbol } from '../Overview/SVG'; const header = [ 'Time'.padEnd(29), @@ -17,10 +17,13 @@ const header = [ export default function CardReader() { const { user } = useSCE(); const token = user.token; + const [logs, setLogs] = useState([]); const [cards, setCards] = useState([]); const [toggleDelete, setToggleDelete] = useState(false); const [cardToDelete, setCardToDelete] = useState({}); + const [editingCardId, setEditingCardId] = useState(null); + const [editedAlias, setEditedAlias] = useState(''); const [tab, setTab] = useState(() => { const params = new URLSearchParams(window.location.search); return params.get('tab') || 'registry'; @@ -44,9 +47,9 @@ export default function CardReader() { const getColumnClassName = (columnName) => { let className = 'px-6 py-3 whitespace-nowrap '; - if(columnName === 'lastVerifiedAt' | columnName === 'registrationDate'){ + if (['lastVerifiedAt', 'registrationDate'].includes(columnName)) { className += 'hidden md:table-cell '; - } else if (columnName === 'verifiedCount'){ + } else if (columnName === 'verifiedCount') { className += 'hidden lg:table-cell'; } return className; @@ -89,12 +92,78 @@ export default function CardReader() { setCardToDelete(card); } + function handleEditClick(card) { + if (editingCardId === card._id) { + setEditingCardId(null); + setEditedAlias(''); + } else { + setEditingCardId(card._id); + setEditedAlias(card.alias); + } + } + + async function handleSaveEdit(cardId) { + if (!editedAlias.trim()) { + return; // Don't save empty alias + } + + try { + const response = await editCardAlias(token, cardId, editedAlias.trim()); + if (!response.error) { + // Refetch all cards from database to ensure UI matches server reality + await getAllCards(); + setEditingCardId(null); + setEditedAlias(''); + } + } catch (error) { + setLogs( + (currLogs) => [ + '[error] unable to update card alias, check browser logs: \n' + error, + ...currLogs, + ] + ); + } + } + + function handleEditKeyDown(key) { + if (key === 'Enter') { + handleSaveEdit(editingCardId); + } else if (key === 'Escape') { + setEditingCardId(null); + setEditedAlias(''); + } + } + + function renderInputOrAlias(card) { + if (editingCardId === card._id) { + return ( + setEditedAlias(e.target.value)} + onKeyDown={(e) => handleEditKeyDown(e.key)} + className='bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded text-gray-700 dark:text-white font-medium m-0 px-1 py-0 focus:outline-none focus:ring-1 focus:ring-blue-500' + style={{ width: '16ch' }} + autoFocus + /> + ); + } + return ( +
+ + {card.alias} + +
+ ); + } + function CardEntry({ card }) { + const isEditing = editingCardId === card._id; return (
- {card.alias} + {renderInputOrAlias(card)}
@@ -113,12 +182,33 @@ export default function CardReader() { - +
+ + + +
); diff --git a/src/Pages/EmailPreferences/EmailPreferences.js b/src/Pages/EmailPreferences/EmailPreferences.js index de66275df..26aa7c05c 100644 --- a/src/Pages/EmailPreferences/EmailPreferences.js +++ b/src/Pages/EmailPreferences/EmailPreferences.js @@ -108,29 +108,29 @@ export default function EmailPreferencesPage(props) { What would you like to hear about?

-
+
setIsOptedIntoEmails(true)} /> -
-
+
setIsOptedIntoEmails(false)} /> -
diff --git a/src/Pages/LedSign/LedSign.js b/src/Pages/LedSign/LedSign.js index 6e48e2c15..a0cd1dfe7 100644 --- a/src/Pages/LedSign/LedSign.js +++ b/src/Pages/LedSign/LedSign.js @@ -1,11 +1,13 @@ import React, { useState, useEffect } from 'react'; import { healthCheck, updateSignText } from '../../APIFunctions/LedSign'; import { useSCE } from '../../Components/context/SceContext'; + import './ledsign.css'; function LedSign() { const { user } = useSCE(); const [signHealthy, setSignHealthy] = useState(false); + const [showInput, setInput] = useState(false); const [loading, setLoading] = useState(true); const [text, setText] = useState(''); const [brightness, setBrightness] = useState(50); @@ -13,12 +15,11 @@ function LedSign() { const [backgroundColor, setBackgroundColor] = useState('#0000ff'); const [textColor, setTextColor] = useState('#00ff00'); const [borderColor, setBorderColor] = useState('#ff0000'); + const [expiration, setExpiration] = useState(null); + const [existingExpirationFromSign, setExistingExpirationFromSign] = useState(null); const [awaitingSignResponse, setAwaitingSignResponse] = useState(false); - const [awaitingStopSignResponse, setAwaitingStopSignResponse] - = useState(false); const [requestSuccessful, setRequestSuccessful] = useState(); const [stopRequestSuccesful, setStopRequestSuccesful] = useState(); - const inputArray = [ { title: 'Sign Text:', @@ -67,7 +68,43 @@ function LedSign() { } ]; + function isExpired() { + if (!expiration) { + return false; + } + const currDate = new Date(); + const expireDateObject = new Date(expiration); + return expireDateObject < currDate; + } + + function getFormattedTime(maybeISOString = null) { + let date = new Date(); + if (maybeISOString) { + date = new Date(maybeISOString); + } + + return date.toLocaleString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: 'numeric', + minute: '2-digit', + hour12: true, + timeZoneName: 'short', + }); + } + + async function handleExpiration() { + setExpiration(null); + setInput(!showInput); + } + async function handleSend() { + let expirationToUse = null; + if (expiration) { + expirationToUse = new Date(expiration).toISOString(); + } + setAwaitingSignResponse(true); let correctedScrollSpeed = 10 - scrollSpeed; const signResponse = await updateSignText( @@ -78,6 +115,7 @@ function LedSign() { backgroundColor, textColor, borderColor, + expiration: expirationToUse, email: user.email, firstName: user.firstName, }, @@ -88,7 +126,6 @@ function LedSign() { } async function handleStop() { - setAwaitingStopSignResponse(true); const signResponse = await updateSignText( { ledIsOff: true, @@ -98,7 +135,6 @@ function LedSign() { user.token ); setStopRequestSuccesful(!signResponse.error); - setAwaitingStopSignResponse(false); } function renderRequestStatus() { @@ -115,20 +151,60 @@ function LedSign() { ); } } + + function maybeShowExpirationDate() { + if (!existingExpirationFromSign) { + return <>; + } + const humanizedExpiration = new Date(existingExpirationFromSign) + .toLocaleString('en-US', { + timeZoneName: 'short' // e.g., "Pacific Standard Time" + }); + return

The current sign message will expire on {humanizedExpiration}

; + } + + function getExpirationButtonOrInput() { + if (showInput) { + return <> +
+ setExpiration(e.target.value)} /> + +
+ { + isExpired() &&
+

+ Your selected expiration is considered behind the current time of {getFormattedTime()}. +

+

+ Submitting a message with this expiration will not update the sign. +

+
+ } + ; + } + + return ; + } + useEffect(() => { async function checkSignHealth() { setLoading(true); const status = await healthCheck(user.firstName); - if (status && !status.error) { + if (status.responseData && !status.error) { setSignHealthy(true); const { responseData } = status; - if (responseData && responseData.text) { + if (Object.keys(responseData).length > 0) { setText(responseData.text); setBrightness(responseData.brightness); setScrollSpeed(responseData.scrollSpeed); setBackgroundColor(responseData.backgroundColor); setTextColor(responseData.textColor); setBorderColor(responseData.borderColor); + setExistingExpirationFromSign(responseData.expiration); } } else { setSignHealthy(false); @@ -198,6 +274,8 @@ function LedSign() { >
+ {maybeShowExpirationDate()} + {getExpirationButtonOrInput()} { inputArray.map(({ id, diff --git a/src/Pages/Login/Login.js b/src/Pages/Login/Login.js index ea117fb7b..e156782ba 100644 --- a/src/Pages/Login/Login.js +++ b/src/Pages/Login/Login.js @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { loginUser } from '../../APIFunctions/Auth'; +import { loginUser, setJwtCookie } from '../../APIFunctions/Auth'; import { useSCE } from '../../Components/context/SceContext'; export default function Login() { @@ -15,7 +15,7 @@ export default function Login() { const loginStatus = await loginUser(email, password); if (!loginStatus.error) { setAuthenticated(true); - window.localStorage.setItem('jwtToken', loginStatus.token); + setJwtCookie(loginStatus.token); if (queryParams.get('redirect')) { window.location.href = queryParams.get('redirect'); return; @@ -54,7 +54,7 @@ export default function Login() { type="email" placeholder="rys@sce.sjsu.edu" required - autofocus="autofocus" + autoFocus="autofocus" className="input input-bordered w-full" /> diff --git a/src/Pages/MembershipApplication/MembershipForm.js b/src/Pages/MembershipApplication/MembershipForm.js index 01eb3693a..a29d006a1 100644 --- a/src/Pages/MembershipApplication/MembershipForm.js +++ b/src/Pages/MembershipApplication/MembershipForm.js @@ -287,7 +287,7 @@ export default function MembershipForm(props) {
diff --git a/src/Pages/Overview/Overview.js b/src/Pages/Overview/Overview.js index 0f2de81ec..47c8ac9cf 100644 --- a/src/Pages/Overview/Overview.js +++ b/src/Pages/Overview/Overview.js @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; const svg = require('./SVG'); import { getAllUsers, deleteUserByID, getNewPaidMembersThisSemester } from '../../APIFunctions/User'; +import { deleteJwtCookie } from '../../APIFunctions/Auth.js'; import { formatFirstAndLastName } from '../../APIFunctions/Profile'; import { getAllUsersValidVerifiedAndSubscribed } from '../../APIFunctions/User'; // import { membershipState } from '../../Enums'; @@ -27,7 +28,7 @@ export default function Overview() { const [clubRevenueData, setClubRevenueData] = useState({newMembersThisYear:0, newSingleSemesterMembers:0, newAnnualMembers:0, currentActiveMembers:0}); // const [toggle, setToggle] = useState(false); // const [currentQueryType, setCurrentQueryType] = useState('All'); - // const queryTypes = ['All', 'Pending', 'Officer', 'Admin', 'Alumni']; + // const queryTypes = ['All', 'Pending', 'Officer', 'Admin']; async function deleteUser(userToDel) { const response = await deleteUserByID( @@ -39,7 +40,7 @@ export default function Overview() { } if (userToDel._id === user._id) { // logout - window.localStorage.removeItem('jwtToken'); + deleteJwtCookie(); window.location.reload(); return window.alert('Self-deprecation is an art'); } @@ -147,10 +148,6 @@ export default function Overview() { // return users.filter( // data => data.accessLevel === membershipState.PENDING // ); - // case 'Alumni': - // return users.filter( - // data => data.accessLevel === membershipState.ALUMNI - // ); // default: // return users; // } diff --git a/src/Pages/Overview/SVG.js b/src/Pages/Overview/SVG.js index 9584f5007..2b81297c9 100644 --- a/src/Pages/Overview/SVG.js +++ b/src/Pages/Overview/SVG.js @@ -51,6 +51,32 @@ export function trashcanSymbol(color = 'black') { ); } +export function pencilSymbol(color = '#6b7280') { + return ( + + + + + ); +} + +export function checkSymbol(color = '#22c55e') { + return ( + + + + ); +} + +export function cancelSymbol(color = '#ef4444') { + return ( + + + + + ); +} + export function copyIcon(className, onClick) { return ( diff --git a/src/Pages/Profile/MemberView/DeleteAccountModal.js b/src/Pages/Profile/MemberView/DeleteAccountModal.js index cb6022c57..59b6ae702 100644 --- a/src/Pages/Profile/MemberView/DeleteAccountModal.js +++ b/src/Pages/Profile/MemberView/DeleteAccountModal.js @@ -1,6 +1,7 @@ import React from 'react'; import { deleteUserByID } from '../../../APIFunctions/User'; import { useSCE } from '../../../Components/context/SceContext'; +import { deleteJwtCookie } from '../../../APIFunctions/Auth'; export default function DeleteAccountModal(props) { const { bannerCallback = () => {} } = props; @@ -15,7 +16,7 @@ export default function DeleteAccountModal(props) { if (!apiResponse.error) { bannerCallback('Account Deleted', 'success'); setTimeout(() => { - window.localStorage.removeItem('jwtToken'); + deleteJwtCookie(); window.location.reload(); }, 2000); } else { diff --git a/src/Pages/Profile/MemberView/Profile.js b/src/Pages/Profile/MemberView/Profile.js index 57689b699..fc5ff3482 100644 --- a/src/Pages/Profile/MemberView/Profile.js +++ b/src/Pages/Profile/MemberView/Profile.js @@ -103,6 +103,10 @@ export default function Profile() {
Membership Expiration
{renderExpirationDate()}
+
+
Door Code
+
{response.doorCode}
+
diff --git a/test/api/Advertisement.js b/test/api/Advertisement.js index 9e48aecf9..47e8bb61d 100644 --- a/test/api/Advertisement.js +++ b/test/api/Advertisement.js @@ -61,9 +61,9 @@ describe('Advertisement', () => { describe('/POST createAdvertisement', () => { - it('Should return 403 when token is not sent', async () => { + it('Should return 401 when token is not sent', async () => { const res = await test.sendPostRequest('/api/Advertisement/createAdvertisement', VALID_ADVERTISEMENT); - expect(res).to.have.status(FORBIDDEN); + expect(res).to.have.status(UNAUTHORIZED); }); it('Should return 401 when invalid token is sent', async () => { @@ -125,9 +125,9 @@ describe('Advertisement', () => { }); describe('/POST deleteAdvertisement', () => { - it('Should return 403 if no token is sent', async () => { + it('Should return 401 if no token is sent', async () => { const res = await test.sendPostRequest('/api/Advertisement/deleteAdvertisement', { _id: VALID_ADVERTISEMENT._id }); - expect(res).to.have.status(FORBIDDEN); + expect(res).to.have.status(UNAUTHORIZED); }); it('Should return 401 if invalid token is sent', async () => { diff --git a/test/api/Auth.js b/test/api/Auth.js index 7c67f565a..12ea2ea4d 100644 --- a/test/api/Auth.js +++ b/test/api/Auth.js @@ -180,19 +180,16 @@ describe('Auth', () => { } }; - const decodedPayload = decodeToken(mockRequest); + const decodedPayload = await decodeToken(mockRequest, MEMBERSHIP_STATE.PENDING); const expectedPayload = { - firstName: 'Test', - lastName: 'User', - email: 'logintest@gmail.com', - accessLevel: MEMBERSHIP_STATE.PENDING, - pagesPrinted: 0, - _id: decodedPayload._id, - iat: decodedPayload.iat, - exp: decodedPayload.exp, + 'accessLevel': -1, + 'email': 'logintest@gmail.com', + 'firstName': 'Test', + 'lastName': 'User', + 'pagesPrinted': 0, }; - expect(decodedPayload).to.deep.equal(expectedPayload); + expect(decodedPayload.token).to.deep.include(expectedPayload); } finally { await User.deleteOne({email: user.email}); } diff --git a/test/api/OfficeAccessCard.js b/test/api/OfficeAccessCard.js index b0106e5b1..a3eabc9af 100644 --- a/test/api/OfficeAccessCard.js +++ b/test/api/OfficeAccessCard.js @@ -20,6 +20,7 @@ const { SERVER_ERROR, FORBIDDEN, } = require('../../api/util/constants').STATUS_CODES; +const { MEMBERSHIP_STATE } = require('../../api/util/constants'); const { initializeTokenMock, setTokenStatus, @@ -45,6 +46,8 @@ const token = ''; describe('OfficeAccessCard', () => { let deleteCardStub = null; let getAllCardsStub = null; + let editAliasStub = null; + let testCardId = null; const VALID_CARD_BYTES = 'wesleys card'; const NEW_CARD_BYTES = 'dials card'; @@ -52,10 +55,14 @@ describe('OfficeAccessCard', () => { const VALID_ALIAS = 'gauravs card'; const INVALID_ALIAS = 'bobs card'; + const NEW_ALIAS = 'updated test card'; + const EMPTY_ALIAS = ''; + const WHITESPACE_ALIAS = ' '; const VERIFY_API_PATH = '/api/OfficeAccessCard/verify'; const DELETE_API_PATH = '/api/OfficeAccessCard/delete'; const GET_ALL_CARDS_API_PATH = '/api/OfficeAccessCard/getAllCards'; + const EDIT_API_PATH = '/api/OfficeAccessCard/edit'; const INCREMENT_VERIFY_COUNT = 0; before(() => { @@ -76,7 +83,10 @@ describe('OfficeAccessCard', () => { }); return new Promise((resolve, reject) => { testOfficeAccessCard.save() - .then(resolve) + .then(savedCard => { + testCardId = savedCard._id.toString(); + resolve(savedCard); + }) .catch(reject); }); }); @@ -116,14 +126,14 @@ describe('OfficeAccessCard', () => { expect(result).to.have.status(BAD_REQUEST); }); - it('Should return 401 with invalid api key', async () => { + it('Should return 403 with invalid api key', async () => { const params = new URLSearchParams(); params.append('cardBytes', VALID_CARD_BYTES); const path = VERIFY_API_PATH + '?' + params.toString(); const invalidApiKey = API_KEY + '-invalid-suffix'; const result = await test.sendGetRequestWithApiKey( invalidApiKey + '', path); - expect(result).to.have.status(UNAUTHORIZED); + expect(result).to.have.status(FORBIDDEN); }); it('Should return 404 with valid api key and unknown card', async () => { @@ -211,9 +221,9 @@ describe('OfficeAccessCard', () => { }); describe('POST getAllCards', () => { - it('Should return 403 when token is not sent', async () => { + it('Should return 401 when token is not sent', async () => { const result = await test.sendPostRequest(GET_ALL_CARDS_API_PATH); - expect(result).to.have.status(FORBIDDEN); + expect(result).to.have.status(UNAUTHORIZED); }); it('Should return 401 when invalid token is sent', async () => { @@ -241,4 +251,94 @@ describe('OfficeAccessCard', () => { }); }); + describe('POST edit', () => { + it('Should return 401 when token is not sent', async () => { + const result = await test.sendPostRequest(EDIT_API_PATH); + expect(result).to.have.status(UNAUTHORIZED); + }); + + it('Should return 401 when invalid token is sent', async () => { + const result = await test.sendPostRequestWithToken(token, + EDIT_API_PATH); + expect(result).to.have.status(UNAUTHORIZED); + }); + + it('Should return 400 when _id is missing from request body', async () => { + setTokenStatus(true); + const result = await test.sendPostRequestWithToken(token, + EDIT_API_PATH, { alias: NEW_ALIAS }); + expect(result).to.have.status(BAD_REQUEST); + }); + + it('Should return 400 when alias is missing from request body', async () => { + setTokenStatus(true); + const result = await test.sendPostRequestWithToken(token, + EDIT_API_PATH, { _id: testCardId }); + expect(result).to.have.status(BAD_REQUEST); + }); + + it('Should return 404 when trying to edit a non-existent card', async () => { + setTokenStatus(true); + const nonExistentId = new mongoose.Types.ObjectId().toString(); + const result = await test.sendPostRequestWithToken(token, + EDIT_API_PATH, { _id: nonExistentId, alias: NEW_ALIAS }); + expect(result).to.have.status(NOT_FOUND); + }); + + it('Should return 400 when _id is not a valid ObjectId', async () => { + setTokenStatus(true); + const result = await test.sendPostRequestWithToken(token, + EDIT_API_PATH, { _id: 'invalid-id', alias: NEW_ALIAS }); + expect(result).to.have.status(BAD_REQUEST); + }); + + it('Should return 200 and successfully update alias for valid request', async () => { + setTokenStatus(true); + const result = await test.sendPostRequestWithToken(token, + EDIT_API_PATH, { _id: testCardId, alias: NEW_ALIAS }); + expect(result).to.have.status(OK); + expect(result.body).to.have.property('message', 'Card alias updated successfully'); + expect(result.body).to.have.property('card'); + expect(result.body.card).to.have.property('alias', NEW_ALIAS); + }); + + it('Should actually update the alias in the database', async () => { + setTokenStatus(true); + await test.sendPostRequestWithToken(token, + EDIT_API_PATH, { _id: testCardId, alias: NEW_ALIAS }); + + const updatedCard = await OfficeAccessCard.findById(testCardId); + expect(updatedCard.alias).to.equal(NEW_ALIAS); + }); + + it('Should handle empty alias by returning 400', async () => { + setTokenStatus(true); + const result = await test.sendPostRequestWithToken(token, + EDIT_API_PATH, { _id: testCardId, alias: EMPTY_ALIAS }); + expect(result).to.have.status(BAD_REQUEST); + }); + + it('Should handle whitespace-only alias by returning 400', async () => { + setTokenStatus(true); + const result = await test.sendPostRequestWithToken(token, + EDIT_API_PATH, { _id: testCardId, alias: WHITESPACE_ALIAS }); + expect(result).to.have.status(BAD_REQUEST); + }); + + it('Should preserve other card properties when updating alias', async () => { + setTokenStatus(true); + const originalCard = await OfficeAccessCard.findById(testCardId); + const originalCardBytes = originalCard.cardBytes; + const originalVerifiedCount = originalCard.verifiedCount; + + await test.sendPostRequestWithToken(token, + EDIT_API_PATH, { _id: testCardId, alias: NEW_ALIAS }); + + const updatedCard = await OfficeAccessCard.findById(testCardId); + expect(updatedCard.cardBytes).to.equal(originalCardBytes); + expect(updatedCard.verifiedCount).to.equal(originalVerifiedCount); + expect(updatedCard.alias).to.equal(NEW_ALIAS); + }); + }); + }); diff --git a/test/api/ShortcutSearch.js b/test/api/ShortcutSearch.js index faa61dcde..19ed9087c 100644 --- a/test/api/ShortcutSearch.js +++ b/test/api/ShortcutSearch.js @@ -9,7 +9,6 @@ const chaiHttp = require('chai-http'); const { OK, UNAUTHORIZED, - FORBIDDEN } = require('../../api/util/constants').STATUS_CODES; const SceApiTester = require('../util/tools/SceApiTester'); @@ -25,12 +24,6 @@ const { initializeTokenMock } = require('../util/mocks/TokenValidFunctions'); -const { - setDiscordAPIStatus, - resetDiscordAPIMock, - restoreDiscordAPIMock, - initializeDiscordAPIMock -} = require('../util/mocks/DiscordApiFunction'); const { MEMBERSHIP_STATE } = require('../../api/util/constants'); const { getMemberExpirationDate } = require('../../api/main_endpoints/util/userHelpers.js'); @@ -41,7 +34,6 @@ chai.use(chaiHttp); describe('ShortcutSearch', () => { before(async () => { initializeTokenMock(); - initializeDiscordAPIMock(); app = tools.initializeServer([ __dirname + '/../../api/main_endpoints/routes/ShortcutSearch.js', ]); @@ -60,18 +52,15 @@ describe('ShortcutSearch', () => { after(done => { restoreTokenMock(); - restoreDiscordAPIMock(); tools.terminateServer(done); }); beforeEach(() => { setTokenStatus(false); - setDiscordAPIStatus(false); }); afterEach(() => { resetTokenMock(); - resetDiscordAPIMock(); }); const token = ''; @@ -81,10 +70,10 @@ describe('ShortcutSearch', () => { const fiveMatchUsers = { query: 'Lot' }; const url = '/api/ShortcutSearch/'; - it('Should return status code 403 if no token is passed through', async () => { + it('Should return status code 401 if no token is passed through', async () => { setTokenStatus(false); const result = await test.sendPostRequest(url, queryUser); - expect(result).to.have.status(FORBIDDEN); + expect(result).to.have.status(UNAUTHORIZED); }); it('Should return status code 401 if access level is invalid', async () => { @@ -256,6 +245,7 @@ describe('ShortcutSearch', () => { ]; for (const payload of injectionPayloads) { const result = await test.sendPostRequestWithToken(token, url, { query: String(payload)}); + expect(result).to.have.status(OK); expect(result.body.items.users.length).at.most(5); expect(result.body.items.cleezyData.length).at.most(5); diff --git a/test/api/TokenFunctions.js b/test/api/TokenFunctions.js index 194f6c403..c1dc5d13e 100644 --- a/test/api/TokenFunctions.js +++ b/test/api/TokenFunctions.js @@ -4,6 +4,8 @@ const sinon = require('sinon'); const chai = require('chai'); const expect = chai.expect; const proxyquire = require('proxyquire'); +const { OK, FORBIDDEN, UNAUTHORIZED } = require('../../api/util/constants').STATUS_CODES; +const membershipState = require('../../api/util/constants').MEMBERSHIP_STATE; const requestWithToken = { headers: { @@ -14,6 +16,7 @@ const requestWithToken = { } }; const requestWithoutToken = { + headers: {}, body: {} }; let tokenFunctions; @@ -31,32 +34,41 @@ describe('TokenFunctions', () => { }); done(); }); - describe('checkIfTokenSent', () => { - it('Should return true if a token field exists in the request', done => { - expect(tokenFunctions.checkIfTokenSent(requestWithToken)).to.equal(true); - done(); + describe('decodeToken', () => { + it('Should resolve with UNAUTHORIZED if no token is sent', done => { + tokenFunctions.decodeToken(requestWithoutToken) + .then(decodedResponse => { + expect(decodedResponse.status).to.equal(UNAUTHORIZED); + done(); + }); }); - it('Should return false if a token field does ' + - 'not exist in the request', done => { - expect(tokenFunctions.checkIfTokenSent(requestWithoutToken)) - .to.equal(false); - done(); - }); - }); - - describe('checkIfTokenValid', () => { - it('Should return the decoded response ', done => { - jwtStub.yields(false, requestWithToken.body); - expect(tokenFunctions.checkIfTokenValid(requestWithToken)) - .to.equal(true); - done(); - }); - it('Should return false if a token field ' + - 'does not exist in the request', done => { - jwtStub.yields(true, false); - expect(tokenFunctions.checkIfTokenValid(requestWithToken)) - .to.equal(false); - done(); + it('Should resolve with FORBIDDEN if token is invalid', done => { + jwtStub.yields(new Error('invalid token'), null); + tokenFunctions.decodeToken(requestWithToken) + .then(decodedResponse => { + expect(decodedResponse.status).to.equal(FORBIDDEN); + done(); + }); }); + it('Should resolve with FORBIDDEN if access level is insufficient', + done => { + jwtStub.yields(null, { accessLevel: membershipState.MEMBER }); + tokenFunctions.decodeToken(requestWithToken, membershipState.OFFICER) + .then(decodedResponse => { + expect(decodedResponse.status).to.equal(FORBIDDEN); + done(); + }); + }); + it('Should resolve with OK and the decoded token if token is valid and access level is sufficient', + done => { + const decodedToken = { accessLevel: membershipState.OFFICER, firstName: 'Test' }; + jwtStub.yields(null, decodedToken); + tokenFunctions.decodeToken(requestWithToken, membershipState.OFFICER) + .then(decodedResponse => { + expect(decodedResponse.status).to.equal(OK); + expect(decodedResponse.token).to.equal(decodedToken); + done(); + }); + }); }); }); diff --git a/test/api/User.js b/test/api/User.js index cd97047cb..135c04041 100644 --- a/test/api/User.js +++ b/test/api/User.js @@ -12,7 +12,6 @@ let id = new mongoose.Types.ObjectId(); const chaiHttp = require('chai-http'); const { OK, - BAD_REQUEST, UNAUTHORIZED, NOT_FOUND, FORBIDDEN @@ -37,12 +36,6 @@ const { initializeTokenMock } = require('../util/mocks/TokenValidFunctions'); -const { - setDiscordAPIStatus, - resetDiscordAPIMock, - restoreDiscordAPIMock, - initializeDiscordAPIMock -} = require('../util/mocks/DiscordApiFunction'); const { MEMBERSHIP_STATE } = require('../../api/util/constants'); const { getMemberExpirationDate } = require('../../api/main_endpoints/util/userHelpers.js'); @@ -53,7 +46,6 @@ chai.use(chaiHttp); describe('User', () => { before(done => { initializeTokenMock(); - initializeDiscordAPIMock(); app = tools.initializeServer([ __dirname + '/../../api/main_endpoints/routes/User.js', __dirname + '/../../api/main_endpoints/routes/Auth.js' @@ -74,30 +66,27 @@ describe('User', () => { after(done => { restoreTokenMock(); - restoreDiscordAPIMock(); tools.terminateServer(done); }); beforeEach(() => { setTokenStatus(false); - setDiscordAPIStatus(false); }); afterEach(() => { resetTokenMock(); - resetDiscordAPIMock(); }); const token = ''; describe('/POST search', () => { - it('Should return statusCode 403 if no token is passed in', async () => { + it('Should return statusCode 401 if no token is passed in', async () => { const user = { email: 'a@b.c' }; const result = await test.sendPostRequest( '/api/User/users', user); - expect(result).to.have.status(FORBIDDEN); + expect(result).to.have.status(UNAUTHORIZED); }); it('Should return statusCode 401 if an invalid ' + @@ -124,13 +113,13 @@ describe('User', () => { }); describe('/POST searchFor', () => { - it('Should return statusCode 403 if no token is passed in', async () => { + it('Should return statusCode 401 if no token is passed in', async () => { const user = { email: 'a@b.c' }; const result = await test.sendPostRequest( '/api/User/search', user); - expect(result).to.have.status(FORBIDDEN); + expect(result).to.have.status(UNAUTHORIZED); }); it('Should return statusCode 401 if an invalid ' + @@ -180,13 +169,13 @@ describe('User', () => { }); describe('/POST edit', () => { - it('Should return statusCode 403 if no token is passed in', async () => { + it('Should return statusCode 401 if no token is passed in', async () => { const user = { _id: id, }; const result = await test.sendPostRequest( '/api/User/edit', user); - expect(result).to.have.status(FORBIDDEN); + expect(result).to.have.status(UNAUTHORIZED); }); it('Should return statusCode 401 if an invalid ' + @@ -223,7 +212,8 @@ describe('User', () => { password: 'Passw0rd', firstName: 'first-name', lastName: 'last-name', - major: 'Computer Science' + major: 'Computer Science', + accessLevel: MEMBERSHIP_STATE.OFFICER, }).save(); setTokenStatus(true, testUser); @@ -369,14 +359,14 @@ describe('User', () => { }); describe('/POST getUserById', () => { - it('Should return status code 403 if no token was passed in', async () => { + it('Should return status code 401 if no token was passed in', async () => { const user = { userID: id, }; const result = await test.sendPostRequest('/api/user/getUserById', user); - expect(result).to.have.status(FORBIDDEN); + expect(result).to.have.status(UNAUTHORIZED); }); - it('Should return status code 403 if' + + it('Should return status code 401 if' + ' an invalid token was passed in', async () => { const user = { userID: id, @@ -439,13 +429,13 @@ describe('User', () => { await userAdmin.save(); }); - it('Should return statusCode 403 if no token is passed in', async () => { + it('Should return statusCode 401 if no token is passed in', async () => { const user = { _id : id }; const result = await test.sendPostRequest( '/api/User/delete', user); - expect(result).to.have.status(FORBIDDEN); + expect(result).to.have.status(UNAUTHORIZED); }); it('Should return statusCode 403 if an invalid ' + @@ -487,7 +477,7 @@ describe('User', () => { }); it('Should return statusCode 200 if user deletes themself', async () => { - setTokenStatus(true); + setTokenStatus(true, {accessLevel: MEMBERSHIP_STATE.MEMBER}); const deleteUser = { email: 'h@i.j', password: 'Passw0rd', @@ -505,33 +495,66 @@ describe('User', () => { _id: getUser.body._id, token: token }; + setTokenStatus(true, {accessLevel: MEMBERSHIP_STATE.MEMBER, _id: getUser.body._id}); const result = await test.sendPostRequestWithToken( token, '/api/User/delete', user); expect(result).to.have.status(OK); }); - it('Should return statusCode 200 if user deletes themself as a member', async () => { - setTokenStatus(true, {accessLevel: MEMBERSHIP_STATE.MEMBER}); - const deleteUser = { - email: 'h@i.j', + it('Should return statusCode 403 if a member deletes another member', async () => { + setTokenStatus(true, { accessLevel: MEMBERSHIP_STATE.MEMBER }); + // Define credentials for the Member (the deleting user) + const memberCredentials = { + email: 'member@test.com', password: 'Passw0rd', - firstName: 'first-name', - lastName: 'last-name', + firstName: 'Member', + lastName: 'User', }; - const searchUser = { - email: 'h@i.j', - token: token + + // Define credentials for the Target User (the user being deleted) + const targetCredentials = { + email: 'target@test.com', + password: 'TargetPassw0rd', + firstName: 'Target', + lastName: 'User', }; - await test.sendPostRequest('/api/Auth/register', deleteUser); - const getUser = await test.sendPostRequestWithToken( - token, '/api/User/search', searchUser); - const user = { - _id: getUser.body._id, - token: token + + // Register the Target User (the one to be deleted) + await test.sendPostRequest('/api/Auth/register', targetCredentials); + + // Register the Member and get their JWT and decoded token data + await test.sendPostRequest('/api/Auth/register', memberCredentials); + + // Find the Target User to get their _id + // Use the *real* member token to perform the search + const targetSearchResponse = await test.sendPostRequestWithToken( + token, + '/api/User/search', + { email: targetCredentials.email } + ); + const memberSearchResponse = await test.sendPostRequestWithToken( + token, + '/api/User/search', + { email: memberCredentials.email } + ); + + // The target user ID is what we want to delete + const targetUserId = targetSearchResponse.body._id; + const memberUserId = memberSearchResponse.body._id; + + + // Member attempts to delete the Target User using the Target User's ID + const deletePayload = { + _id: targetUserId, // ID of the user to delete (NOT the member's ID) }; + + setTokenStatus(true, { accessLevel: MEMBERSHIP_STATE.MEMBER, _id: memberUserId }); + const result = await test.sendPostRequestWithToken( - token, '/api/User/delete', user); - expect(result).to.have.status(FORBIDDEN); + token, '/api/User/delete', deletePayload); + + // Verification + expect(result).to.have.status(FORBIDDEN); // Expect 403 result.body.should.have.property('message'); result.body.message.should.equal( 'you must be an officer or admin to delete other users', @@ -589,9 +612,9 @@ describe('User', () => { }); // no token - it('Should return status code 403 if no token is passed through', async () => { + it('Should return status code 401 if no token is passed through', async () => { const result = await test.sendPostRequest('/api/user/apikey', {}); - expect(result).to.have.status(FORBIDDEN); + expect(result).to.have.status(UNAUTHORIZED); }); // invalid token @@ -614,9 +637,9 @@ describe('User', () => { expect(result).to.have.status(OK); }); - it('Should return statusCode 403 if no token is passed in', async () => { + it('Should return statusCode 401 if no token is passed in', async () => { const result = await test.sendGetRequest('/api/user/getNewPaidMembersThisSemester'); - expect(result).to.have.status(FORBIDDEN); + expect(result).to.have.status(UNAUTHORIZED); }); it('Should return statusCode 401 if an invalid' + diff --git a/test/util/mocks/DiscordApiFunction.js b/test/util/mocks/DiscordApiFunction.js deleted file mode 100644 index c9b310394..000000000 --- a/test/util/mocks/DiscordApiFunction.js +++ /dev/null @@ -1,45 +0,0 @@ -const DiscordValidation = require('../../../api/util/token-verification'); -const sinon = require('sinon'); - -let discordApiKeyMock = null; - -/** - * Initialize the stub to be used in other functions. - */ -function initializeDiscordAPIMock() { - discordApiKeyMock = sinon.stub(DiscordValidation, 'checkDiscordKey'); -} - -/** - * Restore sinon's stub, function returned to its original state - */ -function restoreDiscordAPIMock() { - discordApiKeyMock.restore(); -} - -/** - * Reset sinon-stub's call, reset onCall-function back to the beginning - */ -function resetDiscordAPIMock() { - discordApiKeyMock.reset(); -} - -/** - * - * @param {Boolean} returnValue - * @returns the value of the boolean param. - */ -function setDiscordAPIStatus(returnValue) { - if (returnValue) { - discordApiKeyMock.returns(true); - } else { - discordApiKeyMock.returns(false); - } -} - -module.exports = { - setDiscordAPIStatus, - resetDiscordAPIMock, - restoreDiscordAPIMock, - initializeDiscordAPIMock -}; diff --git a/test/util/mocks/TokenValidFunctions.js b/test/util/mocks/TokenValidFunctions.js index f3d1e36f9..3854fa8c7 100644 --- a/test/util/mocks/TokenValidFunctions.js +++ b/test/util/mocks/TokenValidFunctions.js @@ -1,15 +1,14 @@ const TokenFunctions = require( '../../../api/main_endpoints/util/token-functions'); const sinon = require('sinon'); +const { OK, FORBIDDEN, UNAUTHORIZED } = require('../../../api/util/constants').STATUS_CODES; -let checkifTokenValidMock = null; let decodeTokenValidMock = null; /** * Initialize the stub to be used in other functions. */ function initializeTokenMock() { - checkifTokenValidMock = sinon.stub(TokenFunctions, 'checkIfTokenValid'); decodeTokenValidMock = sinon.stub(TokenFunctions, 'decodeToken'); } @@ -17,7 +16,6 @@ function initializeTokenMock() { * Restore sinon's stub, function returned to its original state */ function restoreTokenMock() { - checkifTokenValidMock.restore(); decodeTokenValidMock.restore(); } @@ -25,7 +23,6 @@ function restoreTokenMock() { * Reset sinon-stub's call, reset onCall-function back to the beginning */ function resetTokenMock() { - checkifTokenValidMock.reset(); decodeTokenValidMock.reset(); } @@ -38,15 +35,18 @@ function resetTokenMock() { * @returns return parameter (above) */ function setTokenStatus( - returnValue, + isSuccessful, data = {}, ) { - checkifTokenValidMock.returns(returnValue); - if (returnValue) { - decodeTokenValidMock.returns(data); - } else { - decodeTokenValidMock.returns(null); - } + const status = isSuccessful ? OK : UNAUTHORIZED; + const tokenPayload = isSuccessful ? data : null; + + decodeTokenValidMock.returns( + Promise.resolve({ + status: status, + token: tokenPayload, + }) + ); } module.exports = {