diff --git a/.storybook/preview.js b/.storybook/preview.js index 9260b91c98..2f611743f2 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -2,14 +2,14 @@ import React from 'react'; import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router'; -import configureStore from '../client/store'; +import { setupStore } from '../client/store'; import '../client/i18n-test'; -import '../client/styles/storybook.css' +import '../client/styles/storybook.css'; import { withThemeProvider, themeToolbarItem } from './decorator-theme'; const initialState = window.__INITIAL_STATE__; -const store = configureStore(initialState); +const store = setupStore(initialState); export const decorators = [ (Story) => ( diff --git a/client/custom.d.ts b/client/custom.d.ts index 216fa99ad4..729f9e03de 100644 --- a/client/custom.d.ts +++ b/client/custom.d.ts @@ -4,5 +4,18 @@ declare module '*.svg' { const ReactComponent: React.FunctionComponent< React.SVGProps & { title?: string } >; + // eslint-disable-next-line import/no-default-export export default ReactComponent; } + +// Extend window for Redux DevTools +interface Window { + __REDUX_DEVTOOLS_EXTENSION__?: () => any; +} + +// Extend NodeModule for hot reloading +interface NodeModule { + hot?: { + accept(path?: string, callback?: () => void): void; + }; +} diff --git a/client/index.integration.test.jsx b/client/index.integration.test.jsx index 66819b1536..f27971b7b9 100644 --- a/client/index.integration.test.jsx +++ b/client/index.integration.test.jsx @@ -4,13 +4,13 @@ import React from 'react'; import Routing from './routes'; import { reduxRender, act, waitFor, screen, within } from './test-utils'; -import configureStore from './store'; +import { setupStore } from './store'; import * as Actions from './modules/User/actions'; import { userResponse } from './testData/testServerResponses'; // setup for the app const initialState = window.__INITIAL_STATE__; -const store = configureStore(initialState); +const store = setupStore(initialState); // need to mock this file or it'll throw ERRCONNECTED jest.mock('./i18n'); diff --git a/client/index.jsx b/client/index.jsx index c4c53c1186..4b79e95f12 100644 --- a/client/index.jsx +++ b/client/index.jsx @@ -5,7 +5,7 @@ import { Router } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import browserHistory from './browserHistory'; -import configureStore from './store'; +import { setupStore } from './store'; import Routing from './routes'; import ThemeProvider from './modules/App/components/ThemeProvider'; import Loader from './modules/App/components/loader'; @@ -19,7 +19,7 @@ require('./images/p5js-square-logo.png'); const initialState = window.__INITIAL_STATE__; -const store = configureStore(initialState); +const store = setupStore(initialState); const DONATE_LOGO_IMAGE_URL = 'https://donorbox.org/images/white_logo.svg'; diff --git a/client/modules/IDE/actions/preferences.js b/client/modules/IDE/actions/preferences.ts similarity index 62% rename from client/modules/IDE/actions/preferences.js rename to client/modules/IDE/actions/preferences.ts index f6e71504ee..ccb5ef6a63 100644 --- a/client/modules/IDE/actions/preferences.js +++ b/client/modules/IDE/actions/preferences.ts @@ -1,8 +1,30 @@ import i18next from 'i18next'; +import { UpdatePreferencesRequestBody } from '../../../../common/types'; import { apiClient } from '../../../utils/apiClient'; import * as ActionTypes from '../../../constants'; +import type { + UpdatePreferencesDispatch, + SetPreferencesTabValue, + SetFontSizeValue, + GetRootState, + SetLineNumbersValue, + SetAutocloseBracketsQuotesValue, + SetAutocompleteHinterValue, + SetAutosaveValue, + SetLinewrapValue, + SetLintWarningValue, + SetTextOutputValue, + SetAllAccessibleOutputValue, + SetAutorefreshValue, + SetGridOutputValue, + SetLanguageValue, + SetThemeValue +} from './preferences.types'; -function updatePreferences(formParams, dispatch) { +function updatePreferences( + formParams: UpdatePreferencesRequestBody, + dispatch: UpdatePreferencesDispatch +) { apiClient .put('/preferences', formParams) .then(() => {}) @@ -14,15 +36,15 @@ function updatePreferences(formParams, dispatch) { }); } -export function setPreferencesTab(value) { +export function setPreferencesTab(value: SetPreferencesTabValue) { return { type: ActionTypes.SET_PREFERENCES_TAB, value }; } -export function setFontSize(value) { - return (dispatch, getState) => { +export function setFontSize(value: SetFontSizeValue) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { // eslint-disable-line dispatch({ type: ActionTypes.SET_FONT_SIZE, @@ -40,8 +62,8 @@ export function setFontSize(value) { }; } -export function setLineNumbers(value) { - return (dispatch, getState) => { +export function setLineNumbers(value: SetLineNumbersValue) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { dispatch({ type: ActionTypes.SET_LINE_NUMBERS, value @@ -58,8 +80,10 @@ export function setLineNumbers(value) { }; } -export function setAutocloseBracketsQuotes(value) { - return (dispatch, getState) => { +export function setAutocloseBracketsQuotes( + value: SetAutocloseBracketsQuotesValue +) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { dispatch({ type: ActionTypes.SET_AUTOCLOSE_BRACKETS_QUOTES, value @@ -76,8 +100,8 @@ export function setAutocloseBracketsQuotes(value) { }; } -export function setAutocompleteHinter(value) { - return (dispatch, getState) => { +export function setAutocompleteHinter(value: SetAutocompleteHinterValue) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { dispatch({ type: ActionTypes.SET_AUTOCOMPLETE_HINTER, value @@ -94,8 +118,8 @@ export function setAutocompleteHinter(value) { }; } -export function setAutosave(value) { - return (dispatch, getState) => { +export function setAutosave(value: SetAutosaveValue) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { dispatch({ type: ActionTypes.SET_AUTOSAVE, value @@ -112,8 +136,8 @@ export function setAutosave(value) { }; } -export function setLinewrap(value) { - return (dispatch, getState) => { +export function setLinewrap(value: SetLinewrapValue) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { dispatch({ type: ActionTypes.SET_LINEWRAP, value @@ -130,8 +154,8 @@ export function setLinewrap(value) { }; } -export function setLintWarning(value) { - return (dispatch, getState) => { +export function setLintWarning(value: SetLintWarningValue) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { dispatch({ type: ActionTypes.SET_LINT_WARNING, value @@ -148,8 +172,8 @@ export function setLintWarning(value) { }; } -export function setTextOutput(value) { - return (dispatch, getState) => { +export function setTextOutput(value: SetTextOutputValue) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { dispatch({ type: ActionTypes.SET_TEXT_OUTPUT, value @@ -166,8 +190,8 @@ export function setTextOutput(value) { }; } -export function setGridOutput(value) { - return (dispatch, getState) => { +export function setGridOutput(value: SetGridOutputValue) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { dispatch({ type: ActionTypes.SET_GRID_OUTPUT, value @@ -184,12 +208,8 @@ export function setGridOutput(value) { }; } -export function setTheme(value) { - // return { - // type: ActionTypes.SET_THEME, - // value - // }; - return (dispatch, getState) => { +export function setTheme(value: SetThemeValue) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { dispatch({ type: ActionTypes.SET_THEME, value @@ -206,12 +226,8 @@ export function setTheme(value) { }; } -export function setAutorefresh(value) { - // return { - // type: ActionTypes.SET_AUTOREFRESH, - // value - // }; - return (dispatch, getState) => { +export function setAutorefresh(value: SetAutorefreshValue) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { dispatch({ type: ActionTypes.SET_AUTOREFRESH, value @@ -228,15 +244,18 @@ export function setAutorefresh(value) { }; } -export function setAllAccessibleOutput(value) { - return (dispatch) => { +export function setAllAccessibleOutput(value: SetAllAccessibleOutputValue) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { dispatch(setTextOutput(value)); dispatch(setGridOutput(value)); }; } -export function setLanguage(value, { persistPreference = true } = {}) { - return (dispatch, getState) => { +export function setLanguage( + value: SetLanguageValue, + { persistPreference = true } = {} +) { + return (dispatch: UpdatePreferencesDispatch, getState: GetRootState) => { i18next.changeLanguage(value); dispatch({ type: ActionTypes.SET_LANGUAGE, diff --git a/client/modules/IDE/actions/preferences.types.ts b/client/modules/IDE/actions/preferences.types.ts new file mode 100644 index 0000000000..e54d28d3cd --- /dev/null +++ b/client/modules/IDE/actions/preferences.types.ts @@ -0,0 +1,116 @@ +import * as ActionTypes from '../../../constants'; +import type { PreferencesState } from '../reducers/preferences'; +import type { RootState } from '../../../reducers'; + +// Value Definitions: +export type SetPreferencesTabValue = PreferencesState['tabIndex']; +export type SetFontSizeValue = PreferencesState['fontSize']; +export type SetLineNumbersValue = PreferencesState['lineNumbers']; +export type SetAutocloseBracketsQuotesValue = PreferencesState['autocloseBracketsQuotes']; +export type SetAutocompleteHinterValue = PreferencesState['autocompleteHinter']; +export type SetAutosaveValue = PreferencesState['autosave']; +export type SetLinewrapValue = PreferencesState['linewrap']; +export type SetLintWarningValue = PreferencesState['lintWarning']; +export type SetTextOutputValue = PreferencesState['textOutput']; +export type SetGridOutputValue = PreferencesState['gridOutput']; +export type SetThemeValue = PreferencesState['theme']; +export type SetAutorefreshValue = PreferencesState['autorefresh']; +export type SetLanguageValue = PreferencesState['language']; +export type SetAllAccessibleOutputValue = + | SetTextOutputValue + | SetGridOutputValue; + +// Action Definitions: +export type OpenPreferencesAction = { + type: typeof ActionTypes.OPEN_PREFERENCES; +}; +export type SetPreferencesAction = { + type: typeof ActionTypes.SET_PREFERENCES; + preferences: PreferencesState; +}; +export type SetErrorAction = { + type: typeof ActionTypes.ERROR; + error: unknown; +}; + +export type SetPreferencesTabAction = { + type: typeof ActionTypes.SET_PREFERENCES_TAB; + value: SetPreferencesTabValue; +}; +export type SetFontSizeAction = { + type: typeof ActionTypes.SET_FONT_SIZE; + value: SetFontSizeValue; +}; +export type SetLineNumbersAction = { + type: typeof ActionTypes.SET_LINE_NUMBERS; + value: SetLineNumbersValue; +}; +export type SetAutocloseBracketsQuotesAction = { + type: typeof ActionTypes.SET_AUTOCLOSE_BRACKETS_QUOTES; + value: SetAutocloseBracketsQuotesValue; +}; +export type SetAutocompleteHinterAction = { + type: typeof ActionTypes.SET_AUTOCOMPLETE_HINTER; + value: SetAutocompleteHinterValue; +}; +export type SetAutosaveAction = { + type: typeof ActionTypes.SET_AUTOSAVE; + value: SetAutosaveValue; +}; +export type SetLinewrapAction = { + type: typeof ActionTypes.SET_LINEWRAP; + value: SetLinewrapValue; +}; +export type SetLintWarningAction = { + type: typeof ActionTypes.SET_LINT_WARNING; + value: SetLintWarningValue; +}; +export type SetTextOutputAction = { + type: typeof ActionTypes.SET_TEXT_OUTPUT; + value: SetTextOutputValue; +}; +export type SetGridOutputAction = { + type: typeof ActionTypes.SET_GRID_OUTPUT; + value: SetGridOutputValue; +}; +export type SetThemeAction = { + type: typeof ActionTypes.SET_THEME; + value: SetThemeValue; +}; +export type SetAutorefreshAction = { + type: typeof ActionTypes.SET_AUTOREFRESH; + value: SetAutorefreshValue; +}; +export type SetLanguageAction = { + type: typeof ActionTypes.SET_LANGUAGE; + language: SetLanguageValue; +}; + +export type PreferencesAction = + | OpenPreferencesAction + | SetPreferencesAction + | SetErrorAction + | SetPreferencesTabAction + | SetFontSizeAction + | SetLineNumbersAction + | SetAutocloseBracketsQuotesAction + | SetAutocompleteHinterAction + | SetAutosaveAction + | SetLinewrapAction + | SetLintWarningAction + | SetTextOutputAction + | SetGridOutputAction + | SetThemeAction + | SetAutorefreshAction + | SetLanguageAction; + +export type UpdatePreferencesDispatch = ( + action: PreferencesAction | PreferencesThunk +) => void; + +export type PreferencesThunk = ( + dispatch: UpdatePreferencesDispatch, + getState: GetRootState +) => void; + +export type GetRootState = () => RootState; diff --git a/client/modules/IDE/reducers/preferences.js b/client/modules/IDE/reducers/preferences.ts similarity index 80% rename from client/modules/IDE/reducers/preferences.js rename to client/modules/IDE/reducers/preferences.ts index d6323c4fd2..f890bb2ccb 100644 --- a/client/modules/IDE/reducers/preferences.js +++ b/client/modules/IDE/reducers/preferences.ts @@ -1,7 +1,17 @@ +import { + UserPreferences as Preferences, + AppThemeOptions +} from '../../../../common/types'; import * as ActionTypes from '../../../constants'; import i18n from '../../../i18n'; +import type { PreferencesAction } from '../actions/preferences.types'; -export const initialState = { +export interface PreferencesState + extends Omit { + tabIndex: number; +} + +export const initialState: PreferencesState = { tabIndex: 0, fontSize: 18, autosave: true, @@ -10,14 +20,17 @@ export const initialState = { lintWarning: false, textOutput: false, gridOutput: false, - theme: 'light', + theme: AppThemeOptions.LIGHT, autorefresh: false, language: i18n.language, autocloseBracketsQuotes: true, autocompleteHinter: false }; -const preferences = (state = initialState, action) => { +export const preferences = ( + state: PreferencesState = initialState, + action: PreferencesAction +) => { switch (action.type) { case ActionTypes.OPEN_PREFERENCES: return Object.assign({}, state, { tabIndex: 0 }); @@ -57,5 +70,3 @@ const preferences = (state = initialState, action) => { return state; } }; - -export default preferences; diff --git a/client/persistState.js b/client/persistState.ts similarity index 68% rename from client/persistState.js rename to client/persistState.ts index 457dba016b..ceff31435e 100644 --- a/client/persistState.js +++ b/client/persistState.ts @@ -1,3 +1,4 @@ +import type { RootState } from './reducers'; /* Saves and loads a snapshot of the Redux store state to session storage @@ -5,7 +6,7 @@ const key = 'p5js-editor'; const storage = sessionStorage; -export const saveState = (state) => { +export const saveState = (state: RootState) => { try { storage.setItem(key, JSON.stringify(state)); } catch (error) { @@ -15,7 +16,9 @@ export const saveState = (state) => { export const loadState = () => { try { - return JSON.parse(storage.getItem(key)); + const stored = storage.getItem(key); + if (!stored) return null; // handle null before parsing + return JSON.parse(stored) as RootState; } catch (error) { console.warn('Failed to retrieve initialize state from storage:', error); return null; diff --git a/client/reducers.js b/client/reducers.ts similarity index 82% rename from client/reducers.js rename to client/reducers.ts index f61d2585d6..2c65e555ca 100644 --- a/client/reducers.js +++ b/client/reducers.ts @@ -1,7 +1,7 @@ import { combineReducers } from 'redux'; import files from './modules/IDE/reducers/files'; import ide from './modules/IDE/reducers/ide'; -import preferences from './modules/IDE/reducers/preferences'; +import { preferences } from './modules/IDE/reducers/preferences'; import project from './modules/IDE/reducers/project'; import editorAccessibility from './modules/IDE/reducers/editorAccessibility'; import user from './modules/User/reducers'; @@ -31,4 +31,8 @@ const rootReducer = combineReducers({ collections }); +// Type for entire redux state +export type RootState = ReturnType; + +// eslint-disable-next-line import/no-default-export export default rootReducer; diff --git a/client/store.js b/client/store.ts similarity index 93% rename from client/store.js rename to client/store.ts index e74248f010..4ff0e566fd 100644 --- a/client/store.js +++ b/client/store.ts @@ -2,6 +2,7 @@ import { configureStore } from '@reduxjs/toolkit'; import listenerMiddleware from './middleware'; import DevTools from './modules/App/components/DevTools'; import rootReducer from './reducers'; +import type { RootState } from './reducers'; import { clearState, loadState } from './persistState'; import { getConfig } from './utils/getConfig'; @@ -15,7 +16,7 @@ export function showReduxDevTools() { ); } -export default function setupStore(initialState) { +export function setupStore(initialState: RootState) { const savedState = loadState(); clearState(); diff --git a/client/storeInstance.js b/client/storeInstance.js deleted file mode 100644 index bd92360c2e..0000000000 --- a/client/storeInstance.js +++ /dev/null @@ -1,6 +0,0 @@ -import setupStore from './store'; - -const initialState = window.__INITIAL_STATE__; -const store = setupStore(initialState); - -export default store; diff --git a/client/test-utils.js b/client/test-utils.js index 9b7c8aab31..4e0aca0e79 100644 --- a/client/test-utils.js +++ b/client/test-utils.js @@ -23,7 +23,7 @@ import { Context as ResponsiveContext } from 'react-responsive'; import i18n from './i18n-test'; import ThemeProvider from './modules/App/components/ThemeProvider'; -import configureStore from './store'; +import { setupStore } from './store'; import theme, { Theme } from './theme'; export const history = createMemoryHistory(); @@ -95,7 +95,7 @@ Providers.propTypes = { */ function reduxRender( ui, - { initialState, store = configureStore(initialState), ...renderOptions } = {} + { initialState, store = setupStore(initialState), ...renderOptions } = {} ) { function Wrapper({ children }) { return ( diff --git a/client/testData/testReduxStore.js b/client/testData/testReduxStore.ts similarity index 96% rename from client/testData/testReduxStore.js rename to client/testData/testReduxStore.ts index f9f5d01925..33223ae950 100644 --- a/client/testData/testReduxStore.js +++ b/client/testData/testReduxStore.ts @@ -1,5 +1,6 @@ import { initialState as initialFilesState } from '../modules/IDE/reducers/files'; import { initialState as initialPrefState } from '../modules/IDE/reducers/preferences'; +import { RootState } from '../reducers'; const mockProjects = [ { @@ -22,7 +23,7 @@ const mockProjects = [ } ]; -const initialTestState = { +const initialTestState: RootState = { ide: { isPlaying: false, isAccessibleOutputPlaying: false, diff --git a/common/types/index.ts b/common/types/index.ts new file mode 100644 index 0000000000..b45c30b26e --- /dev/null +++ b/common/types/index.ts @@ -0,0 +1,33 @@ +// This file declares shared types between the client & server +// Types should be defined in their own portions of the codebase and exported here. + +// SERVER SHARED TYPES: +export type { + SanitisedApiKey, + IApiKey as ApiKey, + ApiKeyResponseOrError, + ApiKeyResponse, + CreateApiKeyRequestBody, + RemoveApiKeyRequestParams +} from '../../server/types/apiKey'; + +export * from '../../server/types/email'; + +export type { Error, GenericResponseBody } from '../../server/types/express'; + +export type { + User, + PublicUser, + PublicUserOrError, + PublicUserOrErrorOrGeneric, + UpdateSettingsRequestBody, + UnlinkThirdPartyResponseBody, + ResetPasswordInitiateRequestBody, + ResetOrUpdatePasswordRequestParams, + UpdatePasswordRequestBody, + CreateUserRequestBody, + DuplicateUserCheckQuery, + VerifyEmailQuery +} from '../../server/types/user'; + +export * from '../../server/types/userPreferences'; diff --git a/package-lock.json b/package-lock.json index f549f7b9ad..9edffa05e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@redux-devtools/dock-monitor": "^3.0.1", "@redux-devtools/log-monitor": "^4.0.2", "@reduxjs/toolkit": "^1.9.3", + "@types/express": "^5.0.3", "acorn": "^8.14.1", "acorn-walk": "^8.3.4", "async": "^3.2.3", @@ -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.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", + "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "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.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", + "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "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", @@ -16180,7 +16233,6 @@ "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dev": true, "dependencies": { "@types/connect": "*", "@types/node": "*" @@ -16200,7 +16252,6 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "dependencies": { "@types/node": "*" } @@ -16274,22 +16325,21 @@ "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==", - "dev": true, + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", + "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", + "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==", - "dev": true, + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", + "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", + "license": "MIT", "dependencies": { "@types/node": "*", "@types/qs": "*", @@ -16351,8 +16401,7 @@ "node_modules/@types/http-errors": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", - "dev": true + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" }, "node_modules/@types/inquirer": { "version": "7.3.3", @@ -16456,8 +16505,7 @@ "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" }, "node_modules/@types/mime-types": { "version": "2.1.4", @@ -16571,14 +16619,13 @@ "node_modules/@types/qs": { "version": "6.9.15", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz", - "integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==", - "dev": true + "integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==" }, "node_modules/@types/range-parser": { "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 + "license": "MIT" }, "node_modules/@types/react": { "version": "16.14.65", @@ -16667,7 +16714,6 @@ "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "dev": true, "dependencies": { "@types/mime": "^1", "@types/node": "*" @@ -16677,7 +16723,6 @@ "version": "1.15.7", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", - "dev": true, "dependencies": { "@types/http-errors": "*", "@types/node": "*", @@ -50587,6 +50632,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.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", + "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "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 +52352,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.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", + "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + } } }, "@svgr/babel-plugin-add-jsx-attribute": { @@ -52844,7 +52939,6 @@ "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dev": true, "requires": { "@types/connect": "*", "@types/node": "*" @@ -52863,7 +52957,6 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "requires": { "@types/node": "*" } @@ -52937,22 +53030,19 @@ "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==", - "dev": true, + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", + "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "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==", - "dev": true, + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", + "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", "requires": { "@types/node": "*", "@types/qs": "*", @@ -53013,8 +53103,7 @@ "@types/http-errors": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", - "dev": true + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" }, "@types/inquirer": { "version": "7.3.3", @@ -53118,8 +53207,7 @@ "@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" }, "@types/mime-types": { "version": "2.1.4", @@ -53227,14 +53315,12 @@ "@types/qs": { "version": "6.9.15", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz", - "integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==", - "dev": true + "integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==" }, "@types/range-parser": { "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 + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" }, "@types/react": { "version": "16.14.65", @@ -53321,7 +53407,6 @@ "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "dev": true, "requires": { "@types/mime": "^1", "@types/node": "*" @@ -53331,7 +53416,6 @@ "version": "1.15.7", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", - "dev": true, "requires": { "@types/http-errors": "*", "@types/node": "*", @@ -57060,14 +57144,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "requires": { - "ms": "2.1.2" - }, - "dependencies": { - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - } + "ms": "^2.1.3" } }, "decimal.js": { diff --git a/package.json b/package.json index 753db99626..643d468aa3 100644 --- a/package.json +++ b/package.json @@ -213,6 +213,7 @@ "@redux-devtools/dock-monitor": "^3.0.1", "@redux-devtools/log-monitor": "^4.0.2", "@reduxjs/toolkit": "^1.9.3", + "@types/express": "^5.0.3", "acorn": "^8.14.1", "acorn-walk": "^8.3.4", "async": "^3.2.3", diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.js deleted file mode 100644 index 96f9401075..0000000000 --- a/server/controllers/user.controller.js +++ /dev/null @@ -1,389 +0,0 @@ -import crypto from 'crypto'; - -import { User } from '../models/user'; -import { mailerService } from '../utils/mail'; -import { renderEmailConfirmation, renderResetPassword } from '../views/mail'; - -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); - } - }); - }); -} - -export async function createUser(req, res) { - try { - const { username, email, password } = req.body; - const emailLowerCase = email.toLowerCase(); - const existingUser = await User.findByEmailAndUsername(email, username); - if (existingUser) { - const fieldInUse = - existingUser.email.toLowerCase() === emailLowerCase - ? 'Email' - : 'Username'; - res.status(422).send({ error: `${fieldInUse} is in use` }); - return; - } - - const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours - const token = await generateToken(); - const user = new User({ - username, - email: emailLowerCase, - password, - verified: User.EmailConfirmation().Sent, - verifiedToken: token, - verifiedTokenExpires: EMAIL_VERIFY_TOKEN_EXPIRY_TIME - }); - - await user.save(); - - req.logIn(user, async (loginErr) => { - if (loginErr) { - console.error(loginErr); - res.status(500).json({ error: 'Failed to log in user.' }); - return; - } - - const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; - const mailOptions = renderEmailConfirmation({ - body: { - domain: `${protocol}://${req.headers.host}`, - link: `${protocol}://${req.headers.host}/verify?t=${token}` - }, - to: req.user.email - }); - - try { - await mailerService.send(mailOptions); - res.json(userResponse(user)); - } catch (mailErr) { - console.error(mailErr); - res.status(500).json({ error: 'Failed to send verification email.' }); - } - }); - } catch (err) { - console.error(err); - res.status(500).json({ error: err }); - } -} - -export async function duplicateUserCheck(req, res) { - const checkType = req.query.check_type; - const value = req.query[checkType]; - const options = { caseInsensitive: true, valueType: checkType }; - const user = await User.findByEmailOrUsername(value, options); - if (user) { - return res.json({ - exists: true, - message: `This ${checkType} is already taken.`, - type: checkType - }); - } - return res.json({ - exists: false, - type: checkType - }); -} - -export async function updatePreferences(req, res) { - try { - const user = await User.findById(req.user.id).exec(); - if (!user) { - res.status(404).json({ error: 'User not found' }); - return; - } - // Shallow merge the new preferences with the existing. - user.preferences = { ...user.preferences, ...req.body.preferences }; - await user.save(); - res.json(user.preferences); - } catch (err) { - res.status(500).json({ error: err }); - } -} - -export async function resetPasswordInitiate(req, res) { - try { - const token = await generateToken(); - const user = await User.findByEmail(req.body.email); - if (!user) { - res.json({ - success: true, - message: - 'If the email is registered with the editor, an email has been sent.' - }); - return; - } - user.resetPasswordToken = token; - user.resetPasswordExpires = Date.now() + 3600000; // 1 hour - - await user.save(); - const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; - const mailOptions = renderResetPassword({ - body: { - domain: `${protocol}://${req.headers.host}`, - link: `${protocol}://${req.headers.host}/reset-password/${token}` - }, - to: user.email - }); - - await mailerService.send(mailOptions); - res.json({ - success: true, - message: - 'If the email is registered with the editor, an email has been sent.' - }); - } catch (err) { - console.log(err); - res.json({ success: false }); - } -} - -export async function validateResetPasswordToken(req, res) { - const user = await User.findOne({ - resetPasswordToken: req.params.token, - resetPasswordExpires: { $gt: Date.now() } - }).exec(); - if (!user) { - res.status(401).json({ - success: false, - message: 'Password reset token is invalid or has expired.' - }); - return; - } - res.json({ success: true }); -} - -export async function emailVerificationInitiate(req, res) { - try { - const token = await generateToken(); - const user = await User.findById(req.user.id).exec(); - if (!user) { - res.status(404).json({ error: 'User not found' }); - return; - } - if (user.verified === User.EmailConfirmation().Verified) { - res.status(409).json({ error: 'Email already verified' }); - return; - } - const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; - const mailOptions = renderEmailConfirmation({ - body: { - domain: `${protocol}://${req.headers.host}`, - link: `${protocol}://${req.headers.host}/verify?t=${token}` - }, - to: user.email - }); - try { - await mailerService.send(mailOptions); - } catch (mailErr) { - res.status(500).send({ error: 'Error sending mail' }); - return; - } - const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours - user.verified = User.EmailConfirmation().Resent; - user.verifiedToken = token; - user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; // 24 hours - await user.save(); - - res.json(userResponse(req.user)); - } catch (err) { - res.status(500).json({ error: err }); - } -} - -export async function verifyEmail(req, res) { - const token = req.query.t; - const user = await User.findOne({ - verifiedToken: token, - verifiedTokenExpires: { $gt: new Date() } - }).exec(); - if (!user) { - res.status(401).json({ - success: false, - message: 'Token is invalid or has expired.' - }); - return; - } - user.verified = User.EmailConfirmation().Verified; - user.verifiedToken = null; - user.verifiedTokenExpires = null; - await user.save(); - res.json({ success: true }); -} - -export async function updatePassword(req, res) { - const user = await User.findOne({ - resetPasswordToken: req.params.token, - resetPasswordExpires: { $gt: Date.now() } - }).exec(); - if (!user) { - res.status(401).json({ - success: false, - message: 'Password reset token is invalid or has expired.' - }); - return; - } - - user.password = req.body.password; - user.resetPasswordToken = undefined; - user.resetPasswordExpires = undefined; - - await user.save(); - req.logIn(user, (loginErr) => res.json(userResponse(req.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; -} - -/** - * 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) { - try { - await user.save(); - res.json(userResponse(user)); - } catch (error) { - res.status(500).json({ error }); - } -} - -export async function updateSettings(req, res) { - try { - const user = await User.findById(req.user.id); - if (!user) { - res.status(404).json({ error: 'User not found' }); - return; - } - user.username = req.body.username; - - if (req.body.newPassword) { - if (user.password === undefined) { - user.password = req.body.newPassword; - saveUser(res, user); - } - if (!req.body.currentPassword) { - res.status(401).json({ error: 'Current password is not provided.' }); - return; - } - } - if (req.body.currentPassword) { - const isMatch = await user.comparePassword(req.body.currentPassword); - if (!isMatch) { - res.status(401).json({ error: 'Current password is invalid.' }); - return; - } - user.password = req.body.newPassword; - await saveUser(res, user); - } else if (user.email !== req.body.email) { - const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours - user.verified = User.EmailConfirmation().Sent; - - user.email = req.body.email; - - const token = await generateToken(); - user.verifiedToken = token; - user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; - - await saveUser(res, user); - - const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; - const mailOptions = renderEmailConfirmation({ - body: { - domain: `${protocol}://${req.headers.host}`, - link: `${protocol}://${req.headers.host}/verify?t=${token}` - }, - to: user.email - }); - - await mailerService.send(mailOptions); - } else { - await saveUser(res, user); - } - } catch (err) { - res.status(500).json({ error: err }); - } -} - -export async function unlinkGithub(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); - return; - } - res.status(404).json({ - success: false, - message: 'You must be logged in to complete this action.' - }); -} - -export async function unlinkGoogle(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); - return; - } - res.status(404).json({ - success: false, - message: 'You must be logged in to complete this action.' - }); -} - -export async function updateCookieConsent(req, res) { - try { - const user = await User.findById(req.user.id).exec(); - if (!user) { - res.status(404).json({ error: 'User not found' }); - return; - } - const { cookieConsent } = req.body; - user.cookieConsent = cookieConsent; - await saveUser(res, user); - } catch (err) { - res.status(500).json({ error: err }); - } -} diff --git a/server/controllers/user.controller/__testUtils__.ts b/server/controllers/user.controller/__testUtils__.ts new file mode 100644 index 0000000000..9d851c49f8 --- /dev/null +++ b/server/controllers/user.controller/__testUtils__.ts @@ -0,0 +1,66 @@ +import { Types } from 'mongoose'; +import { PublicUser, User, UserDocument, UserPreferences } from '../../types'; +import { + CookieConsentOptions, + ApiKeyDocument, + AppThemeOptions +} from '../../types'; + +/** Mock user preferences for testing. Matches mongoose defaults in User model */ +export const mockUserPreferences: UserPreferences = { + fontSize: 18, + lineNumbers: true, + indentationAmount: 2, + isTabIndent: false, + autosave: true, + linewrap: true, + lintWarning: false, + textOutput: false, + gridOutput: false, + theme: AppThemeOptions.LIGHT, + autorefresh: false, + language: 'en-GB', + autocloseBracketsQuotes: true, + autocompleteHinter: false +}; + +/** Mock sanitised user for testing */ +export const mockBaseUserSanitised: PublicUser = { + email: 'test@example.com', + username: 'tester', + preferences: mockUserPreferences, + apiKeys: ([] as unknown) as Types.DocumentArray, + verified: 'verified', + id: 'abc123', + totalSize: 42, + cookieConsent: CookieConsentOptions.NONE, + google: 'user@gmail.com', + github: 'user123' +}; + +/** Mock full user for testing. createdAt is omitted to simplify jest timers where possible */ +export const mockBaseUserFull: Omit = { + ...mockBaseUserSanitised, + name: 'test user', + tokens: [], + password: 'abweorij', + resetPasswordToken: '1i14ij23', + banned: false +}; + +/** + * Helper function to make mock user document / object for tests + * - Does not attach any document methods + * @param unSanitised - use the entire user type, including sensitive fields + * @param overrides - any overrides on the default mocks --> for clearest tests, always define the properties expected to change + * @returns + */ +export function createMockUser( + overrides: Partial = {}, + unSanitised: boolean = false +): PublicUser & Record { + return { + ...(unSanitised ? mockBaseUserFull : mockBaseUserSanitised), + ...overrides + }; +} diff --git a/server/controllers/user.controller/__tests__/apiKey.test.js b/server/controllers/user.controller/__tests__/apiKey.test.ts similarity index 62% rename from server/controllers/user.controller/__tests__/apiKey.test.js rename to server/controllers/user.controller/__tests__/apiKey.test.ts index 87dc1320e8..db85962033 100644 --- a/server/controllers/user.controller/__tests__/apiKey.test.js +++ b/server/controllers/user.controller/__tests__/apiKey.test.ts @@ -1,20 +1,25 @@ -/* @jest-environment node */ - import { last } from 'lodash'; -import { Request, Response } from 'jest-express'; +import { Request as MockRequest } from 'jest-express/lib/request'; +import { Response as MockResponse } from 'jest-express/lib/response'; +import { NextFunction as MockNext } from 'jest-express/lib/next'; +import { Types } from 'mongoose'; import { User } from '../../../models/user'; import { createApiKey, removeApiKey } from '../apiKey'; +import type { ApiKeyDocument } from '../../../types'; +import { createMockUser } from '../__testUtils__'; jest.mock('../../../models/user'); -describe('user.controller', () => { - let request; - let response; +describe('user.controller > api key', () => { + let request: any; + let response: any; + let next: MockNext; beforeEach(() => { - request = new Request(); - response = new Response(); + request = new MockRequest(); + response = new MockResponse(); + next = jest.fn(); }); afterEach(() => { @@ -25,12 +30,11 @@ describe('user.controller', () => { describe('createApiKey', () => { it("returns an error if user doesn't exist", async () => { - request.user = { id: '1234' }; - response = new Response(); + request.user = createMockUser({ id: '1234' }); User.findById = jest.fn().mockResolvedValue(null); - await createApiKey(request, response); + await createApiKey(request, response, next); expect(response.status).toHaveBeenCalledWith(404); expect(response.json).toHaveBeenCalledWith({ @@ -39,13 +43,13 @@ describe('user.controller', () => { }); it('returns an error if label not provided', async () => { - request.user = { id: '1234' }; + request.user = createMockUser({ id: '1234' }); request.body = {}; const user = new User(); User.findById = jest.fn().mockResolvedValue(user); - await createApiKey(request, response); + await createApiKey(request, response, next); expect(response.status).toHaveBeenCalledWith(400); expect(response.json).toHaveBeenCalledWith({ @@ -54,39 +58,38 @@ describe('user.controller', () => { }); it('returns generated API key to the user', async () => { + request.user = createMockUser({ id: '1234' }); request.setBody({ label: 'my key' }); - request.user = { id: '1234' }; const user = new User(); - user.apiKeys = []; + user.apiKeys = ([] as unknown) as Types.DocumentArray; User.findById = jest.fn().mockResolvedValue(user); - user.save = jest.fn().mockResolvedValue(); + user.save = jest.fn(); - await createApiKey(request, response); + await createApiKey(request, response, next); const lastKey = last(user.apiKeys); - expect(lastKey.label).toBe('my key'); - expect(typeof lastKey.hashedKey).toBe('string'); + expect(lastKey?.label).toBe('my key'); + expect(typeof lastKey?.hashedKey).toBe('string'); const responseData = response.json.mock.calls[0][0]; expect(responseData.apiKeys.length).toBe(1); expect(responseData.apiKeys[0]).toMatchObject({ label: 'my key', - token: lastKey.hashedKey + token: lastKey?.hashedKey }); }); }); describe('removeApiKey', () => { it("returns an error if user doesn't exist", async () => { - request.user = { id: '1234' }; - response = new Response(); + request.user = createMockUser({ id: '1234' }); User.findById = jest.fn().mockResolvedValue(null); - await removeApiKey(request, response); + await removeApiKey(request, response, next); expect(response.status).toHaveBeenCalledWith(404); expect(response.json).toHaveBeenCalledWith({ @@ -95,14 +98,14 @@ describe('user.controller', () => { }); it("returns an error if specified key doesn't exist", async () => { - request.user = { id: '1234' }; + request.user = createMockUser({ id: '1234' }); request.params = { keyId: 'not-a-real-key' }; const user = new User(); - user.apiKeys = []; + user.apiKeys = ([] as unknown) as Types.DocumentArray; User.findById = jest.fn().mockResolvedValue(user); - await removeApiKey(request, response); + await removeApiKey(request, response, next); expect(response.status).toHaveBeenCalledWith(404); expect(response.json).toHaveBeenCalledWith({ @@ -114,21 +117,25 @@ describe('user.controller', () => { const mockKey1 = { _id: 'id1', id: 'id1', label: 'first key' }; const mockKey2 = { _id: 'id2', id: 'id2', label: 'second key' }; - const apiKeys = [mockKey1, mockKey2]; + const apiKeys = ([ + mockKey1, + mockKey2 + ] as unknown) as Types.DocumentArray; apiKeys.find = Array.prototype.find; apiKeys.pull = jest.fn(); - const user = { + const user = createMockUser({ + id: '1234', apiKeys, - save: jest.fn().mockResolvedValue() - }; + save: jest.fn() + }); - request.user = { id: '1234' }; + request.user = user; request.params = { keyId: 'id1' }; User.findById = jest.fn().mockResolvedValue(user); - await removeApiKey(request, response); + await removeApiKey(request, response, next); expect(user.apiKeys.pull).toHaveBeenCalledWith({ _id: 'id1' }); expect(user.save).toHaveBeenCalled(); diff --git a/server/controllers/user.controller/__tests__/authManagement/3rdPartyManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement/3rdPartyManagement.test.ts new file mode 100644 index 0000000000..d94745d606 --- /dev/null +++ b/server/controllers/user.controller/__tests__/authManagement/3rdPartyManagement.test.ts @@ -0,0 +1,106 @@ +import { Request as MockRequest } from 'jest-express/lib/request'; +import { Response as MockResponse } from 'jest-express/lib/response'; +import { NextFunction as MockNext } from 'jest-express/lib/next'; +import { unlinkGithub, unlinkGoogle } from '../../authManagement'; +import { saveUser } from '../../helpers'; +import { createMockUser } from '../../__testUtils__'; + +jest.mock('../../helpers', () => ({ + ...jest.requireActual('../../helpers'), + saveUser: jest.fn() +})); +jest.mock('../../../../utils/mail'); + +describe('user.controller > auth management > 3rd party auth', () => { + let request: any; + let response: any; + let next: MockNext; + + beforeEach(() => { + request = new MockRequest(); + response = new MockResponse(); + next = jest.fn(); + }); + + afterEach(() => { + request.resetMocked(); + response.resetMocked(); + jest.clearAllMocks(); + }); + + describe('unlinkGithub', () => { + describe('and when there is no user in the request', () => { + beforeEach(async () => { + await unlinkGithub(request, response, next); + }); + it('does not call saveUser', () => { + expect(saveUser).not.toHaveBeenCalled(); + }); + it('returns a 404 with the correct status and message', () => { + expect(response.status).toHaveBeenCalledWith(404); + expect(response.json).toHaveBeenCalledWith({ + success: false, + message: 'You must be logged in to complete this action.' + }); + }); + }); + describe('and when there is a user in the request', () => { + const user = createMockUser({ + github: 'testuser', + tokens: [{ kind: 'github' }, { kind: 'google' }] + }); + + beforeEach(async () => { + request.user = user; + await unlinkGithub(request, response, next); + }); + it('removes the users github property', () => { + expect(user.github).toBeUndefined(); + }); + it('filters out the github token', () => { + expect(user.tokens).toEqual([{ kind: 'google' }]); + }); + it('does calls saveUser', () => { + expect(saveUser).toHaveBeenCalledWith(response, user); + }); + }); + }); + + describe('unlinkGoogle', () => { + describe('and when there is no user in the request', () => { + beforeEach(async () => { + await unlinkGoogle(request, response, next); + }); + it('does not call saveUser', () => { + expect(saveUser).not.toHaveBeenCalled(); + }); + it('returns a 404 with the correct status and message', () => { + expect(response.status).toHaveBeenCalledWith(404); + expect(response.json).toHaveBeenCalledWith({ + success: false, + message: 'You must be logged in to complete this action.' + }); + }); + }); + describe('and when there is a user in the request', () => { + const user = createMockUser({ + google: 'testuser', + tokens: [{ kind: 'github' }, { kind: 'google' }] + }); + + beforeEach(async () => { + request.user = user; + await unlinkGoogle(request, response, next); + }); + it('removes the users google property', () => { + expect(user.google).toBeUndefined(); + }); + it('filters out the google token', () => { + expect(user.tokens).toEqual([{ kind: 'github' }]); + }); + it('does calls saveUser', () => { + expect(saveUser).toHaveBeenCalledWith(response, user); + }); + }); + }); +}); diff --git a/server/controllers/user.controller/__tests__/authManagement/passwordManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement/passwordManagement.test.ts new file mode 100644 index 0000000000..f8b90d2ff8 --- /dev/null +++ b/server/controllers/user.controller/__tests__/authManagement/passwordManagement.test.ts @@ -0,0 +1,288 @@ +import { Request as MockRequest } from 'jest-express/lib/request'; +import { Response as MockResponse } from 'jest-express/lib/response'; +import { NextFunction as MockNext } from 'jest-express/lib/next'; +import { User } from '../../../../models/user'; +import { + resetPasswordInitiate, + validateResetPasswordToken, + updatePassword +} from '../../authManagement'; +import { generateToken } from '../../helpers'; +import { createMockUser } from '../../__testUtils__'; + +import { mailerService } from '../../../../utils/mail'; +import { UserDocument } from '../../../../types'; + +jest.mock('../../../../models/user'); +jest.mock('../../../../utils/mail'); +jest.mock('../../helpers', () => ({ + ...jest.requireActual('../../helpers'), + generateToken: jest.fn() +})); + +describe('user.controller > auth management > password management', () => { + let request: any; + let response: any; + let next: MockNext; + let mockToken: string; + let mockUser: Partial; + const fixedTime = 100000000; + + beforeEach(() => { + request = new MockRequest(); + response = new MockResponse(); + next = jest.fn(); + }); + + afterEach(() => { + request.resetMocked(); + response.resetMocked(); + jest.clearAllMocks(); + }); + + describe('resetPasswordInitiate', () => { + beforeAll(() => { + jest.useFakeTimers().setSystemTime(fixedTime); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('calls User.findByEmail with the correct email', async () => { + User.findByEmail = jest.fn().mockResolvedValue({}); + request.body = { email: 'email@gmail.com' }; + await resetPasswordInitiate(request, response, next); + + expect(User.findByEmail).toHaveBeenCalledWith('email@gmail.com'); + }); + + describe('if the user is found', () => { + beforeEach(async () => { + mockToken = 'mock-token'; + mockUser = createMockUser({ + email: 'test@example.com', + save: jest.fn().mockResolvedValue(null) + }); + + (generateToken as jest.Mock).mockResolvedValue(mockToken); + User.findByEmail = jest.fn().mockResolvedValue(mockUser); + + request.body = { email: 'test@example.com' }; + request.headers.host = 'localhost:3000'; + + await resetPasswordInitiate(request, response, next); + }); + it('sets a resetPasswordToken with an expiry of 1h to the user', () => { + expect(mockUser.resetPasswordToken).toBe(mockToken); + expect(mockUser.resetPasswordExpires).toBe(fixedTime + 3600000); + expect(mockUser.save).toHaveBeenCalled(); + }); + it('sends the reset password email', () => { + expect(mailerService.send).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'test@example.com', + body: expect.objectContaining({ + link: expect.stringContaining(mockToken) + }) + }) + ); + }); + it('returns a success message that does not indicate if the user exists, for security purposes', () => { + expect(response.json).toHaveBeenCalledWith({ + success: true, + message: + 'If the email is registered with the editor, an email has been sent.' + }); + }); + }); + describe('if the user is not found', () => { + beforeEach(() => { + mockToken = 'mock-token'; + + (generateToken as jest.Mock).mockResolvedValue(mockToken); + User.findByEmail = jest.fn().mockResolvedValue(null); + + request.body = { email: 'test@example.com' }; + request.headers.host = 'localhost:3000'; + }); + it('does not send the reset password email', async () => { + await resetPasswordInitiate(request, response, next); + + expect(mailerService.send).not.toHaveBeenCalledWith(); + }); + it('returns a success message that does not indicate if the user exists, for security purposes', async () => { + await resetPasswordInitiate(request, response, next); + + expect(response.json).toHaveBeenCalledWith({ + success: true, + message: + 'If the email is registered with the editor, an email has been sent.' + }); + }); + }); + it('returns unsuccessful for all other errors', async () => { + mockToken = 'mock-token'; + mockUser = createMockUser({ + email: 'test@example.com', + save: jest.fn().mockResolvedValue(null) + }); + + (generateToken as jest.Mock).mockRejectedValue( + new Error('network error') + ); + User.findByEmail = jest.fn().mockResolvedValue(null); + + request.body = { email: 'test@example.com' }; + request.headers.host = 'localhost:3000'; + + await resetPasswordInitiate(request, response, next); + + expect(response.json).toHaveBeenCalledWith({ + success: false + }); + }); + }); + + describe('validateResetPasswordToken', () => { + beforeAll(() => jest.useFakeTimers().setSystemTime(fixedTime)); + afterAll(() => jest.useRealTimers()); + + it('calls User.findone with the correct token and expiry', async () => { + User.findOne = jest.fn().mockReturnValue({ + exec: jest.fn() + }); + + request.params = { token: 'some-token' }; + + await validateResetPasswordToken(request, response, next); + + expect(User.findOne).toHaveBeenCalledWith({ + resetPasswordToken: 'some-token', + resetPasswordExpires: { $gt: fixedTime } + }); + }); + + describe('and when no user is found', () => { + beforeEach(async () => { + User.findOne = jest.fn().mockReturnValue({ + exec: jest.fn().mockResolvedValue(null) + }); + + request.params = { token: 'invalid-token' }; + + await validateResetPasswordToken(request, response, next); + }); + it('returns a 401', () => { + expect(response.status).toHaveBeenCalledWith(401); + }); + it('returns a "invalid or expired" token message', () => { + expect(response.json).toHaveBeenCalledWith({ + success: false, + message: 'Password reset token is invalid or has expired.' + }); + }); + }); + + describe('and when there is a user with valid token', () => { + beforeEach(async () => { + const fakeUser = createMockUser({ + email: 'test@example.com', + resetPasswordToken: 'valid-token', + resetPasswordExpires: fixedTime + 10000 // still valid + }); + + User.findOne = jest.fn().mockReturnValue({ + exec: jest.fn().mockResolvedValue(fakeUser) + }); + + request.params = { token: 'valid-token' }; + + await validateResetPasswordToken(request, response, next); + }); + it('returns a success response', () => { + expect(response.json).toHaveBeenCalledWith({ success: true }); + }); + }); + }); + + describe('updatePassword', () => { + beforeAll(() => jest.useFakeTimers().setSystemTime(fixedTime)); + afterAll(() => jest.useRealTimers()); + + it('calls User.findone with the correct token and expiry', async () => { + User.findOne = jest.fn().mockReturnValue({ + exec: jest.fn() + }); + + request.params = { token: 'some-token' }; + + await updatePassword(request, response, next); + + expect(User.findOne).toHaveBeenCalledWith({ + resetPasswordToken: 'some-token', + resetPasswordExpires: { $gt: fixedTime } + }); + }); + + describe('and when no user is found', () => { + beforeEach(async () => { + User.findOne = jest.fn().mockReturnValue({ + exec: jest.fn().mockResolvedValue(null) + }); + + request.params = { token: 'invalid-token' }; + + await updatePassword(request, response, next); + }); + it('returns a 401', () => { + expect(response.status).toHaveBeenCalledWith(401); + }); + it('returns a "invalid or expired" token message', () => { + expect(response.json).toHaveBeenCalledWith({ + success: false, + message: 'Password reset token is invalid or has expired.' + }); + }); + }); + + describe('and when there is a user with valid token', () => { + const sanitisedMockUser = createMockUser({ email: 'test@example.com' }); + mockUser = { + ...sanitisedMockUser, + password: 'oldpassword', + resetPasswordToken: 'valid-token', + resetPasswordExpires: fixedTime + 10000, // still valid + save: jest.fn() + }; + + beforeEach(async () => { + User.findOne = jest.fn().mockReturnValue({ + exec: jest.fn().mockResolvedValue(mockUser) + }); + + request.params = { token: 'valid-token' }; + request.setBody({ + password: 'newpassword' + }); + + // simulate logging in after resetting the password works + request.logIn = jest.fn((user, cb) => { + request.user = user; + cb(null); + }); + + await updatePassword(request, response, next); + }); + it('calls user.save with the updated password and removes the reset password token', () => { + expect(mockUser.password).toBe('newpassword'); + expect(mockUser.resetPasswordToken).toBeUndefined(); + expect(mockUser.resetPasswordExpires).toBeUndefined(); + expect(mockUser.save).toHaveBeenCalled(); + }); + it('returns a success response with the sanitised user', () => { + expect(response.json).toHaveBeenCalledWith(sanitisedMockUser); + }); + }); + }); +}); diff --git a/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts b/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts new file mode 100644 index 0000000000..565a32cd80 --- /dev/null +++ b/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts @@ -0,0 +1,268 @@ +import { Request as MockRequest } from 'jest-express/lib/request'; +import { Response as MockResponse } from 'jest-express/lib/response'; +import { NextFunction as MockNext } from 'jest-express/lib/next'; +import { User } from '../../../../models/user'; +import { updateSettings } from '../../authManagement'; +import { saveUser, generateToken } from '../../helpers'; +import { createMockUser } from '../../__testUtils__'; + +import { mailerService } from '../../../../utils/mail'; +import { UpdateSettingsRequestBody, UserDocument } from '../../../../types'; + +jest.mock('../../../../models/user'); +jest.mock('../../../../utils/mail'); +jest.mock('../../../../views/mail'); +jest.mock('../../helpers', () => ({ + ...jest.requireActual('../../helpers'), // use actual userResponse + saveUser: jest.fn(), + generateToken: jest.fn() +})); + +describe('user.controller > auth management > updateSettings (email, username, password)', () => { + let request: any; + let response: any; + let next: MockNext; + let requestBody: UpdateSettingsRequestBody; + let startingUser: Partial; + + const fixedTime = 100000000; + const GENERATED_TOKEN = 'new-token-1io23jijo'; + + const OLD_USERNAME = 'oldusername'; + const NEW_USERNAME = 'newusername'; + + const OLD_EMAIL = 'old@email.com'; + const NEW_EMAIL = 'new@email.com'; + + const OLD_PASSWORD = 'oldpassword'; + const NEW_PASSWORD = 'newpassword'; + + // minimum valid request body to manipulate per test + // from manual testing on the account form: + // both username and email are required & there is client-side validation for valid email & username-taken prior to submit + const minimumValidRequest: UpdateSettingsRequestBody = { + username: OLD_USERNAME, + email: OLD_EMAIL + }; + + beforeAll(() => { + jest.useFakeTimers().setSystemTime(fixedTime); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + request = new MockRequest(); + response = new MockResponse(); + next = jest.fn(); + + startingUser = createMockUser({ + username: OLD_USERNAME, + email: OLD_EMAIL, + password: OLD_PASSWORD, + id: '123459', + comparePassword: jest.fn().mockResolvedValue(true) + }); + + User.findById = jest.fn().mockResolvedValue(startingUser); + User.EmailConfirmation = jest.fn().mockReturnValue({ Sent: 'sent' }); + (saveUser as jest.Mock).mockResolvedValue(null); + (generateToken as jest.Mock).mockResolvedValue(GENERATED_TOKEN); + (mailerService.send as jest.Mock).mockResolvedValue(true); + + request.user = { id: 'valid-id' }; + request.headers.host = 'localhost:3000'; + }); + + afterEach(() => { + request.resetMocked(); + response.resetMocked(); + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + describe('if the user is not found', () => { + beforeEach(async () => { + (User.findById as jest.Mock).mockResolvedValue(null); + request.user = { id: 'nonexistent-id' }; + + await updateSettings(request, response, next); + }); + + it('returns 404 and a user-not-found error', async () => { + expect(response.status).toHaveBeenCalledWith(404); + expect(response.json).toHaveBeenCalledWith({ + error: 'User not found' + }); + }); + + it('does not save the user', () => { + expect(saveUser).not.toHaveBeenCalled(); + }); + }); + + // the below tests match the current logic, but logic can be improved + describe('if the user is found', () => { + // Q: should we add check & logic that if no username or email are on the request, + // we fallback to the username and/or email on the found user for safety? + // not sure if anyone is hitting this api directly, so the client-side checks may not be enough + + // duplicate username check happens client-side before this request is made + it('saves the user with any username in the request', async () => { + // saves with old username + requestBody = { ...minimumValidRequest, username: OLD_USERNAME }; + request.setBody(requestBody); + await updateSettings(request, response, next); + expect(saveUser).toHaveBeenCalledWith(response, { ...startingUser }); + + // saves with new username + requestBody = { ...minimumValidRequest, username: NEW_USERNAME }; + request.setBody(requestBody); + await updateSettings(request, response, next); + expect(saveUser).toHaveBeenCalledWith(response, { + ...startingUser, + username: NEW_USERNAME + }); + }); + + // currently frontend doesn't seem to call password-change related things the below + // not sure if we should update the logic to be cleaner? + describe('when there is a new password in the request', () => { + describe('and the current password is not provided', () => { + beforeEach(async () => { + requestBody = { ...minimumValidRequest, newPassword: NEW_PASSWORD }; + request.setBody(requestBody); + await updateSettings(request, response, next); + }); + + it('returns 401 with a "current password not provided" message', () => { + expect(response.status).toHaveBeenCalledWith(401); + expect(response.json).toHaveBeenCalledWith({ + error: 'Current password is not provided.' + }); + }); + + it('does not save the user with the new password', () => { + expect(saveUser).not.toHaveBeenCalled(); + }); + }); + }); + + // this should be nested in the previous block but currently here to match existing logic as-is + // NOTE: will make a PR into this branch to propose the change + describe('and when there is a currentPassword in the request', () => { + describe('and the current password does not match', () => { + beforeEach(async () => { + startingUser.comparePassword = jest.fn().mockResolvedValue(false); + + requestBody = { + ...minimumValidRequest, + newPassword: NEW_PASSWORD, + currentPassword: 'WRONG_PASSWORD' + }; + + request.setBody(requestBody); + await updateSettings(request, response, next); + }); + + it('returns 401 with a "current password invalid" message', () => { + expect(response.status).toHaveBeenCalledWith(401); + expect(response.json).toHaveBeenCalledWith({ + error: 'Current password is invalid.' + }); + }); + it('does not save the user with the new password', () => { + expect(saveUser).not.toHaveBeenCalled(); + }); + }); + + describe('and when the current password does match', () => { + beforeEach(async () => { + startingUser.comparePassword = jest.fn().mockResolvedValue(true); + + requestBody = { + ...minimumValidRequest, + newPassword: NEW_PASSWORD, + currentPassword: OLD_PASSWORD + }; + request.setBody(requestBody); + + await updateSettings(request, response, next); + }); + it('calls saveUser with the new password', () => { + expect(saveUser).toHaveBeenCalledWith(response, { + ...startingUser, + password: NEW_PASSWORD + }); + }); + }); + + // NOTE: This should not pass, but it currently does!! + describe('and when there is no new password on the request', () => { + beforeEach(async () => { + startingUser.comparePassword = jest.fn().mockResolvedValue(true); + + requestBody = { + ...minimumValidRequest, + newPassword: undefined, + currentPassword: OLD_PASSWORD + }; + request.setBody(requestBody); + + await updateSettings(request, response, next); + }); + it('calls saveUser with the new empty password', () => { + expect(saveUser).toHaveBeenCalledWith(response, { + ...startingUser, + password: undefined + }); + }); + }); + }); + + describe('and when there is an email in the request', () => { + it('does not send a verification email if email is unchanged', async () => { + requestBody = minimumValidRequest; + request.setBody(requestBody); + await updateSettings(request, response, next); + + expect(saveUser).toHaveBeenCalledWith(response, startingUser); + expect(mailerService.send).not.toHaveBeenCalled(); + }); + + it('updates email and sends verification email if email is changed', async () => { + requestBody = { ...minimumValidRequest, email: NEW_EMAIL }; + request.setBody(requestBody); + await updateSettings(request, response, next); + + expect(saveUser).toHaveBeenCalledWith(response, { + ...startingUser, + email: NEW_EMAIL, + verified: 'sent', + verifiedToken: GENERATED_TOKEN + }); + + expect(mailerService.send).toHaveBeenCalledWith( + expect.objectContaining({ + subject: 'Mock confirm your email' + }) + ); + }); + }); + + describe('and when there is any other error', () => { + beforeEach(async () => { + User.findById = jest.fn().mockRejectedValue('db error'); + requestBody = minimumValidRequest; + request.setBody(requestBody); + await updateSettings(request, response, next); + }); + it('returns a 500 error', () => { + expect(response.status).toHaveBeenCalledWith(500); + expect(response.json).toHaveBeenCalledWith({ error: 'db error' }); + }); + }); + }); +}); diff --git a/server/controllers/user.controller/__tests__/helpers.test.ts b/server/controllers/user.controller/__tests__/helpers.test.ts new file mode 100644 index 0000000000..a6add4fda9 --- /dev/null +++ b/server/controllers/user.controller/__tests__/helpers.test.ts @@ -0,0 +1,104 @@ +/* eslint-disable no-unused-vars */ +import crypto from 'crypto'; + +import { Response as MockResponse } from 'jest-express/lib/response'; +import { Response } from 'express'; +import { userResponse, generateToken, userExists, saveUser } from '../helpers'; +import { createMockUser } from '../__testUtils__'; +import { User } from '../../../models/user'; +import { UserDocument } from '../../../types'; + +jest.mock('../../../models/user'); + +const mockFullUser = createMockUser({ + // sensitive fields to be removed: + name: 'bob dylan', + tokens: [], + password: 'password12314', + resetPasswordToken: 'wijroaijwoer', + banned: true +}); + +const { + name, + tokens, + password, + resetPasswordToken, + banned, + ...sanitised +} = mockFullUser; + +describe('user.controller > helpers', () => { + describe('userResponse', () => { + it('returns a sanitized PublicUser object', () => { + const result = userResponse(mockFullUser); + 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('returns a response with a sanitised user if user.save succeeds', async () => { + const userWithSuccessfulSave = { + ...mockFullUser, + save: jest.fn().mockResolvedValue(null) + }; + const response = new MockResponse(); + await saveUser( + (response as unknown) as Response, + (userWithSuccessfulSave as unknown) as UserDocument + ); + expect(response.json).toHaveBeenCalledWith(sanitised); + }); + + it('returns a 500 Error if user.save fails', async () => { + const userWithUnsuccessfulSave = { + ...mockFullUser, + save: jest.fn().mockRejectedValue('async error') + }; + const response = new MockResponse(); + await saveUser( + (response as unknown) as Response, + (userWithUnsuccessfulSave as unknown) as UserDocument + ); + expect(response.status).toHaveBeenCalledWith(500); + expect(response.json).toHaveBeenCalledWith({ + error: 'async error' + }); + }); + }); + + describe('userExists', () => { + it('returns true when User.findByUsername returns non-nullish', async () => { + User.findByUsername = jest.fn().mockResolvedValue({ id: 'something' }); + const exists = await userExists('someusername'); + expect(exists).toBe(true); + }); + it('returns false when User.findByUsername returns nullish', async () => { + User.findByUsername = jest.fn().mockResolvedValue(null); + const exists = await userExists('someusername'); + expect(exists).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..df823a9639 --- /dev/null +++ b/server/controllers/user.controller/__tests__/signup.test.ts @@ -0,0 +1,301 @@ +import { Request as MockRequest } from 'jest-express/lib/request'; +import { Response as MockResponse } from 'jest-express/lib/response'; +import { NextFunction as MockNext } from 'jest-express/lib/next'; +import { User } from '../../../models/user'; +import { + createUser, + duplicateUserCheck, + verifyEmail, + emailVerificationInitiate +} from '../signup'; + +import { mailerService } from '../../../utils/mail'; + +jest.mock('../../../models/user'); +jest.mock('../../../utils/mail'); +jest.mock('../../../views/mail'); + +describe('user.controller > signup', () => { + let request: any; + let response: any; + let next: MockNext; + + beforeEach(() => { + request = new MockRequest(); + response = new MockResponse(); + next = jest.fn(); + }); + + afterEach(() => { + request.resetMocked(); + response.resetMocked(); + jest.clearAllMocks(); + }); + + describe('createUser', () => { + it('should return 422 if email already exists', async () => { + User.findByEmailAndUsername = jest.fn().mockResolvedValue({ + email: 'existing@example.com', + username: 'anyusername' + }); + + request.setBody({ + username: 'testuser', + email: 'existing@example.com', + password: 'password' + }); + + await createUser(request, response, next); + + expect(User.findByEmailAndUsername).toHaveBeenCalledWith( + 'existing@example.com', + 'testuser' + ); + expect(response.status).toHaveBeenCalledWith(422); + expect(response.send).toHaveBeenCalledWith({ error: 'Email is in use' }); + }); + it('should return 422 if username already exists', async () => { + User.findByEmailAndUsername = jest.fn().mockResolvedValue({ + email: 'anyemail@example.com', + username: 'testuser' + }); + + request.setBody({ + username: 'testuser', + email: 'existing@example.com', + password: 'password' + }); + + await createUser(request, response, next); + + expect(User.findByEmailAndUsername).toHaveBeenCalledWith( + 'existing@example.com', + 'testuser' + ); + expect(response.status).toHaveBeenCalledWith(422); + expect(response.send).toHaveBeenCalledWith({ + error: 'Username is in use' + }); + }); + }); + + describe('duplicateUserCheck', () => { + it('calls findByEmailOrUsername with the correct params', async () => { + User.findByEmailOrUsername = jest.fn().mockResolvedValue(null); + + request.query = { check_type: 'email', email: 'test@example.com' }; + + await duplicateUserCheck(request, response, next); + + expect(User.findByEmailOrUsername).toHaveBeenCalledWith( + 'test@example.com', + { + caseInsensitive: true, + valueType: 'email' + } + ); + }); + + it('returns the correct response body when no matching user is found', async () => { + User.findByEmailOrUsername = jest.fn().mockResolvedValue(null); + + request.query = { check_type: 'username', username: 'newuser' }; + + await duplicateUserCheck(request, response, next); + + expect(response.json).toHaveBeenCalledWith({ + exists: false, + type: 'username' + }); + }); + + it('returns the correct response body when the username already exists', async () => { + User.findByEmailOrUsername = jest.fn().mockResolvedValue({ + username: 'existinguser' + }); + + request.query = { check_type: 'username', username: 'existinguser' }; + + await duplicateUserCheck(request, response, next); + + expect(response.json).toHaveBeenCalledWith({ + exists: true, + message: 'This username is already taken.', + type: 'username' + }); + }); + + it('returns the correct response body when the email already exists', async () => { + User.findByEmailOrUsername = jest.fn().mockResolvedValue({ + email: 'existing@example.com' + }); + + request.query = { check_type: 'email', email: 'existing@example.com' }; + + await duplicateUserCheck(request, response, next); + + expect(response.json).toHaveBeenCalledWith({ + exists: true, + message: 'This email is already taken.', + type: 'email' + }); + }); + }); + + describe('verifyEmail', () => { + it('returns 401 if token is invalid or expired', async () => { + User.findOne = jest.fn().mockReturnValue({ + exec: jest.fn().mockResolvedValue(null) + }); + + request.query = { t: 'invalidtoken' }; + + await verifyEmail(request, response, next); + + expect(User.findOne).toHaveBeenCalledWith({ + verifiedToken: 'invalidtoken', + verifiedTokenExpires: { $gt: expect.any(Date) } + }); + expect(response.status).toHaveBeenCalledWith(401); + expect(response.json).toHaveBeenCalledWith({ + success: false, + message: 'Token is invalid or has expired.' + }); + }); + + it('verifies the user and returns success if token is valid', async () => { + const saveMock = jest.fn().mockResolvedValue({}); + const mockUser = { + save: saveMock, + verified: 'verified', + verifiedToken: 'validtoken', + verifiedTokenExpires: new Date(Date.now() + 10000) + }; + + User.EmailConfirmation = jest.fn().mockReturnValue({ + Verified: 'verified' + }); + + User.findOne = jest.fn().mockReturnValue({ + exec: jest.fn().mockResolvedValue(mockUser) + }); + + request.query = { t: 'validtoken' }; + + await verifyEmail(request, response, next); + + expect(mockUser.verified).toBe('verified'); + expect(mockUser.verifiedToken).toBeNull(); + expect(mockUser.verifiedTokenExpires).toBeNull(); + expect(saveMock).toHaveBeenCalled(); + expect(response.json).toHaveBeenCalledWith({ success: true }); + }); + }); + + describe('emailVerificationInitiate', () => { + it('returns 404 if user is not found', async () => { + User.findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); + + request.user = { id: 'nonexistentid' }; + request.headers.host = 'localhost:3000'; + + await emailVerificationInitiate(request, response, next); + + expect(User.findById).toHaveBeenCalledWith('nonexistentid'); + expect(response.status).toHaveBeenCalledWith(404); + expect(response.json).toHaveBeenCalledWith({ error: 'User not found' }); + }); + + it('returns 409 if user is already verified', async () => { + User.EmailConfirmation = jest.fn().mockReturnValue({ + Verified: 'verified' + }); + + const mockUser = { + verified: 'verified' + }; + User.findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); + + request.user = { id: 'user1' }; + request.headers.host = 'localhost:3000'; + + await emailVerificationInitiate(request, response, next); + + expect(response.status).toHaveBeenCalledWith(409); + expect(response.json).toHaveBeenCalledWith({ + error: 'Email already verified' + }); + }); + + it('sends a new verification email and updates the user', async () => { + User.EmailConfirmation = jest.fn().mockReturnValue({ + Resent: 'resent' + }); + + const saveMock = jest.fn().mockResolvedValue({}); + const mockUser = { + verified: 'sent', + verifiedToken: null, + verifiedTokenExpires: null, + email: 'test@example.com', + save: saveMock + }; + + User.findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); + + request.user = { id: 'user1' }; + request.headers.host = 'localhost:3000'; + + await emailVerificationInitiate(request, response, next); + + expect(User.findById).toHaveBeenCalledWith('user1'); + expect(mailerService.send).toHaveBeenCalledWith( + expect.objectContaining({ subject: 'Mock confirm your email' }) // see views/__mocks__/mail.ts + ); + expect(mockUser.verified).toBe('resent'); + expect(mockUser.verifiedToken).toBeDefined(); + expect(mockUser.verifiedTokenExpires).toBeDefined(); + expect(saveMock).toHaveBeenCalled(); + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining({ + email: request.user.email, + username: request.user.username + }) + ); + }); + + it('returns 500 if mailer fails', async () => { + const saveMock = jest.fn().mockResolvedValue({}); + const mockUser = { + verified: 'sent', + verifiedToken: null, + verifiedTokenExpires: null, + email: 'test@example.com', + save: saveMock + }; + + User.findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); + mailerService.send = jest + .fn() + .mockRejectedValue(new Error('Mailer fail')); + + request.user = { id: 'user1' }; + request.headers.host = 'localhost:3000'; + + await emailVerificationInitiate(request, response, next); + + expect(response.status).toHaveBeenCalledWith(500); + expect(response.send).toHaveBeenCalledWith({ + error: 'Error sending mail' + }); + }); + }); +}); diff --git a/server/controllers/user.controller/__tests__/userPreferences.test.ts b/server/controllers/user.controller/__tests__/userPreferences.test.ts new file mode 100644 index 0000000000..d0f653ad96 --- /dev/null +++ b/server/controllers/user.controller/__tests__/userPreferences.test.ts @@ -0,0 +1,153 @@ +import { Request as MockRequest } from 'jest-express/lib/request'; +import { Response as MockResponse } from 'jest-express/lib/response'; +import { NextFunction as MockNext } from 'jest-express/lib/next'; +import { User } from '../../../models/user'; +import { updatePreferences, updateCookieConsent } from '../userPreferences'; +import { createMockUser, mockUserPreferences } from '../__testUtils__'; +import { + AppThemeOptions, + CookieConsentOptions, + PublicUser +} from '../../../types'; + +jest.mock('../../../models/user'); + +const mockBaseUser = createMockUser(); + +describe('user.controller > user preferences', () => { + let request: any; + let response: any; + let next: MockNext; + let mockUser: PublicUser & Record; + + beforeEach(() => { + request = new MockRequest(); + response = new MockResponse(); + next = jest.fn(); + }); + + afterEach(() => { + request.resetMocked(); + response.resetMocked(); + jest.clearAllMocks(); + }); + + describe('updatePreferences', () => { + it('saves user preferences when user exists', async () => { + mockUser = createMockUser({ + preferences: { ...mockUserPreferences, theme: AppThemeOptions.LIGHT }, + save: jest.fn().mockResolvedValue(null) + }); + + User.findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); + + request.user = { id: 'user1' }; + request.body = { + preferences: { theme: AppThemeOptions.DARK, notifications: true } + }; + + await updatePreferences(request, response, next); + + // Check that preferences were merged correctly + expect(mockUser.preferences).toEqual({ + ...mockUserPreferences, + theme: AppThemeOptions.DARK, + notifications: true + }); + expect(mockUser.save).toHaveBeenCalled(); + expect(response.json).toHaveBeenCalledWith(mockUser.preferences); + }); + it('returns 404 when no user is found', async () => { + User.findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); + + request.user = { id: 'nonexistentid' }; + + await updatePreferences(request, response, next); + + expect(User.findById).toHaveBeenCalledWith('nonexistentid'); + expect(response.status).toHaveBeenCalledWith(404); + expect(response.json).toHaveBeenCalledWith({ error: 'User not found' }); + }); + it('returns 500 if saving preferences fails', async () => { + mockUser = createMockUser({ + preferences: { ...mockUserPreferences, theme: AppThemeOptions.LIGHT }, + save: jest.fn().mockRejectedValue(new Error('DB error')) + }); + + User.findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); + + request.user = { id: 'user1' }; + request.body = { preferences: { theme: 'dark' } }; + + await updatePreferences(request, response, next); + + expect(response.status).toHaveBeenCalledWith(500); + expect(response.json).toHaveBeenCalledWith({ error: expect.any(Error) }); + }); + }); + + describe('updateCookieConsent', () => { + it('updates cookieConsent when user exists', async () => { + mockUser = createMockUser({ + cookieConsent: CookieConsentOptions.ALL, + save: jest.fn().mockResolvedValue(null) + }); + User.findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); + + request.user = { id: 'user1' }; + request.body = { cookieConsent: CookieConsentOptions.ESSENTIAL }; + + await updateCookieConsent(request, response, next); + + expect(User.findById).toHaveBeenCalledWith('user1'); + expect(mockUser.cookieConsent).toBe(CookieConsentOptions.ESSENTIAL); + expect(mockUser.save).toHaveBeenCalled(); + expect(response.json).toHaveBeenCalledWith({ + ...mockBaseUser, + cookieConsent: CookieConsentOptions.ESSENTIAL + }); + }); + + it('returns 404 when no user is found', async () => { + User.findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); + + request.user = { id: 'nonexistentid' }; + request.body = { cookieConsent: true }; + + await updateCookieConsent(request, response, next); + + expect(User.findById).toHaveBeenCalledWith('nonexistentid'); + expect(response.status).toHaveBeenCalledWith(404); + expect(response.json).toHaveBeenCalledWith({ error: 'User not found' }); + }); + + it('returns 500 if saving cookieConsent fails', async () => { + mockUser = createMockUser({ + cookieConsent: CookieConsentOptions.ALL, + save: jest.fn().mockRejectedValue(new Error('DB error')) + }); + + User.findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); + + request.user = { id: 'user1' }; + request.body = { cookieConsent: true }; + + await updateCookieConsent(request, response, next); + + expect(response.status).toHaveBeenCalledWith(500); + expect(response.json).toHaveBeenCalledWith({ error: expect.any(Error) }); + }); + }); +}); diff --git a/server/controllers/user.controller/apiKey.js b/server/controllers/user.controller/apiKey.ts similarity index 53% rename from server/controllers/user.controller/apiKey.js rename to server/controllers/user.controller/apiKey.ts index d614a27324..e87747a75c 100644 --- a/server/controllers/user.controller/apiKey.js +++ b/server/controllers/user.controller/apiKey.ts @@ -1,12 +1,17 @@ import crypto from 'crypto'; - +import { RequestHandler } from 'express'; import { User } from '../../models/user'; +import type { + ApiKeyResponseOrError, + CreateApiKeyRequestBody, + RemoveApiKeyRequestParams +} 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 +23,26 @@ function generateApiKey() { }); } -export async function createApiKey(req, res) { - function sendFailure(code, error) { +/** + * - Method: `POST` + * - Endpoint: `/account/api-keys` + * - Authenticated: `true` + * - Id: `UserController.createApiKey` + * + * Description: + * - Create API key + */ +export const createApiKey: RequestHandler< + {}, + ApiKeyResponseOrError, + CreateApiKeyRequestBody +> = 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.user!.id); if (!user) { sendFailure(404, 'User not found'); @@ -49,7 +67,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 +75,33 @@ 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) { +/** + * - Method: `DELETE` + * - Endpoint: `/account/api-keys/:keyId` + * - Authenticated: `true` + * - Id: `UserController.removeApiKey` + * + * Description: + * - Remove API key + */ +export const removeApiKey: RequestHandler< + RemoveApiKeyRequestParams, + ApiKeyResponseOrError +> = 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.user!.id); if (!user) { sendFailure(404, 'User not found'); @@ -85,7 +119,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/authManagement.ts b/server/controllers/user.controller/authManagement.ts new file mode 100644 index 0000000000..71c5213cf9 --- /dev/null +++ b/server/controllers/user.controller/authManagement.ts @@ -0,0 +1,259 @@ +import { RequestHandler } from 'express'; +import { User } from '../../models/user'; +import { saveUser, generateToken, userResponse } from './helpers'; +import { + GenericResponseBody, + PublicUserOrErrorOrGeneric, + UnlinkThirdPartyResponseBody, + PublicUserOrError, + ResetPasswordInitiateRequestBody, + ResetOrUpdatePasswordRequestParams, + UpdatePasswordRequestBody, + UpdateSettingsRequestBody +} from '../../types'; +import { mailerService } from '../../utils/mail'; +import { renderResetPassword, renderEmailConfirmation } from '../../views/mail'; + +/** + * - Method: `POST` + * - Endpoint: `/reset-password` + * - Authenticated: `false` + * - Id: `UserController.resetPasswordInitiate` + * + * Description: + * - Send an Reset Email email to the registered email account + */ +export const resetPasswordInitiate: RequestHandler< + {}, + GenericResponseBody, + ResetPasswordInitiateRequestBody +> = async (req, res) => { + try { + const token = await generateToken(); + const user = await User.findByEmail(req.body.email); + if (!user) { + res.json({ + success: true, + message: + 'If the email is registered with the editor, an email has been sent.' + }); + return; + } + user.resetPasswordToken = token; + user.resetPasswordExpires = Date.now() + 3600000; // 1 hour + + await user.save(); + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + const mailOptions = renderResetPassword({ + body: { + domain: `${protocol}://${req.headers.host}`, + link: `${protocol}://${req.headers.host}/reset-password/${token}` + }, + to: user.email + }); + + await mailerService.send(mailOptions); + res.json({ + success: true, + message: + 'If the email is registered with the editor, an email has been sent.' + }); + } catch (err) { + if (process.env.NODE_ENV !== 'test') { + // don't log in test env + console.log(err); + } + res.json({ success: false }); + } +}; + +/** + * - Method: `GET` + * - Endpoint: `/reset-password/:token` + * - Authenticated: `false` + * - Id: `UserController.validateResetPasswordToken` + * + * Description: + * - The link in the Reset Password email, which contains a reset token that is valid for 1h + * - If valid, the user will see a form to reset their password + * - Else they will see a message that their token has expired + */ +export const validateResetPasswordToken: RequestHandler< + ResetOrUpdatePasswordRequestParams, + GenericResponseBody +> = async (req, res) => { + const user = await User.findOne({ + resetPasswordToken: req.params.token, + resetPasswordExpires: { $gt: Date.now() } + }).exec(); + if (!user) { + res.status(401).json({ + success: false, + message: 'Password reset token is invalid or has expired.' + }); + return; + } + res.json({ success: true }); +}; + +/** + * - Method: `POST` + * - Endpoint: `/reset-password/:token` + * - Authenticated: `false` + * - Id: `UserController.updatePassword` + * + * Description: + * - Used by the new password form to update a user's password with the valid token + * - Returns a Generic 401 - 'Password reset token is invalid or has expired.' if the token timed out + * - Returns a PublicUser if successfully saved + * - Returns an Error if network error on save attempt + */ +export const updatePassword: RequestHandler< + ResetOrUpdatePasswordRequestParams, + PublicUserOrErrorOrGeneric, + UpdatePasswordRequestBody +> = async (req, res) => { + const user = await User.findOne({ + resetPasswordToken: req.params.token, + resetPasswordExpires: { $gt: Date.now() } + }).exec(); + if (!user) { + res.status(401).json({ + success: false, + message: 'Password reset token is invalid or has expired.' + }); + return; + } + + user.password = req.body.password; + user.resetPasswordToken = undefined; + user.resetPasswordExpires = undefined; + + await user.save(); + req.logIn(user, (loginErr) => res.json(userResponse(req.user!))); + // eventually send email that the password has been reset +}; + +/** + * - Method: `PUT` + * - Endpoint: `/account` + * - Authenticated: `true` + * - Id: `UserController.updateSettings` + * + * Description: + * - Used to update the user's username, email, or password while authenticated + */ +export const updateSettings: RequestHandler< + {}, + PublicUserOrError, + UpdateSettingsRequestBody +> = async (req, res) => { + try { + const user = await User.findById(req.user!.id); + if (!user) { + res.status(404).json({ error: 'User not found' }); + return; + } + user.username = req.body.username; + + if (req.body.newPassword) { + if (user.password === undefined) { + user.password = req.body.newPassword; + saveUser(res, user); + } + if (!req.body.currentPassword) { + res.status(401).json({ error: 'Current password is not provided.' }); + return; + } + } + if (req.body.currentPassword) { + const isMatch = await user.comparePassword(req.body.currentPassword); + if (!isMatch) { + res.status(401).json({ error: 'Current password is invalid.' }); + return; + } + user.password = req.body.newPassword!; + await saveUser(res, user); + } else if (user.email !== req.body.email) { + const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours + user.verified = User.EmailConfirmation().Sent; + + user.email = req.body.email; + + const token = await generateToken(); + user.verifiedToken = token; + user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; + + await saveUser(res, user); + + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + const mailOptions = renderEmailConfirmation({ + body: { + domain: `${protocol}://${req.headers.host}`, + link: `${protocol}://${req.headers.host}/verify?t=${token}` + }, + to: user.email + }); + + await mailerService.send(mailOptions); + } else { + await saveUser(res, user); + } + } catch (err) { + res.status(500).json({ error: err }); + } +}; + +/** + * - Method: `DELETE` + * - Endpoint: `/auth/github` + * - Authenticated: `false` -- TODO: update to true? + * - Id: `UserController.unlinkGithub` + * + * Description: + * - Unlink github account + */ +export const unlinkGithub: RequestHandler< + {}, + UnlinkThirdPartyResponseBody +> = 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); + return; + } + res.status(404).json({ + success: false, + message: 'You must be logged in to complete this action.' + }); +}; + +/** + * - Method: `DELETE` + * - Endpoint: `/auth/google` + * - Authenticated: `false` -- TODO: update to true? + * - Id: `UserController.unlinkGoogle` + * + * Description: + * - Unlink google account + */ +export const unlinkGoogle: RequestHandler< + {}, + UnlinkThirdPartyResponseBody +> = 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); + return; + } + res.status(404).json({ + success: false, + message: 'You must be logged in to complete this action.' + }); +}; diff --git a/server/controllers/user.controller/helpers.ts b/server/controllers/user.controller/helpers.ts new file mode 100644 index 0000000000..e24df5516a --- /dev/null +++ b/server/controllers/user.controller/helpers.ts @@ -0,0 +1,68 @@ +import crypto from 'crypto'; +import type { Response } from 'express'; +import { User } from '../../models/user'; +import { PublicUser, UserDocument } from '../../types'; + +/** + * Sanitise user objects to remove sensitive fields + * @param user + * @returns Sanitised user + */ +export function userResponse( + user: PublicUser & Record +): 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 of type PublicUserOrError + * - The sanitised user or a 500 error. + * @param res + * @param user + */ +export async function saveUser(res: Response, user: UserDocument) { + try { + await user.save(); + res.json(userResponse(user)); + } catch (error) { + res.status(500).json({ error }); + } +} + +/** + * Helper used in other controllers to check if user by username exists. + */ +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..3a7dc9b141 --- /dev/null +++ b/server/controllers/user.controller/index.ts @@ -0,0 +1,5 @@ +export * from './apiKey'; +export * from './authManagement'; +export * from './helpers'; +export * from './signup'; +export * from './userPreferences'; diff --git a/server/controllers/user.controller/signup.ts b/server/controllers/user.controller/signup.ts new file mode 100644 index 0000000000..4aabd9df0e --- /dev/null +++ b/server/controllers/user.controller/signup.ts @@ -0,0 +1,195 @@ +import { RequestHandler } from 'express'; +import { User } from '../../models/user'; +import { generateToken, userResponse } from './helpers'; +import { renderEmailConfirmation } from '../../views/mail'; +import { mailerService } from '../../utils/mail'; +import { + PublicUserOrError, + CreateUserRequestBody, + DuplicateUserCheckQuery, + VerifyEmailQuery +} from '../../types'; + +/** + * - Method: `POST` + * - Endpoint: `/signup` + * - Authenticated: `false` + * - Id: `UserController.createUser` + * + * Description: + * - Create a new user + */ +export const createUser: RequestHandler< + {}, + PublicUserOrError, + CreateUserRequestBody +> = async (req, res) => { + try { + const { username, email, password } = req.body; + const emailLowerCase = email.toLowerCase(); + const existingUser = await User.findByEmailAndUsername(email, username); + if (existingUser) { + const fieldInUse = + existingUser.email.toLowerCase() === emailLowerCase + ? 'Email' + : 'Username'; + res.status(422).send({ error: `${fieldInUse} is in use` }); + return; + } + + const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours + const token = await generateToken(); + const user = new User({ + username, + email: emailLowerCase, + password, + verified: User.EmailConfirmation().Sent, + verifiedToken: token, + verifiedTokenExpires: EMAIL_VERIFY_TOKEN_EXPIRY_TIME + }); + + await user.save(); + + req.logIn(user, async (loginErr) => { + if (loginErr) { + console.error(loginErr); + res.status(500).json({ error: 'Failed to log in user.' }); + return; + } + + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + const mailOptions = renderEmailConfirmation({ + body: { + domain: `${protocol}://${req.headers.host}`, + link: `${protocol}://${req.headers.host}/verify?t=${token}` + }, + to: req.user!.email + }); + + try { + await mailerService.send(mailOptions); + res.json(userResponse(user)); + } catch (mailErr) { + console.error(mailErr); + res.status(500).json({ error: 'Failed to send verification email.' }); + } + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: err }); + } +}; + +/** + * - Method: `GET` + * - Endpoint: `/signup/duplicate_check` + * - Authenticated: `false` + * - Id: `UserController.duplicateUserCheck` + * + * Description: + * - Check if a user with the same email or username already exists + */ +export const duplicateUserCheck: RequestHandler< + {}, + {}, + {}, + DuplicateUserCheckQuery +> = async (req, res) => { + const checkType = req.query.check_type; + const value = req.query[checkType]; + const options = { caseInsensitive: true, valueType: checkType }; + const user = await User.findByEmailOrUsername(value!, options); + if (user) { + return res.json({ + exists: true, + message: `This ${checkType} is already taken.`, + type: checkType + }); + } + return res.json({ + exists: false, + type: checkType + }); +}; + +/** + * - Method: `POST` + * - Endpoint: `/verify/send` + * - Authenticated: `false` + * - Id: `UserController.emailVerificationInitiate` + * + * Description: + * - Send a Confirm Email email to verify that the user owns the specified email account + */ +export const emailVerificationInitiate: RequestHandler< + {}, + PublicUserOrError +> = async (req, res) => { + try { + const token = await generateToken(); + const user = await User.findById(req.user!.id).exec(); + if (!user) { + res.status(404).json({ error: 'User not found' }); + return; + } + if (user.verified === User.EmailConfirmation().Verified) { + res.status(409).json({ error: 'Email already verified' }); + return; + } + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + const mailOptions = renderEmailConfirmation({ + body: { + domain: `${protocol}://${req.headers.host}`, + link: `${protocol}://${req.headers.host}/verify?t=${token}` + }, + to: user.email + }); + try { + await mailerService.send(mailOptions); + } catch (mailErr) { + res.status(500).send({ error: 'Error sending mail' }); + return; + } + const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours + user.verified = User.EmailConfirmation().Resent; + user.verifiedToken = token; + user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; // 24 hours + await user.save(); + + res.json(userResponse(req.user!)); + } catch (err) { + res.status(500).json({ error: err }); + } +}; + +/** + * - Method: `GET` + * - Endpoint: `/verify` + * - Authenticated: `false` + * - Id: `UserController.verifyEmail` + * + * Description: + * - Used in the Confirm Email's link to verify a user's email is attached to their account + */ +export const verifyEmail: RequestHandler<{}, {}, {}, VerifyEmailQuery> = async ( + req, + res +) => { + const token = req.query.t; + const user = await User.findOne({ + verifiedToken: token, + verifiedTokenExpires: { $gt: new Date() } + }).exec(); + if (!user) { + res.status(401).json({ + success: false, + message: 'Token is invalid or has expired.' + }); + return; + } + user.verified = User.EmailConfirmation().Verified; + user.verifiedToken = null; + user.verifiedTokenExpires = null; + await user.save(); + res.json({ success: true }); +}; diff --git a/server/controllers/user.controller/userPreferences.ts b/server/controllers/user.controller/userPreferences.ts new file mode 100644 index 0000000000..259a140839 --- /dev/null +++ b/server/controllers/user.controller/userPreferences.ts @@ -0,0 +1,66 @@ +import { RequestHandler } from 'express'; +import { User } from '../../models/user'; +import { + UpdatePreferencesRequestBody, + UpdateCookieConsentRequestBody, + UpdatePreferencesResponseBody, + PublicUserOrError +} from '../../types'; +import { saveUser } from './helpers'; + +/** + * - Method: `PUT` + * - Endpoint: `/preferences` + * - Authenticated: `true` + * - Id: `UserController.updatePreferences` + * + * Description: + * - Update user preferences, such as AppTheme + */ +export const updatePreferences: RequestHandler< + {}, + UpdatePreferencesResponseBody, + UpdatePreferencesRequestBody +> = async (req, res) => { + try { + const user = await User.findById(req.user!.id).exec(); + if (!user) { + res.status(404).json({ error: 'User not found' }); + return; + } + // Shallow merge the new preferences with the existing. + user.preferences = { ...user.preferences, ...req.body.preferences }; + await user.save(); + res.json(user.preferences); + } catch (err) { + res.status(500).json({ error: err }); + } +}; + +/** + * - Method: `PUT` + * - Endpoint: `/cookie-consent` + * - Authenticated: `true` + * - Id: `UserController.updatePreferences` + * + * Description: + * - Update user cookie consent + */ +export const updateCookieConsent: RequestHandler< + {}, + PublicUserOrError, + UpdateCookieConsentRequestBody +> = async (req, res) => { + try { + const user = await User.findById(req.user!.id).exec(); + if (!user) { + res.status(404).json({ error: 'User not found' }); + return; + } + const { cookieConsent } = req.body; + user.cookieConsent = cookieConsent; + await saveUser(res, user); + } catch (err) { + res.status(500).json({ error: err }); + } +}; diff --git a/server/middleware/__tests__/isAuthenticated.test.ts b/server/middleware/__tests__/isAuthenticated.test.ts new file mode 100644 index 0000000000..b5f12d6b6f --- /dev/null +++ b/server/middleware/__tests__/isAuthenticated.test.ts @@ -0,0 +1,31 @@ +import { Request, Response, NextFunction } from 'express'; +import { isAuthenticated } from '../isAuthenticated'; + +describe('isAuthenticated middleware', () => { + it('should call next() if user property is present', () => { + const req = ({ user: 'any_user' } as unknown) as Request; + const res = {} as Response; + const next = jest.fn() as NextFunction; + + isAuthenticated(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + it('should return 403 if user is missing', () => { + const req = { headers: {} } as Request; + const res = ({ + status: jest.fn().mockReturnThis(), + send: jest.fn() + } as unknown) as Response; + const next = jest.fn() as NextFunction; + + isAuthenticated(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.send).toHaveBeenCalledWith({ + success: false, + message: 'You must be logged in in order to perform the requested action.' + }); + }); +}); diff --git a/server/middleware/isAuthenticated.ts b/server/middleware/isAuthenticated.ts new file mode 100644 index 0000000000..6d1f2e70f0 --- /dev/null +++ b/server/middleware/isAuthenticated.ts @@ -0,0 +1,18 @@ +import { Request, Response, NextFunction } from 'express'; +import { AuthenticatedRequest } from '../types'; + +/** Middleware function to check if a request is authenticated prior to passing onto routes requiring user to be logged in */ +export function isAuthenticated( + req: Request, + res: Response, + next: NextFunction +): asserts req is AuthenticatedRequest { + 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/models/__test__/user.test.ts b/server/models/__test__/user.test.ts index 6e448a971d..2abc77873c 100644 --- a/server/models/__test__/user.test.ts +++ b/server/models/__test__/user.test.ts @@ -35,7 +35,7 @@ describe('User model', () => { await user.save(); expect(user.password).not.toBe('mypassword'); - const match = await bcrypt.compare('mypassword', user.password); + const match = await bcrypt.compare('mypassword', user.password!); expect(match).toBe(true); }); diff --git a/server/routes/aws.routes.ts b/server/routes/aws.routes.ts index 91a5751866..5469e7e6d0 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 '../middleware/isAuthenticated'; const router = Router(); diff --git a/server/routes/collection.routes.ts b/server/routes/collection.routes.ts index 4ec02961b1..a764f48b2e 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 '../middleware/isAuthenticated'; const router = Router(); diff --git a/server/routes/file.routes.ts b/server/routes/file.routes.ts index c0bc434917..36a793e7b4 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 '../middleware/isAuthenticated'; const router = Router(); diff --git a/server/routes/project.routes.ts b/server/routes/project.routes.ts index 26f6ee9501..bf873a6b6f 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 '../middleware/isAuthenticated'; const router = Router(); diff --git a/server/routes/user.routes.ts b/server/routes/user.routes.ts index 532ade8923..a61a42e11b 100644 --- a/server/routes/user.routes.ts +++ b/server/routes/user.routes.ts @@ -1,43 +1,68 @@ import { Router } from 'express'; import * as UserController from '../controllers/user.controller'; -import isAuthenticated from '../utils/isAuthenticated'; +import { isAuthenticated } from '../middleware/isAuthenticated'; const router = Router(); +/** + * =============== + * SIGN UP + * =============== + */ +// POST /signup router.post('/signup', UserController.createUser); - +// GET /signup/duplicate_check router.get('/signup/duplicate_check', UserController.duplicateUserCheck); +// POST /verify/send +router.post('/verify/send', UserController.emailVerificationInitiate); +// GET /verify +router.get('/verify', UserController.verifyEmail); -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 +); +/** + * =============== + * AUTH 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 username, email or password while logged in) router.put('/account', isAuthenticated, UserController.updateSettings); +// 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..50811e441c 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -4,7 +4,8 @@ "target": "ES2022", "module": "commonjs", "lib": ["ES2022"], - "types": ["node", "jest"] + "types": ["node", "jest", "express"], + "typeRoots": ["./types", "../node_modules/@types"] }, "strictNullChecks": true, "include": ["./**/*"], diff --git a/server/types/apiKey.ts b/server/types/apiKey.ts index 10fd86a1f4..21ba15aa04 100644 --- a/server/types/apiKey.ts +++ b/server/types/apiKey.ts @@ -1,6 +1,8 @@ import { Model, Document, Types } from 'mongoose'; import { VirtualId, MongooseTimestamps } from './mongoose'; +import { Error, RouteParam } from './express'; +// -------- MONGOOSE -------- /** Full Api Key interface */ export interface IApiKey extends VirtualId, MongooseTimestamps { label: string; @@ -25,3 +27,25 @@ export interface SanitisedApiKey /** Mongoose model for API Key */ export interface ApiKeyModel extends Model {} + +// -------- API -------- +/** + * Response body for userController.createApiKey & userController.removeApiKey + * - Either an ApiKeyResponse or Error + */ +export type ApiKeyResponseOrError = ApiKeyResponse | Error; + +/** Response for api-key related endpoints, containing list of keys */ +export interface ApiKeyResponse { + apiKeys: ApiKeyDocument[]; +} + +/** userController.createApiKey - Request */ +export interface CreateApiKeyRequestBody { + label: string; +} + +/** userController.removeApiKey - Request */ +export interface RemoveApiKeyRequestParams extends RouteParam { + keyId: string; +} diff --git a/server/types/express.ts b/server/types/express.ts new file mode 100644 index 0000000000..7b8a49a7bb --- /dev/null +++ b/server/types/express.ts @@ -0,0 +1,22 @@ +import * as core from 'express-serve-static-core'; +import { Request } from 'express'; +import { UserDocument } from './user'; + +/** Authenticated express request for routes that require auth. Has a user property */ +export interface AuthenticatedRequest extends Request { + user: UserDocument; +} + +/** 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; +} + +/** Wrapper around Express core.ParamsDictionary to prevent repeated importing when defining RequestHandler route params */ +export interface RouteParam extends core.ParamsDictionary {} diff --git a/server/types/express/index.d.ts b/server/types/express/index.d.ts new file mode 100644 index 0000000000..8caa4943bd --- /dev/null +++ b/server/types/express/index.d.ts @@ -0,0 +1,10 @@ +import type { UserDocument } from '../user'; + +// to make the file a module and avoid the TypeScript error +export {}; + +declare global { + namespace Express { + export interface User extends UserDocument {} + } +} 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..6d6d53fa1f 100644 --- a/server/types/user.ts +++ b/server/types/user.ts @@ -3,12 +3,14 @@ import { VirtualId, MongooseTimestamps } from './mongoose'; import { UserPreferences, CookieConsentOptions } from './userPreferences'; import { EmailConfirmationStates } from './email'; import { ApiKeyDocument } from './apiKey'; +import { Error, GenericResponseBody, RouteParam } from './express'; +// -------- MONGOOSE -------- /** Full User interface */ export interface IUser extends VirtualId, MongooseTimestamps { name: string; username: string; - password: string; + password?: string; resetPasswordToken?: string; resetPasswordExpires?: number; verified?: string; @@ -18,7 +20,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 +32,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' @@ -74,3 +76,65 @@ export interface UserModel extends Model { EmailConfirmation(): typeof EmailConfirmationStates; } + +// -------- API -------- +/** + * Response body used for User related routes + * Contains either the Public (sanitised) User or an Error + */ +export type PublicUserOrError = PublicUser | Error; + +/** + * Note: This type should probably be updated to be removed in the future and use just PublicUserOrError + * - Contains either a GenericResponseBody for when there is no user found or attached to a request + * - Or a PublicUserOrError resulting from calling the `saveUser` helper. + */ +export type PublicUserOrErrorOrGeneric = + | PublicUserOrError + | GenericResponseBody; + +/** userController.updateSettings - Request */ +export interface UpdateSettingsRequestBody { + username: string; + email: string; + newPassword?: string; + currentPassword?: string; +} + +/** + * userContoller.unlinkGithub & userContoller.unlinkGoogle - Response + * - If user is not logged in, a GenericResponseBody with 404 is returned + * - If user is logged in, PublicUserOrError is returned + */ +export type UnlinkThirdPartyResponseBody = PublicUserOrErrorOrGeneric; + +/** userController.resetPasswordInitiate - Request */ +export interface ResetPasswordInitiateRequestBody { + email: string; +} + +/** userContoller.validateResetPasswordToken & userController.updatePassword - Request */ +export interface ResetOrUpdatePasswordRequestParams extends RouteParam { + token: string; +} +/** userController.updatePassword - Request */ +export interface UpdatePasswordRequestBody { + password: string; +} +/** userController.createUser - Request */ +export interface CreateUserRequestBody { + username: string; + email: string; + password: string; +} +/** userController.duplicateUserCheck - Query */ +export interface DuplicateUserCheckQuery { + // eslint-disable-next-line camelcase + check_type: 'email' | 'username'; + email?: string; + username?: string; +} +/** userController.verifyEmail - Query */ +export interface VerifyEmailQuery { + t: string; +} diff --git a/server/types/userPreferences.ts b/server/types/userPreferences.ts index 20fe3b116f..6a467999cc 100644 --- a/server/types/userPreferences.ts +++ b/server/types/userPreferences.ts @@ -1,3 +1,5 @@ +import { Error } from './express'; + export enum AppThemeOptions { LIGHT = 'light', DARK = 'dark', @@ -26,3 +28,17 @@ export enum CookieConsentOptions { ESSENTIAL = 'essential', ALL = 'all' } + +// -------- API -------- +/** user.controller.updatePreferences - Request */ +export interface UpdatePreferencesRequestBody { + preferences: Partial; +} + +/** userController.updatePreferences - Response */ +export type UpdatePreferencesResponseBody = UserPreferences | Error; + +/** user.controller.updateCookieConsent - Request */ +export interface UpdateCookieConsentRequestBody { + cookieConsent: CookieConsentOptions; +} diff --git a/server/utils/__mocks__/mail.ts b/server/utils/__mocks__/mail.ts new file mode 100644 index 0000000000..fdde3d2484 --- /dev/null +++ b/server/utils/__mocks__/mail.ts @@ -0,0 +1,4 @@ +export const mailerService = { + send: jest.fn().mockResolvedValue({ success: true }), + sendMail: jest.fn().mockResolvedValue({ success: true }) +}; 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/views/__mocks__/mail.ts b/server/views/__mocks__/mail.ts new file mode 100644 index 0000000000..cdf84b1303 --- /dev/null +++ b/server/views/__mocks__/mail.ts @@ -0,0 +1,12 @@ +export const renderAccountConsolidation = jest.fn().mockReturnValue({ + to: 'test@example.com', + subject: 'Mock consolidate your email' +}); +export const renderResetPassword = jest.fn().mockReturnValue({ + to: 'test@example.com', + subject: 'Mock reset your password' +}); +export const renderEmailConfirmation = jest.fn().mockReturnValue({ + to: 'test@example.com', + subject: 'Mock confirm your email' +});