diff --git a/package-lock.json b/package-lock.json index f549f7b9ad..15d74ff12f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -160,6 +160,7 @@ "@testing-library/jest-dom": "^5.15.0", "@testing-library/react": "^12.1.2", "@types/bcryptjs": "^2.4.6", + "@types/express": "^5.0.3", "@types/classnames": "^2.3.0", "@types/friendly-words": "^1.2.2", "@types/jest": "^29.5.14", @@ -13014,6 +13015,32 @@ "url": "https://opencollective.com/storybook" } }, + "node_modules/@storybook/addons/node_modules/@types/express": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@storybook/addons/node_modules/@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@storybook/addons/node_modules/type-fest": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", @@ -15356,6 +15383,32 @@ "url": "https://opencollective.com/storybook" } }, + "node_modules/@storybook/types/node_modules/@types/express": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@storybook/types/node_modules/@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-6.0.0.tgz", @@ -16274,22 +16327,23 @@ "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==" }, "node_modules/@types/express": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", + "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, + "license": "MIT", "dependencies": { "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", + "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "*" } }, "node_modules/@types/express-serve-static-core": { - "version": "4.19.5", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.5.tgz", - "integrity": "sha512-y6W03tvrACO72aijJ5uF02FRq5cgDR9lUxddQ8vyF+GvmjJQqbzDcJngEjURc+ZsG31VI3hODNZJ2URj86pzmg==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", + "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "@types/qs": "*", @@ -16578,7 +16632,8 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/react": { "version": "16.14.65", @@ -50587,6 +50642,30 @@ "file-system-cache": "2.3.0" } }, + "@types/express": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "type-fest": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", @@ -52283,6 +52362,32 @@ "@types/babel__core": "^7.0.0", "@types/express": "^4.7.0", "file-system-cache": "2.3.0" + }, + "dependencies": { + "@types/express": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + } } }, "@svgr/babel-plugin-add-jsx-attribute": { @@ -52937,21 +53042,20 @@ "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==" }, "@types/express": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", + "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, "requires": { "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", + "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "*" } }, "@types/express-serve-static-core": { - "version": "4.19.5", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.5.tgz", - "integrity": "sha512-y6W03tvrACO72aijJ5uF02FRq5cgDR9lUxddQ8vyF+GvmjJQqbzDcJngEjURc+ZsG31VI3hODNZJ2URj86pzmg==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", + "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", "dev": true, "requires": { "@types/node": "*", diff --git a/package.json b/package.json index 753db99626..1c99409109 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,7 @@ "@testing-library/jest-dom": "^5.15.0", "@testing-library/react": "^12.1.2", "@types/bcryptjs": "^2.4.6", + "@types/express": "^5.0.3", "@types/classnames": "^2.3.0", "@types/friendly-words": "^1.2.2", "@types/jest": "^29.5.14", diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.ts similarity index 64% rename from server/controllers/user.controller.js rename to server/controllers/user.controller.ts index 96f9401075..7bdc805f93 100644 --- a/server/controllers/user.controller.js +++ b/server/controllers/user.controller.ts @@ -1,45 +1,37 @@ -import crypto from 'crypto'; - +/* eslint-disable camelcase */ +import { RequestHandler, Response } from 'express'; +import { + AuthenticatedRequest, + PublicUser, + UserDocument, + UserPreferences, + Error, + CookieConsentOptions, + GenericResponseBody +} from '../types'; import { User } from '../models/user'; import { mailerService } from '../utils/mail'; import { renderEmailConfirmation, renderResetPassword } from '../views/mail'; +import { + userResponse, + generateToken, + saveUser +} from './user.controller/helpers'; export * from './user.controller/apiKey'; -export function userResponse(user) { - return { - email: user.email, - username: user.username, - preferences: user.preferences, - apiKeys: user.apiKeys, - verified: user.verified, - id: user._id, - totalSize: user.totalSize, - github: user.github, - google: user.google, - cookieConsent: user.cookieConsent - }; -} - -/** - * Create a new verification token. - * Note: can be done synchronously - https://nodejs.org/api/crypto.html#cryptorandombytessize-callback - * @return Promise - */ -async function generateToken() { - return new Promise((resolve, reject) => { - crypto.randomBytes(20, (err, buf) => { - if (err) { - reject(err); - } else { - const token = buf.toString('hex'); - resolve(token); - } - }); - }); +/** POST /signup, UserController.createUser */ +export interface CreateUserRequestBody { + username: string; + email: string; + password: string; } - -export async function createUser(req, res) { +/** POST /signup, UserController.createUser */ +export const createUser: RequestHandler< + {}, + PublicUser | Error, + CreateUserRequestBody +> = async (req, res) => { try { const { username, email, password } = req.body; const emailLowerCase = email.toLowerCase(); @@ -79,7 +71,7 @@ export async function createUser(req, res) { domain: `${protocol}://${req.headers.host}`, link: `${protocol}://${req.headers.host}/verify?t=${token}` }, - to: req.user.email + to: (req as AuthenticatedRequest).user.email }); try { @@ -94,11 +86,32 @@ export async function createUser(req, res) { console.error(err); res.status(500).json({ error: err }); } -} +}; -export async function duplicateUserCheck(req, res) { +export interface DuplicateUserCheckResponseBody { + exists: boolean; + message?: string; + type: 'email' | 'username'; +} +export interface DuplicateUserQuery { + check_type: 'email' | 'username'; + email?: string; + username?: string; +} +export const duplicateUserCheck: RequestHandler< + {}, + DuplicateUserCheckResponseBody | Error, + {}, + DuplicateUserQuery +> = async (req, res) => { const checkType = req.query.check_type; const value = req.query[checkType]; + if (!value) { + return res.status(500).json({ + error: + 'Invalid combination for duplicate user check-type and value of email or username' + }); + } const options = { caseInsensitive: true, valueType: checkType }; const user = await User.findByEmailOrUsername(value, options); if (user) { @@ -112,11 +125,24 @@ export async function duplicateUserCheck(req, res) { exists: false, type: checkType }); -} +}; -export async function updatePreferences(req, res) { +export interface UpdatePreferencesRequestBody { + preferences: UserPreferences; +} +export const updatePreferences: RequestHandler< + {}, + UserPreferences | Error, + UpdatePreferencesRequestBody +> = async (req, res) => { try { - const user = await User.findById(req.user.id).exec(); + const { user: requestUser } = req as AuthenticatedRequest; + if (!requestUser) { + res.status(404).json({ + error: 'You must be logged in to complete this action.' + }); + } + const user = await User.findById(requestUser.id).exec(); if (!user) { res.status(404).json({ error: 'User not found' }); return; @@ -128,9 +154,16 @@ export async function updatePreferences(req, res) { } catch (err) { res.status(500).json({ error: err }); } -} +}; -export async function resetPasswordInitiate(req, res) { +export interface ResetPasswordInitiateRequestBody { + email: string; +} +export const resetPasswordInitiate: RequestHandler< + {}, + GenericResponseBody, + ResetPasswordInitiateRequestBody +> = async (req, res) => { try { const token = await generateToken(); const user = await User.findByEmail(req.body.email); @@ -165,9 +198,15 @@ export async function resetPasswordInitiate(req, res) { console.log(err); res.json({ success: false }); } -} +}; -export async function validateResetPasswordToken(req, res) { +export interface ValidateResetPasswordTokenRouteParams { + token: string; +} +export const validateResetPasswordToken: RequestHandler< + ValidateResetPasswordTokenRouteParams, + GenericResponseBody +> = async (req, res) => { const user = await User.findOne({ resetPasswordToken: req.params.token, resetPasswordExpires: { $gt: Date.now() } @@ -180,12 +219,23 @@ export async function validateResetPasswordToken(req, res) { return; } res.json({ success: true }); -} +}; -export async function emailVerificationInitiate(req, res) { +export const emailVerificationInitiate: RequestHandler< + {}, + PublicUser | Error +> = async (req, res) => { try { const token = await generateToken(); - const user = await User.findById(req.user.id).exec(); + + const { user: requestUser } = req as AuthenticatedRequest; + if (!requestUser) { + res.status(404).json({ + error: 'You must be logged in to complete this action.' + }); + } + + const user = await User.findById(requestUser.id).exec(); if (!user) { res.status(404).json({ error: 'User not found' }); return; @@ -214,13 +264,19 @@ export async function emailVerificationInitiate(req, res) { user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; // 24 hours await user.save(); - res.json(userResponse(req.user)); + res.json(userResponse((req as AuthenticatedRequest).user)); } catch (err) { res.status(500).json({ error: err }); } -} +}; -export async function verifyEmail(req, res) { +export interface VerifyEmailRouteParams { + t: string; +} +export const verifyEmail: RequestHandler< + VerifyEmailRouteParams, + GenericResponseBody +> = async (req, res) => { const token = req.query.t; const user = await User.findOne({ verifiedToken: token, @@ -238,9 +294,19 @@ export async function verifyEmail(req, res) { user.verifiedTokenExpires = null; await user.save(); res.json({ success: true }); -} +}; -export async function updatePassword(req, res) { +export interface UpdatePasswordRouteParams { + token: string; +} +export interface UpdatePasswordRequestBody { + password: string; +} +export const updatePassword: RequestHandler< + UpdatePasswordRouteParams, + GenericResponseBody | PublicUser, + UpdatePasswordRequestBody +> = async (req, res) => { const user = await User.findOne({ resetPasswordToken: req.params.token, resetPasswordExpires: { $gt: Date.now() } @@ -258,37 +324,32 @@ export async function updatePassword(req, res) { user.resetPasswordExpires = undefined; await user.save(); - req.logIn(user, (loginErr) => res.json(userResponse(req.user))); + req.logIn(user, (loginErr) => + res.json(userResponse(((req as unknown) as AuthenticatedRequest).user)) + ); // eventually send email that the password has been reset -} +}; -/** - * @param {string} username - * @return {Promise} - */ -export async function userExists(username) { - const user = await User.findByUsername(username); - return user != null; +export interface UpdateSettingsRequestBody { + username: string; + newPassword: string; + currentPassword: string; + email: string; } - -/** - * Updates the user object and sets the response. - * Response is the user or a 500 error. - * @param res - * @param user - */ -export async function saveUser(res, user) { +export const updateSettings: RequestHandler< + {}, + void | Error, + UpdateSettingsRequestBody +> = async (req, res) => { try { - await user.save(); - res.json(userResponse(user)); - } catch (error) { - res.status(500).json({ error }); - } -} + const { user: requestUser } = req as AuthenticatedRequest; + if (!requestUser) { + res.status(404).json({ + error: 'You must be logged in to complete this action.' + }); + } -export async function updateSettings(req, res) { - try { - const user = await User.findById(req.user.id); + const user = await User.findById(requestUser.id); if (!user) { res.status(404).json({ error: 'User not found' }); return; @@ -341,41 +402,47 @@ export async function updateSettings(req, res) { } catch (err) { res.status(500).json({ error: err }); } -} +}; -export async function unlinkGithub(req, res) { +export const unlinkGithub: RequestHandler = async (req, res) => { if (req.user) { - req.user.github = undefined; - req.user.tokens = req.user.tokens.filter( - (token) => token.kind !== 'github' - ); - await saveUser(res, req.user); + const { user } = req as AuthenticatedRequest; + user.github = undefined; + user.tokens = user.tokens.filter((token) => token.kind !== 'github'); + await saveUser(res, user as UserDocument); return; } res.status(404).json({ success: false, message: 'You must be logged in to complete this action.' }); -} +}; -export async function unlinkGoogle(req, res) { +export const unlinkGoogle: RequestHandler = async (req, res) => { if (req.user) { - req.user.google = undefined; - req.user.tokens = req.user.tokens.filter( - (token) => token.kind !== 'google' - ); - await saveUser(res, req.user); + const { user } = req as AuthenticatedRequest; + user.google = undefined; + user.tokens = user.tokens.filter((token) => token.kind !== 'google'); + await saveUser(res, user as UserDocument); return; } res.status(404).json({ success: false, message: 'You must be logged in to complete this action.' }); -} +}; -export async function updateCookieConsent(req, res) { +export interface UpdateCookieConsentResponse { + cookieConsent: CookieConsentOptions; +} +export const updateCookieConsent: RequestHandler< + {}, + PublicUser | Error, + UpdateCookieConsentResponse +> = async (req, res) => { try { - const user = await User.findById(req.user.id).exec(); + const { user: requestUser } = req as AuthenticatedRequest; + const user = await User.findById(requestUser.id).exec(); if (!user) { res.status(404).json({ error: 'User not found' }); return; @@ -386,4 +453,4 @@ export async function updateCookieConsent(req, res) { } catch (err) { res.status(500).json({ error: err }); } -} +}; diff --git a/server/controllers/user.controller/__tests__/apiKey.test.js b/server/controllers/user.controller/__tests__/apiKey.test.js index 87dc1320e8..641de79f04 100644 --- a/server/controllers/user.controller/__tests__/apiKey.test.js +++ b/server/controllers/user.controller/__tests__/apiKey.test.js @@ -4,7 +4,7 @@ import { last } from 'lodash'; import { Request, Response } from 'jest-express'; import { User } from '../../../models/user'; -import { createApiKey, removeApiKey } from '../apiKey'; +import { createApiKey, removeApiKey } from '../index'; jest.mock('../../../models/user'); diff --git a/server/controllers/user.controller/__tests__/helpers.test.js b/server/controllers/user.controller/__tests__/helpers.test.js new file mode 100644 index 0000000000..9b6f2ca96d --- /dev/null +++ b/server/controllers/user.controller/__tests__/helpers.test.js @@ -0,0 +1,168 @@ +/* eslint-disable no-unused-vars */ +/* @jest-environment node */ + +import { Request, Response } from 'jest-express'; +import crypto from 'crypto'; + +import { userResponse, generateToken, saveUser, userExists } from '../index'; +import { User } from '../../../models/user'; +import { CookieConsentOptions, AppThemeOptions } from '../../../types'; + +jest.mock('../../../models/user'); + +const mockFullUser = { + email: 'test@example.com', + username: 'tester', + preferences: { + fontSize: 12, + lineNumbers: false, + indentationAmount: 10, + isTabIndent: false, + autosave: false, + linewrap: false, + lintWarning: false, + textOutput: false, + gridOutput: false, + theme: AppThemeOptions.CONTRAST, + autorefresh: false, + language: 'en-GB', + autocloseBracketsQuotes: false, + autocompleteHinter: false + }, + apiKeys: [], + verified: true, + id: 'abc123', + totalSize: 42, + cookieConsent: CookieConsentOptions.NONE, + google: 'user@gmail.com', + github: 'user123', + + // to be removed: + password: 'abweorij', + resetPasswordToken: '1i14ij23', + banned: false +}; + +describe('user.helpers', () => { + let request; + let response; + + beforeEach(() => { + request = new Request(); + response = new Response(); + }); + + afterEach(() => { + request.resetMocked(); + response.resetMocked(); + jest.clearAllMocks(); + }); + + describe('userResponse', () => { + it('returns a sanitized PublicUser object', () => { + const result = userResponse(mockFullUser); + + const { + password, + resetPasswordToken, + banned, + save, + ...sanitised + } = result; + + expect(result).toMatchObject(sanitised); + }); + it('gracefully handles objects with some, but not all properties of PublicUser', () => { + const fakeUser = { + email: 'test@example.com', + username: 'tester', + id: 'abc123', + totalSize: 42, + + // to be removed: + password: 'abweorij', + resetPasswordToken: '1i14ij23', + banned: false + }; + + const result = userResponse(fakeUser); + + const { + password, + resetPasswordToken, + banned, + save, + ...sanitised + } = result; + + expect(result).toMatchObject(sanitised); + }); + }); + + describe('generateToken', () => { + it('generates a random hex string of length 40', async () => { + const token = await generateToken(); + expect(typeof token).toBe('string'); + expect(token).toMatch(/^[a-f0-9]+$/); + expect(token).toHaveLength(40); + }); + + it('rejects if crypto.randomBytes errors', async () => { + const spy = jest + .spyOn(crypto, 'randomBytes') + .mockImplementationOnce((_size, cb) => { + cb(new Error('fail'), Buffer.alloc(0)); + return {}; + }); + + await expect(generateToken()).rejects.toThrow('fail'); + + spy.mockRestore(); + }); + }); + + describe('saveUser', () => { + it('saves user and responds with sanitized user', async () => { + const user = { + ...mockFullUser, + save: jest.fn().mockResolvedValue(undefined) + }; + + await saveUser(response, user); + + expect(user.save).toHaveBeenCalled(); + + const { password, resetPasswordToken, banned, save, ...sanitised } = user; + + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining(sanitised) + ); + }); + + it('responds with 500 if save fails', async () => { + const user = { + ...mockFullUser, + save: jest.fn().mockRejectedValue(new Error('db error')) + }; + + await saveUser(response, user); + + expect(response.status).toHaveBeenCalledWith(500); + expect(response.json).toHaveBeenCalledWith({ error: expect.any(Error) }); + }); + }); + + describe('userExists', () => { + it('returns true if user is found', async () => { + User.findByUsername = jest.fn().mockResolvedValue({ id: '123' }); + const result = await userExists('someone'); + expect(result).toBe(true); + }); + + it('returns false if user not found', async () => { + User.findByUsername = jest.fn().mockResolvedValue(null); + const result = await userExists('nobody'); + expect(result).toBe(false); + }); + }); +}); diff --git a/server/controllers/user.controller/__tests__/signup.test.ts b/server/controllers/user.controller/__tests__/signup.test.ts new file mode 100644 index 0000000000..652e74e53f --- /dev/null +++ b/server/controllers/user.controller/__tests__/signup.test.ts @@ -0,0 +1,801 @@ +/* @jest-environment node */ + +import { Request, Response } from 'jest-express'; + +import * as controller from '../../user.controller'; +import { User } from '../../../models/user'; +import { mailerService } from '../../../utils/mail'; +import { + renderEmailConfirmation, + renderResetPassword +} from '../../../views/mail'; +import { userResponse, generateToken, saveUser } from '../helpers'; + +jest.mock('../../../utils/mail', () => ({ + mailerService: { + send: jest.fn().mockResolvedValue(true) + } +})); + +jest.mock('../../../models/user'); +jest.mock('../../../utils/mail'); +jest.mock('../../../views/mail'); +jest.mock('../helpers'); + +const mockUserBase = { + email: 'test@example.com', + name: 'bob dylan', + username: 'tester', + preferences: { + fontSize: 12, + lineNumbers: false, + indentationAmount: 10, + isTabIndent: false, + autosave: false, + linewrap: false, + lintWarning: false, + textOutput: false, + gridOutput: false, + theme: 'contrast', + autorefresh: false, + language: 'en-GB', + autocloseBracketsQuotes: false, + autocompleteHinter: false + }, + apiKeys: [], + verified: true, + id: 'abc123', + totalSize: 42, + cookieConsent: 'none', + google: 'user@gmail.com', + github: 'user123', + tokens: [{ kind: 'github' }, { kind: 'google' }], + banned: false +}; + +describe('user.controller unit tests', () => { + let req: Request; + let res: Response; + let next: () => void; + + beforeEach(() => { + req = new Request(); + res = new Response(); + next = jest.fn(); + + jest.clearAllMocks(); + + // default mocks for helpers + (generateToken as jest.Mock).mockResolvedValue('tok-123'); + (userResponse as jest.Mock).mockImplementation((u) => + // return a shallow sanitized shape + ({ + email: u.email, + username: u.username, + id: u.id, + totalSize: u.totalSize, + apiKeys: u.apiKeys, + cookieConsent: u.cookieConsent, + preferences: u.preferences, + verified: u.verified, + google: u.google, + github: u.github + }) + ); + + // default mail renderer & mailer + (renderEmailConfirmation as jest.Mock).mockImplementation( + ({ body, to }) => ({ + to, + body + }) + ); + (renderResetPassword as jest.Mock).mockImplementation(({ body, to }) => ({ + to, + body + })); + (mailerService.send as jest.Mock).mockResolvedValue(undefined); + + // default EmailConfirmation helper on User + ((User as unknown) as any).EmailConfirmation = jest.fn().mockReturnValue({ + Sent: 'sent', + Resent: 'resent', + Verified: 'verified' + }); + }); + + /** ************************************************************************* + * createUser + ************************************************************************** */ + describe('createUser', () => { + it('responds 422 when email or username already in use', async () => { + (User as any).findByEmailAndUsername = jest + .fn() + .mockResolvedValue({ email: 'test@example.com' }); + + req.body = { + username: 'tester', + email: 'test@example.com', + password: 'pw' + }; + req.headers.host = 'example.test'; + + await controller.createUser(req, res, next); + + expect(res.status).toHaveBeenCalledWith(422); + expect(res.send).toHaveBeenCalledWith({ error: expect.any(String) }); + }); + + it('creates user, logs in, sends email and responds with sanitized user', async () => { + (User as any).findByEmailAndUsername = jest.fn().mockResolvedValue(null); + + // new User constructor should return an object with save + const newUser = { + ...mockUserBase, + password: 'pw', + verified: 'sent', + verifiedToken: 'tok-123', + verifiedTokenExpires: expect.any(Number), + save: jest.fn().mockResolvedValue(undefined) + }; + // mock User constructor + (User as any).mockImplementationOnce(() => newUser); + + // req.logIn to call callback with no error + req.logIn = jest.fn((user, cb) => cb && cb(null)); + req.body = { + username: 'tester', + email: 'TEST@EXAMPLE.COM', + password: 'pw' + }; + req.headers.host = 'example.test'; + + await controller.createUser(req, res, next); + + expect(newUser.save).toHaveBeenCalled(); + expect(mailerService.send).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining(userResponse(newUser)) + ); + }); + + it('handles login error by responding 500', async () => { + (User as any).findByEmailAndUsername = jest.fn().mockResolvedValue(null); + const newUser = { + ...mockUserBase, + password: 'pw', + verified: 'sent', + verifiedToken: 'tok-123', + verifiedTokenExpires: Date.now() + 1000, + save: jest.fn().mockResolvedValue(undefined) + }; + (User as any).mockImplementationOnce(() => newUser); + + req.logIn = jest.fn((user, cb) => cb && cb(new Error('login fail'))); + req.body = { + username: 'tester', + email: 'test@example.com', + password: 'pw' + }; + req.headers.host = 'example.test'; + + await controller.createUser(req as any, res as any); + + expect(newUser.save).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + error: 'Failed to log in user.' + }); + }); + + it('handles mailer failure by responding 500', async () => { + (User as any).findByEmailAndUsername = jest.fn().mockResolvedValue(null); + const newUser = { + ...mockUserBase, + password: 'pw', + verified: 'sent', + verifiedToken: 'tok-123', + verifiedTokenExpires: Date.now() + 1000, + save: jest.fn().mockResolvedValue(undefined) + }; + (User as any).mockImplementationOnce(() => newUser); + + req.logIn = jest.fn((user, cb) => cb && cb(null)); + (mailerService.send as jest.Mock).mockRejectedValue( + new Error('smtp fail') + ); + req.body = { + username: 'tester', + email: 'test@example.com', + password: 'pw' + }; + req.headers.host = 'example.test'; + + await controller.createUser(req as any, res as any); + + expect(newUser.save).toHaveBeenCalled(); + expect(mailerService.send).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + error: 'Failed to send verification email.' + }); + }); + }); + + /** ************************************************************************* + * duplicateUserCheck + ************************************************************************** */ + describe('duplicateUserCheck', () => { + it('responds 500 when missing value', async () => { + req.query = { check_type: 'email' }; // but email param missing + await controller.duplicateUserCheck(req as any, res as any); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + error: expect.any(String) + }); + }); + + it('returns exists:true when found', async () => { + (User as any).findByEmailOrUsername = jest + .fn() + .mockResolvedValue({ id: 'u1' }); + req.query = { check_type: 'email', email: 'a@b.com' }; + + await controller.duplicateUserCheck(req as any, res as any); + + expect(res.json).toHaveBeenCalledWith({ + exists: true, + message: 'This email is already taken.', + type: 'email' + }); + }); + + it('returns exists:false when not found', async () => { + (User as any).findByEmailOrUsername = jest.fn().mockResolvedValue(null); + req.query = { check_type: 'username', username: 'someone' }; + + await controller.duplicateUserCheck(req as any, res as any); + + expect(res.json).toHaveBeenCalledWith({ + exists: false, + type: 'username' + }); + }); + }); + + /** ************************************************************************* + * updatePreferences + ************************************************************************** */ + describe('updatePreferences', () => { + it('returns 404 if not logged in', async () => { + req.body = { preferences: { fontSize: 14 } }; + await controller.updatePreferences(req as any, res as any); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + error: 'You must be logged in to complete this action.' + }); + }); + + it('returns 404 if user not found', async () => { + req.user = { id: 'abc123' } as any; + (User as any).findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); + + req.body = { preferences: { fontSize: 14 } }; + + await controller.updatePreferences(req as any, res as any); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'User not found' }); + }); + + it('merges preferences, saves and returns preferences', async () => { + const user = { + ...mockUserBase, + preferences: { fontSize: 12, lineNumbers: false }, + save: jest.fn().mockResolvedValue(undefined) + }; + (User as any).findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(user) }); + + req.user = { id: 'abc123' } as any; + req.body = { preferences: { fontSize: 16 } }; + + await controller.updatePreferences(req as any, res as any); + + expect(user.save).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ fontSize: 16 }) + ); + }); + }); + + /** ************************************************************************* + * resetPasswordInitiate + ************************************************************************** */ + describe('resetPasswordInitiate', () => { + it('responds success true if user not found (no leak)', async () => { + (generateToken as jest.Mock).mockResolvedValue('tok-123'); + (User as any).findByEmail = jest.fn().mockResolvedValue(null); + req.body = { email: 'no-such@example.com' }; + req.headers.host = 'example.test'; + + await controller.resetPasswordInitiate(req as any, res as any); + + expect(res.json).toHaveBeenCalledWith({ + success: true, + message: expect.any(String) + }); + }); + + it('saves token, sends email and responds success true when user found', async () => { + (generateToken as jest.Mock).mockResolvedValue('tok-123'); + const user = { + ...mockUserBase, + save: jest.fn().mockResolvedValue(undefined) + }; + (User as any).findByEmail = jest.fn().mockResolvedValue(user); + req.body = { email: 'test@example.com' }; + req.headers.host = 'example.test'; + + await controller.resetPasswordInitiate(req as any, res as any); + + expect(user.save).toHaveBeenCalled(); + expect(mailerService.send).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ + success: true, + message: expect.any(String) + }); + }); + + it('returns success false on exception (mailer error)', async () => { + (generateToken as jest.Mock).mockResolvedValue('tok-123'); + const user = { + ...mockUserBase, + save: jest.fn().mockResolvedValue(undefined) + }; + (User as any).findByEmail = jest.fn().mockResolvedValue(user); + (mailerService.send as jest.Mock).mockRejectedValue( + new Error('mailfail') + ); + req.body = { email: 'test@example.com' }; + req.headers.host = 'example.test'; + + await controller.resetPasswordInitiate(req as any, res as any); + + expect(user.save).toHaveBeenCalled(); + expect(mailerService.send).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ success: false }); + }); + }); + + /** ************************************************************************* + * validateResetPasswordToken + ************************************************************************** */ + describe('validateResetPasswordToken', () => { + it('returns 401 when token invalid or expired', async () => { + (User as any).findOne = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); + req.params = { token: 'bad' }; + + await controller.validateResetPasswordToken(req as any, res as any); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + success: false, + message: 'Password reset token is invalid or has expired.' + }); + }); + + it('returns success true when token valid', async () => { + const user = { id: 'u1' }; + (User as any).findOne = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(user) }); + req.params = { token: 'good' }; + + await controller.validateResetPasswordToken(req as any, res as any); + + expect(res.json).toHaveBeenCalledWith({ success: true }); + }); + }); + + /** ************************************************************************* + * emailVerificationInitiate + ************************************************************************** */ + describe('emailVerificationInitiate', () => { + it('returns 404 if not logged in', async () => { + await controller.emailVerificationInitiate(req as any, res as any); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + error: 'You must be logged in to complete this action.' + }); + }); + + it('returns 404 if user not found', async () => { + req.user = { id: 'abc123' } as any; + (User as any).findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); + + await controller.emailVerificationInitiate(req as any, res as any); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'User not found' }); + }); + + it('returns 409 if already verified', async () => { + req.user = { id: 'abc123' } as any; + const user = { ...mockUserBase, verified: 'verified' }; + (User as any).findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(user) }); + + await controller.emailVerificationInitiate(req as any, res as any); + + expect(res.status).toHaveBeenCalledWith(409); + expect(res.json).toHaveBeenCalledWith({ + error: 'Email already verified' + }); + }); + + it('sends mail, updates user, saves and responds with sanitized user', async () => { + req.user = { id: 'abc123', email: 'test@example.com' } as any; + const user = { + ...mockUserBase, + verified: 'sent', + save: jest.fn().mockResolvedValue(undefined) + }; + (User as any).findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(user) }); + (mailerService.send as jest.Mock).mockResolvedValue(undefined); + + req.headers.host = 'example.test'; + + await controller.emailVerificationInitiate(req as any, res as any); + + expect(mailerService.send).toHaveBeenCalled(); + expect(user.save).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining(userResponse(req.user)) + ); + }); + + it('handles mail send error with 500', async () => { + req.user = { id: 'abc123', email: 'test@example.com' } as any; + const user = { + ...mockUserBase, + verified: 'sent', + save: jest.fn().mockResolvedValue(undefined) + }; + (User as any).findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(user) }); + (mailerService.send as jest.Mock).mockRejectedValue(new Error('fail')); + + await controller.emailVerificationInitiate(req as any, res as any); + + expect(mailerService.send).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.send).toHaveBeenCalledWith({ error: 'Error sending mail' }); + }); + }); + + /** ************************************************************************* + * verifyEmail + ************************************************************************** */ + describe('verifyEmail', () => { + it('returns 401 when token invalid', async () => { + (User as any).findOne = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); + req.query = { t: 'bad' }; + + await controller.verifyEmail(req as any, res as any); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + success: false, + message: 'Token is invalid or has expired.' + }); + }); + + it('verifies user, clears tokens and responds success', async () => { + const user = { save: jest.fn().mockResolvedValue(undefined) }; + (User as any).findOne = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(user) }); + req.query = { t: 'good' }; + + await controller.verifyEmail(req as any, res as any); + + expect(user.save).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ success: true }); + }); + }); + + /** ************************************************************************* + * updatePassword + ************************************************************************** */ + describe('updatePassword', () => { + it('returns 401 when token invalid', async () => { + (User as any).findOne = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); + req.params = { token: 'bad' }; + + await controller.updatePassword(req as any, res as any); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + success: false, + message: 'Password reset token is invalid or has expired.' + }); + }); + + it('updates password, saves and logs in user then responds with sanitized user', async () => { + const user = { + ...mockUserBase, + save: jest.fn().mockResolvedValue(undefined) + }; + (User as any).findOne = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(user) }); + + // set req.logIn and req.user for response + req.logIn = jest.fn((u, cb) => cb && cb(null)); + req.params = { token: 'good' }; + req.body = { password: 'newpass' }; + // req.user in this flow used by userResponse after logIn; simulate + (req as any).user = { + email: 'test@example.com', + username: 'tester', + id: 'abc123' + }; + + await controller.updatePassword(req as any, res as any); + + expect(user.save).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining(userResponse(req.user)) + ); + }); + }); + + /** ************************************************************************* + * updateSettings + ************************************************************************** */ + describe('updateSettings', () => { + it('returns 404 if not logged in', async () => { + await controller.updateSettings(req as any, res as any); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + error: 'You must be logged in to complete this action.' + }); + }); + + it('returns 404 if user not found', async () => { + req.user = { id: 'abc123' } as any; + (User as any).findById = jest.fn().mockResolvedValue(null); + await controller.updateSettings(req as any, res as any); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'User not found' }); + }); + + it('updates username and saves via saveUser when email unchanged', async () => { + const user = { + ...mockUserBase, + password: 'old', + save: jest.fn().mockResolvedValue(undefined) + }; + (User as any).findById = jest.fn().mockResolvedValue(user); + req.user = { id: 'abc123' } as any; + req.body = { username: 'newname', email: user.email }; + + (saveUser as jest.Mock).mockResolvedValue(undefined); + + await controller.updateSettings(req as any, res as any); + + expect(user.username).toBe('newname'); + expect(saveUser).toHaveBeenCalledWith(res, user); + }); + + it('changes email, generates token, saves, sends mail', async () => { + const user = { + ...mockUserBase, + save: jest.fn().mockResolvedValue(undefined) + }; + (User as any).findById = jest.fn().mockResolvedValue(user); + req.user = { id: 'abc123' } as any; + req.body = { + username: user.username, + email: 'new@example.com', + newPassword: '', + currentPassword: '' + }; + req.headers.host = 'example.test'; + + (generateToken as jest.Mock).mockResolvedValue('tok-123'); + (saveUser as jest.Mock).mockResolvedValue(undefined); + + await controller.updateSettings(req as any, res as any); + + expect(user.email).toBe('new@example.com'); + expect(user.verified).toBe('sent'); + expect(generateToken).toHaveBeenCalled(); + expect(saveUser).toHaveBeenCalledWith(res, user); + expect(mailerService.send).toHaveBeenCalled(); + }); + + it('when newPassword and user.password undefined -> set password and saveUser called', async () => { + const user = { + ...mockUserBase, + password: undefined, + save: jest.fn().mockResolvedValue(undefined) + }; + (User as any).findById = jest.fn().mockResolvedValue(user); + req.user = { id: 'abc123' } as any; + req.body = { username: 'u', newPassword: 'new', currentPassword: '' }; + + (saveUser as jest.Mock).mockResolvedValue(undefined); + + await controller.updateSettings(req as any, res as any); + + expect(user.password).toBe('new'); + expect(saveUser).toHaveBeenCalledWith(res, user); + }); + + it('returns 401 if newPassword provided but currentPassword missing', async () => { + const user = { ...mockUserBase, password: 'old' }; + (User as any).findById = jest.fn().mockResolvedValue(user); + req.user = { id: 'abc123' } as any; + req.body = { username: 'u', newPassword: 'new', currentPassword: '' }; + + await controller.updateSettings(req as any, res as any); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + error: 'Current password is not provided.' + }); + }); + + it('returns 401 if currentPassword invalid', async () => { + const user: any = { + ...mockUserBase, + password: 'old', + comparePassword: jest.fn().mockResolvedValue(false) + }; + (User as any).findById = jest.fn().mockResolvedValue(user); + req.user = { id: 'abc123' } as any; + req.body = { + username: 'u', + newPassword: 'new', + currentPassword: 'wrong' + }; + + await controller.updateSettings(req as any, res as any); + + expect(user.comparePassword).toHaveBeenCalledWith('wrong'); + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + error: 'Current password is invalid.' + }); + }); + + it('changes password when currentPassword valid and calls saveUser', async () => { + const user: any = { + ...mockUserBase, + password: 'old', + comparePassword: jest.fn().mockResolvedValue(true) + }; + (User as any).findById = jest.fn().mockResolvedValue(user); + req.user = { id: 'abc123' } as any; + req.body = { username: 'u', newPassword: 'new', currentPassword: 'old' }; + + (saveUser as jest.Mock).mockResolvedValue(undefined); + + await controller.updateSettings(req as any, res as any); + + expect(user.comparePassword).toHaveBeenCalledWith('old'); + expect(user.password).toBe('new'); + expect(saveUser).toHaveBeenCalledWith(res, user); + }); + }); + + /** ************************************************************************* + * unlinkGithub / unlinkGoogle + ************************************************************************** */ + describe('unlinkGithub & unlinkGoogle', () => { + it('returns 404 when not logged in for unlinkGithub', async () => { + await controller.unlinkGithub(req as any, res as any); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + success: false, + message: 'You must be logged in to complete this action.' + }); + }); + + it('unlinks github when logged in', async () => { + const user: any = { + ...mockUserBase, + tokens: [{ kind: 'github' }, { kind: 'other' }], + save: jest.fn().mockResolvedValue(undefined) + }; + (saveUser as jest.Mock).mockResolvedValue(undefined); + req.user = user as any; + (req as any).user = user; + + await controller.unlinkGithub(req as any, res as any); + + expect(user.github).toBeUndefined(); + expect(saveUser).toHaveBeenCalledWith(res, user); + }); + + it('returns 404 when not logged in for unlinkGoogle', async () => { + await controller.unlinkGoogle(req as any, res as any); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + success: false, + message: 'You must be logged in to complete this action.' + }); + }); + + it('unlinks google when logged in', async () => { + const user: any = { + ...mockUserBase, + tokens: [{ kind: 'google' }, { kind: 'other' }], + save: jest.fn().mockResolvedValue(undefined) + }; + (saveUser as jest.Mock).mockResolvedValue(undefined); + req.user = user as any; + (req as any).user = user; + + await controller.unlinkGoogle(req as any, res as any); + + expect(user.google).toBeUndefined(); + expect(saveUser).toHaveBeenCalledWith(res, user); + }); + }); + + /** ************************************************************************* + * updateCookieConsent + ************************************************************************** */ + describe('updateCookieConsent', () => { + it('returns 404 if user not found', async () => { + req.user = { id: 'abc123' } as any; + (User as any).findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); + req.body = { cookieConsent: 'none' }; + + await controller.updateCookieConsent(req as any, res as any); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'User not found' }); + }); + + it('sets cookieConsent and calls saveUser', async () => { + const user: any = { + ...mockUserBase, + save: jest.fn().mockResolvedValue(undefined) + }; + (User as any).findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(user) }); + (saveUser as jest.Mock).mockResolvedValue(undefined); + req.user = { id: 'abc123' } as any; + req.body = { cookieConsent: 'none' }; + + await controller.updateCookieConsent(req as any, res as any); + + expect(user.cookieConsent).toBe('none'); + expect(saveUser).toHaveBeenCalledWith(res, user); + }); + }); +}); diff --git a/server/controllers/user.controller/apiKey.js b/server/controllers/user.controller/apiKey.ts similarity index 56% rename from server/controllers/user.controller/apiKey.js rename to server/controllers/user.controller/apiKey.ts index d614a27324..310da2a805 100644 --- a/server/controllers/user.controller/apiKey.js +++ b/server/controllers/user.controller/apiKey.ts @@ -1,12 +1,13 @@ import crypto from 'crypto'; - +import { RequestHandler } from 'express'; import { User } from '../../models/user'; +import type { AuthenticatedRequest, ApiKeyDocument, Error } from '../../types'; /** * Generates a unique token to be used as a Personal Access Token * @returns Promise A promise that resolves to the token, or an Error */ -function generateApiKey() { +function generateApiKey(): Promise { return new Promise((resolve, reject) => { crypto.randomBytes(20, (err, buf) => { if (err) { @@ -18,13 +19,18 @@ function generateApiKey() { }); } -export async function createApiKey(req, res) { - function sendFailure(code, error) { +/** POST /account/api-keys, UserController.createApiKey */ +export const createApiKey: RequestHandler< + {}, + { apiKeys: ApiKeyDocument[] } | Error, + { label: string } +> = async (req, res) => { + function sendFailure(code: number, error: string) { res.status(code).json({ error }); } try { - const user = await User.findById(req.user.id); + const user = await User.findById((req as AuthenticatedRequest).user.id); if (!user) { sendFailure(404, 'User not found'); @@ -49,7 +55,7 @@ export async function createApiKey(req, res) { await user.save(); const apiKeys = user.apiKeys.map((apiKey, index) => { - const fields = apiKey.toObject(); + const fields = apiKey.toObject!(); const shouldIncludeToken = index === addedApiKeyIndex - 1; return shouldIncludeToken ? { ...fields, token: keyToBeHashed } : fields; @@ -57,17 +63,29 @@ export async function createApiKey(req, res) { res.json({ apiKeys }); } catch (err) { - sendFailure(500, err.message || 'Internal server error'); + if (err instanceof Error) { + res.status(500).json({ error: err.message }); + } else { + res.status(500).json({ error: 'Internal server error' }); + } } -} - -export async function removeApiKey(req, res) { - function sendFailure(code, error) { +}; + +/** DELETE /account/api-keys/:keyId, UserController.removeApiKey */ +export const removeApiKey: RequestHandler< + { + keyId: string; + }, + { apiKeys: ApiKeyDocument[] } | Error +> = async (req, res) => { + function sendFailure(code: number, error: string) { res.status(code).json({ error }); } try { - const user = await User.findById(req.user.id); + const user = await User.findById( + ((req as unknown) as AuthenticatedRequest).user.id + ); if (!user) { sendFailure(404, 'User not found'); @@ -85,7 +103,11 @@ export async function removeApiKey(req, res) { await user.save(); res.status(200).json({ apiKeys: user.apiKeys }); - } catch (err) { - sendFailure(500, err.message || 'Internal server error'); + } catch (err: unknown) { + if (err instanceof Error) { + res.status(500).json({ error: err.message }); + } else { + res.status(500).json({ error: 'Internal server error' }); + } } -} +}; diff --git a/server/controllers/user.controller/helpers.ts b/server/controllers/user.controller/helpers.ts new file mode 100644 index 0000000000..4a48427a94 --- /dev/null +++ b/server/controllers/user.controller/helpers.ts @@ -0,0 +1,64 @@ +import crypto from 'crypto'; +import { Response } from 'express'; +import { PublicUser, UserDocument, User as UserType } from '../../types'; +import { User } from '../../models/user'; + +export function userResponse(user: UserType | PublicUser): PublicUser { + return { + email: user.email, + username: user.username, + preferences: user.preferences, + apiKeys: user.apiKeys, + verified: user.verified, + id: user.id, + totalSize: user.totalSize, + github: user.github, + google: user.google, + cookieConsent: user.cookieConsent + }; +} + +/** + * Create a new verification token. + * Note: can be done synchronously - https://nodejs.org/api/crypto.html#cryptorandombytessize-callback + * @return Promise + */ +export async function generateToken(): Promise { + return new Promise((resolve, reject) => { + crypto.randomBytes(20, (err, buf) => { + if (err) { + reject(err); + } else { + const token = buf.toString('hex'); + resolve(token); + } + }); + }); +} + +/** + * Updates the user object and sets the response. + * Response is the user or a 500 error. + * @param res + * @param user + */ +export async function saveUser( + res: Response, + user: UserDocument +): Promise { + try { + await user.save(); + res.json(userResponse(user)); + } catch (error) { + res.status(500).json({ error }); + } +} + +/** + * @param {string} username + * @return {Promise} + */ +export async function userExists(username: string): Promise { + const user = await User.findByUsername(username); + return user != null; +} diff --git a/server/controllers/user.controller/index.ts b/server/controllers/user.controller/index.ts new file mode 100644 index 0000000000..4e472f9bbb --- /dev/null +++ b/server/controllers/user.controller/index.ts @@ -0,0 +1,2 @@ +export * from './apiKey'; +export * from './helpers'; diff --git a/server/routes/aws.routes.ts b/server/routes/aws.routes.ts index 91a5751866..98a339a10c 100644 --- a/server/routes/aws.routes.ts +++ b/server/routes/aws.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import * as AWSController from '../controllers/aws.controller'; -import isAuthenticated from '../utils/isAuthenticated'; +import { isAuthenticated } from '../utils/isAuthenticated'; const router = Router(); diff --git a/server/routes/collection.routes.ts b/server/routes/collection.routes.ts index 4ec02961b1..2b25e042c7 100644 --- a/server/routes/collection.routes.ts +++ b/server/routes/collection.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import * as CollectionController from '../controllers/collection.controller'; -import isAuthenticated from '../utils/isAuthenticated'; +import { isAuthenticated } from '../utils/isAuthenticated'; const router = Router(); diff --git a/server/routes/file.routes.ts b/server/routes/file.routes.ts index c0bc434917..7498fbae24 100644 --- a/server/routes/file.routes.ts +++ b/server/routes/file.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import * as FileController from '../controllers/file.controller'; -import isAuthenticated from '../utils/isAuthenticated'; +import { isAuthenticated } from '../utils/isAuthenticated'; const router = Router(); diff --git a/server/routes/project.routes.ts b/server/routes/project.routes.ts index 26f6ee9501..826752e73f 100644 --- a/server/routes/project.routes.ts +++ b/server/routes/project.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import * as ProjectController from '../controllers/project.controller'; -import isAuthenticated from '../utils/isAuthenticated'; +import { isAuthenticated } from '../utils/isAuthenticated'; const router = Router(); diff --git a/server/routes/user.routes.ts b/server/routes/user.routes.ts index 532ade8923..74aa2a510f 100644 --- a/server/routes/user.routes.ts +++ b/server/routes/user.routes.ts @@ -1,43 +1,74 @@ import { Router } from 'express'; import * as UserController from '../controllers/user.controller'; -import isAuthenticated from '../utils/isAuthenticated'; +import { isAuthenticated } from '../utils/isAuthenticated'; const router = Router(); +/** + * =============== + * SIGN UP + * =============== + */ +// POST /signup router.post('/signup', UserController.createUser); - +// GET /signup/duplicate_check router.get('/signup/duplicate_check', UserController.duplicateUserCheck); +// GET /verify +router.get('/verify', UserController.verifyEmail); +// POST /verify/send +router.post('/verify/send', UserController.emailVerificationInitiate); -router.put('/preferences', isAuthenticated, UserController.updatePreferences); +/** + * =============== + * API KEYS + * =============== + */ +// POST /account/api-keys +router.post('/account/api-keys', isAuthenticated, UserController.createApiKey); +// DELETE /account/api-keys/:keyId +router.delete( + '/account/api-keys/:keyId', + isAuthenticated, + UserController.removeApiKey +); +/** + * =============== + * PASSWORD MANAGEMENT + * =============== + */ +// POST /reset-password router.post('/reset-password', UserController.resetPasswordInitiate); - +// GET /reset-password/:token router.get('/reset-password/:token', UserController.validateResetPasswordToken); - +// POST /reset-password/:token router.post('/reset-password/:token', UserController.updatePassword); - +// PUT /account (updating password) router.put('/account', isAuthenticated, UserController.updateSettings); +/** + * =============== + * 3RD PARTY AUTH MANAGEMENT + * =============== + */ +// DELETE /auth/github +router.delete('/auth/github', UserController.unlinkGithub); +// DELETE /auth/google +router.delete('/auth/google', UserController.unlinkGoogle); + +/** + * =============== + * USER PREFERENCES + * =============== + */ +// PUT /cookie-consent router.put( '/cookie-consent', isAuthenticated, UserController.updateCookieConsent ); - -router.post('/account/api-keys', isAuthenticated, UserController.createApiKey); - -router.delete( - '/account/api-keys/:keyId', - isAuthenticated, - UserController.removeApiKey -); - -router.post('/verify/send', UserController.emailVerificationInitiate); - -router.get('/verify', UserController.verifyEmail); - -router.delete('/auth/github', UserController.unlinkGithub); -router.delete('/auth/google', UserController.unlinkGoogle); +// PUT /preferences +router.put('/preferences', isAuthenticated, UserController.updatePreferences); // eslint-disable-next-line import/no-default-export export default router; diff --git a/server/tsconfig.json b/server/tsconfig.json index e40550e1eb..42679f0ce9 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -4,9 +4,9 @@ "target": "ES2022", "module": "commonjs", "lib": ["ES2022"], - "types": ["node", "jest"] + "types": ["node", "jest"], + "strictNullChecks": true, }, - "strictNullChecks": true, "include": ["./**/*"], "exclude": ["../node_modules", "../client"] } diff --git a/server/types/express.ts b/server/types/express.ts new file mode 100644 index 0000000000..a1b2fea58c --- /dev/null +++ b/server/types/express.ts @@ -0,0 +1,19 @@ +import { Request } from 'express'; +import { User } from './user'; + +// workaround for express.d.ts not working as expected +/** Authenticated express request for routes that require authentication, which attaches user: {id:string} */ +export interface AuthenticatedRequest extends Request { + user: User; +} + +/** Simple error object for express requests */ +export interface Error { + error: string | unknown; +} + +/** Simple response object for express requests with success status and optional message */ +export interface GenericResponseBody { + success: boolean; + message?: string; +} diff --git a/server/types/index.ts b/server/types/index.ts index 6efd8a00fb..8511e3c860 100644 --- a/server/types/index.ts +++ b/server/types/index.ts @@ -1,5 +1,6 @@ export * from './apiKey'; export * from './email'; +export * from './express'; export * from './mongoose'; export * from './user'; export * from './userPreferences'; diff --git a/server/types/user.ts b/server/types/user.ts index ca14ee60cf..7467d7ec9a 100644 --- a/server/types/user.ts +++ b/server/types/user.ts @@ -18,7 +18,7 @@ export interface IUser extends VirtualId, MongooseTimestamps { google?: string; email: string; tokens: { kind: string }[]; - apiKeys: ApiKeyDocument[]; + apiKeys: Types.DocumentArray; preferences: UserPreferences; totalSize: number; cookieConsent: CookieConsentOptions; @@ -30,7 +30,7 @@ export interface IUser extends VirtualId, MongooseTimestamps { export interface User extends IUser {} /** Sanitised version of the user document without sensitive info */ -export interface PublicUserDocument +export interface PublicUser extends Pick< UserDocument, | 'email' diff --git a/server/utils/isAuthenticated.js b/server/utils/isAuthenticated.js deleted file mode 100644 index 865075d864..0000000000 --- a/server/utils/isAuthenticated.js +++ /dev/null @@ -1,10 +0,0 @@ -export default function isAuthenticated(req, res, next) { - if (req.user) { - next(); - return; - } - res.status(403).send({ - success: false, - message: 'You must be logged in in order to perform the requested action.' - }); -} diff --git a/server/utils/isAuthenticated.ts b/server/utils/isAuthenticated.ts new file mode 100644 index 0000000000..1d12810de0 --- /dev/null +++ b/server/utils/isAuthenticated.ts @@ -0,0 +1,12 @@ +import { RequestHandler } from 'express'; + +export const isAuthenticated: RequestHandler = (req, res, next) => { + if (!req.user) { + res.status(403).send({ + success: false, + message: 'You must be logged in in order to perform the requested action.' + }); + } + + next(); +};