From cd95bf1c9f95f41490c354be3037aefa88c9a48f Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 27 Sep 2025 15:16:12 +0100 Subject: [PATCH 01/21] routes/user: re-organise into subdomains --- server/routes/user.routes.ts | 71 ++++++++++++++++++++++++++---------- 1 file changed, 51 insertions(+), 20 deletions(-) diff --git a/server/routes/user.routes.ts b/server/routes/user.routes.ts index 532ade8923..a39e9b1487 100644 --- a/server/routes/user.routes.ts +++ b/server/routes/user.routes.ts @@ -4,40 +4,71 @@ import isAuthenticated from '../utils/isAuthenticated'; const router = Router(); +/** + * =============== + * SIGN UP + * =============== + */ +// POST /signup router.post('/signup', UserController.createUser); - +// GET /signup/duplicate_check router.get('/signup/duplicate_check', UserController.duplicateUserCheck); +// GET /verify +router.get('/verify', UserController.verifyEmail); +// POST /verify/send +router.post('/verify/send', UserController.emailVerificationInitiate); -router.put('/preferences', isAuthenticated, UserController.updatePreferences); +/** + * =============== + * API KEYS + * =============== + */ +// POST /account/api-keys +router.post('/account/api-keys', isAuthenticated, UserController.createApiKey); +// DELETE /account/api-keys/:keyId +router.delete( + '/account/api-keys/:keyId', + isAuthenticated, + UserController.removeApiKey +); +/** + * =============== + * PASSWORD MANAGEMENT + * =============== + */ +// POST /reset-password router.post('/reset-password', UserController.resetPasswordInitiate); - +// GET /reset-password/:token router.get('/reset-password/:token', UserController.validateResetPasswordToken); - +// POST /reset-password/:token router.post('/reset-password/:token', UserController.updatePassword); - +// PUT /account (updating password) router.put('/account', isAuthenticated, UserController.updateSettings); +/** + * =============== + * 3RD PARTY AUTH MANAGEMENT + * =============== + */ +// DELETE /auth/github +router.delete('/auth/github', UserController.unlinkGithub); +// DELETE /auth/google +router.delete('/auth/google', UserController.unlinkGoogle); + +/** + * =============== + * USER PREFERENCES + * =============== + */ +// PUT /cookie-consent router.put( '/cookie-consent', isAuthenticated, UserController.updateCookieConsent ); - -router.post('/account/api-keys', isAuthenticated, UserController.createApiKey); - -router.delete( - '/account/api-keys/:keyId', - isAuthenticated, - UserController.removeApiKey -); - -router.post('/verify/send', UserController.emailVerificationInitiate); - -router.get('/verify', UserController.verifyEmail); - -router.delete('/auth/github', UserController.unlinkGithub); -router.delete('/auth/google', UserController.unlinkGoogle); +// PUT /preferences +router.put('/preferences', isAuthenticated, UserController.updatePreferences); // eslint-disable-next-line import/no-default-export export default router; From f8c0c024a3833adae2473d66013388b81448c105 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 27 Sep 2025 15:25:55 +0100 Subject: [PATCH 02/21] server/controllers/user.controller/apiKey: update to ts, no-verify --- server/controllers/user.controller/{apiKey.js => apiKey.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename server/controllers/user.controller/{apiKey.js => apiKey.ts} (100%) diff --git a/server/controllers/user.controller/apiKey.js b/server/controllers/user.controller/apiKey.ts similarity index 100% rename from server/controllers/user.controller/apiKey.js rename to server/controllers/user.controller/apiKey.ts From 41a5fa60a8bb1b91d45af20a66682fcb4f329537 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 27 Sep 2025 15:46:27 +0100 Subject: [PATCH 03/21] add express types, no-verify --- package-lock.json | 138 ++++++++++++++++++++++++++++++++++++++++------ package.json | 1 + 2 files changed, 122 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0400d0037a..619dcc35df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -155,6 +155,7 @@ "@testing-library/jest-dom": "^5.15.0", "@testing-library/react": "^12.1.2", "@types/bcryptjs": "^2.4.6", + "@types/express": "^5.0.3", "@types/friendly-words": "^1.2.2", "@types/jest": "^29.5.14", "@types/mjml": "^4.7.4", @@ -13054,6 +13055,32 @@ "url": "https://opencollective.com/storybook" } }, + "node_modules/@storybook/addons/node_modules/@types/express": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@storybook/addons/node_modules/@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@storybook/addons/node_modules/type-fest": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", @@ -15505,6 +15532,32 @@ "url": "https://opencollective.com/storybook" } }, + "node_modules/@storybook/types/node_modules/@types/express": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@storybook/types/node_modules/@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-6.0.0.tgz", @@ -16424,22 +16477,23 @@ "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==" }, "node_modules/@types/express": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", + "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, + "license": "MIT", "dependencies": { "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", + "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "*" } }, "node_modules/@types/express-serve-static-core": { - "version": "4.19.5", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.5.tgz", - "integrity": "sha512-y6W03tvrACO72aijJ5uF02FRq5cgDR9lUxddQ8vyF+GvmjJQqbzDcJngEjURc+ZsG31VI3hODNZJ2URj86pzmg==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", + "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "@types/qs": "*", @@ -16736,7 +16790,8 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/react": { "version": "16.14.65", @@ -52596,6 +52651,30 @@ "file-system-cache": "2.3.0" } }, + "@types/express": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "type-fest": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", @@ -54368,6 +54447,32 @@ "@types/babel__core": "^7.0.0", "@types/express": "^4.7.0", "file-system-cache": "2.3.0" + }, + "dependencies": { + "@types/express": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + } } }, "@svgr/babel-plugin-add-jsx-attribute": { @@ -55021,21 +55126,20 @@ "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==" }, "@types/express": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", + "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, "requires": { "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", + "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "*" } }, "@types/express-serve-static-core": { - "version": "4.19.5", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.5.tgz", - "integrity": "sha512-y6W03tvrACO72aijJ5uF02FRq5cgDR9lUxddQ8vyF+GvmjJQqbzDcJngEjURc+ZsG31VI3hODNZJ2URj86pzmg==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", + "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", "dev": true, "requires": { "@types/node": "*", diff --git a/package.json b/package.json index b1267258c2..21b06915dc 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,7 @@ "@testing-library/jest-dom": "^5.15.0", "@testing-library/react": "^12.1.2", "@types/bcryptjs": "^2.4.6", + "@types/express": "^5.0.3", "@types/friendly-words": "^1.2.2", "@types/jest": "^29.5.14", "@types/mjml": "^4.7.4", From d185596ebcacf48a58c87173250594931f4a29b2 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 27 Sep 2025 16:05:42 +0100 Subject: [PATCH 04/21] migrate strictNullCheck: true to compileroptions, no-verify --- server/tsconfig.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/tsconfig.json b/server/tsconfig.json index e40550e1eb..42679f0ce9 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -4,9 +4,9 @@ "target": "ES2022", "module": "commonjs", "lib": ["ES2022"], - "types": ["node", "jest"] + "types": ["node", "jest"], + "strictNullChecks": true, }, - "strictNullChecks": true, "include": ["./**/*"], "exclude": ["../node_modules", "../client"] } From 658aaf4b828e86716657348b736953be90d4783e Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 27 Sep 2025 16:07:38 +0100 Subject: [PATCH 05/21] utils/isAuthenticated: migrate to ts, no-verify --- server/utils/{isAuthenticated.js => isAuthenticated.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename server/utils/{isAuthenticated.js => isAuthenticated.ts} (100%) diff --git a/server/utils/isAuthenticated.js b/server/utils/isAuthenticated.ts similarity index 100% rename from server/utils/isAuthenticated.js rename to server/utils/isAuthenticated.ts From 51116c14ab8184ab1e6b27d12bc40bee2931b493 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 27 Sep 2025 16:10:24 +0100 Subject: [PATCH 06/21] update isAuthenticated with types, update to named export & wip user controller add types, no-verify --- server/controllers/user.controller/apiKey.ts | 29 +++++++++++++------- server/routes/aws.routes.ts | 2 +- server/routes/collection.routes.ts | 2 +- server/routes/file.routes.ts | 2 +- server/routes/project.routes.ts | 2 +- server/routes/user.routes.ts | 3 +- server/types/express.ts | 7 +++++ server/types/index.ts | 1 + server/types/user.ts | 2 +- server/utils/isAuthenticated.ts | 21 +++++++++----- 10 files changed, 48 insertions(+), 23 deletions(-) create mode 100644 server/types/express.ts diff --git a/server/controllers/user.controller/apiKey.ts b/server/controllers/user.controller/apiKey.ts index d614a27324..f9345eeaf4 100644 --- a/server/controllers/user.controller/apiKey.ts +++ b/server/controllers/user.controller/apiKey.ts @@ -1,12 +1,13 @@ import crypto from 'crypto'; - +import { Response } from 'express'; import { User } from '../../models/user'; +import { AuthenticatedRequest } 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,8 +19,8 @@ function generateApiKey() { }); } -export async function createApiKey(req, res) { - function sendFailure(code, error) { +export async function createApiKey(req: AuthenticatedRequest, res: Response) { + function sendFailure(code: number, error: string) { res.status(code).json({ error }); } @@ -49,7 +50,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,12 +58,16 @@ 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) { +export async function removeApiKey(req: AuthenticatedRequest, res: Response) { + function sendFailure(code: number, error: string) { res.status(code).json({ error }); } @@ -85,7 +90,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/routes/aws.routes.ts b/server/routes/aws.routes.ts index 91a5751866..98a339a10c 100644 --- a/server/routes/aws.routes.ts +++ b/server/routes/aws.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import * as AWSController from '../controllers/aws.controller'; -import isAuthenticated from '../utils/isAuthenticated'; +import { isAuthenticated } from '../utils/isAuthenticated'; const router = Router(); diff --git a/server/routes/collection.routes.ts b/server/routes/collection.routes.ts index 4ec02961b1..2b25e042c7 100644 --- a/server/routes/collection.routes.ts +++ b/server/routes/collection.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import * as CollectionController from '../controllers/collection.controller'; -import isAuthenticated from '../utils/isAuthenticated'; +import { isAuthenticated } from '../utils/isAuthenticated'; const router = Router(); diff --git a/server/routes/file.routes.ts b/server/routes/file.routes.ts index c0bc434917..7498fbae24 100644 --- a/server/routes/file.routes.ts +++ b/server/routes/file.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import * as FileController from '../controllers/file.controller'; -import isAuthenticated from '../utils/isAuthenticated'; +import { isAuthenticated } from '../utils/isAuthenticated'; const router = Router(); diff --git a/server/routes/project.routes.ts b/server/routes/project.routes.ts index 26f6ee9501..826752e73f 100644 --- a/server/routes/project.routes.ts +++ b/server/routes/project.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import * as ProjectController from '../controllers/project.controller'; -import isAuthenticated from '../utils/isAuthenticated'; +import { isAuthenticated } from '../utils/isAuthenticated'; const router = Router(); diff --git a/server/routes/user.routes.ts b/server/routes/user.routes.ts index a39e9b1487..15cdd4d429 100644 --- a/server/routes/user.routes.ts +++ b/server/routes/user.routes.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import * as UserController from '../controllers/user.controller'; -import isAuthenticated from '../utils/isAuthenticated'; +import { isAuthenticated } from '../utils/isAuthenticated'; +import { RequestHandler } from 'express'; const router = Router(); diff --git a/server/types/express.ts b/server/types/express.ts new file mode 100644 index 0000000000..4a888db160 --- /dev/null +++ b/server/types/express.ts @@ -0,0 +1,7 @@ +import { Request } from 'express'; + +// workaround for express.d.ts not working as expected +/** Authenticated express request for routes that require authentication, which attaches user: {id:string} */ +export interface AuthenticatedRequest extends Request { + user: { id: string }; +} diff --git a/server/types/index.ts b/server/types/index.ts index 6efd8a00fb..8511e3c860 100644 --- a/server/types/index.ts +++ b/server/types/index.ts @@ -1,5 +1,6 @@ export * from './apiKey'; export * from './email'; +export * from './express'; export * from './mongoose'; export * from './user'; export * from './userPreferences'; diff --git a/server/types/user.ts b/server/types/user.ts index ca14ee60cf..c18d386113 100644 --- a/server/types/user.ts +++ b/server/types/user.ts @@ -18,7 +18,7 @@ export interface IUser extends VirtualId, MongooseTimestamps { google?: string; email: string; tokens: { kind: string }[]; - apiKeys: ApiKeyDocument[]; + apiKeys: Types.DocumentArray; preferences: UserPreferences; totalSize: number; cookieConsent: CookieConsentOptions; diff --git a/server/utils/isAuthenticated.ts b/server/utils/isAuthenticated.ts index 865075d864..cbf3e30f24 100644 --- a/server/utils/isAuthenticated.ts +++ b/server/utils/isAuthenticated.ts @@ -1,10 +1,17 @@ -export default function isAuthenticated(req, res, next) { - if (req.user) { - next(); +import { Request, Response, NextFunction } from 'express'; +import { AuthenticatedRequest } from '../types'; + +export function isAuthenticated( + req: Request, + res: Response, + next: NextFunction +): asserts req is AuthenticatedRequest { + if (!req.user) { + res.status(403).json({ + success: false, + message: 'You must be logged in to perform this action.' + }); return; } - res.status(403).send({ - success: false, - message: 'You must be logged in in order to perform the requested action.' - }); + next(); } From 97329d443516388bc63923da0e355feea94f561c Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 27 Sep 2025 16:24:51 +0100 Subject: [PATCH 07/21] wip attaching user {id:string} to authenticated requests, not working, no-verify --- server/controllers/user.controller/apiKey.ts | 13 ++++++++----- server/routes/user.routes.ts | 1 - 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/server/controllers/user.controller/apiKey.ts b/server/controllers/user.controller/apiKey.ts index f9345eeaf4..03ecaf56d3 100644 --- a/server/controllers/user.controller/apiKey.ts +++ b/server/controllers/user.controller/apiKey.ts @@ -1,5 +1,5 @@ import crypto from 'crypto'; -import { Response } from 'express'; +import { RequestHandler } from 'express'; import { User } from '../../models/user'; import { AuthenticatedRequest } from '../../types'; @@ -19,7 +19,10 @@ function generateApiKey(): Promise { }); } -export async function createApiKey(req: AuthenticatedRequest, res: Response) { +export const createApiKey: RequestHandler = async ( + req: AuthenticatedRequest, + res +) => { function sendFailure(code: number, error: string) { res.status(code).json({ error }); } @@ -64,9 +67,9 @@ export async function createApiKey(req: AuthenticatedRequest, res: Response) { res.status(500).json({ error: 'Internal server error' }); } } -} +}; -export async function removeApiKey(req: AuthenticatedRequest, res: Response) { +export const removeApiKey: RequestHandler = async (req, res) => { function sendFailure(code: number, error: string) { res.status(code).json({ error }); } @@ -97,4 +100,4 @@ export async function removeApiKey(req: AuthenticatedRequest, res: Response) { res.status(500).json({ error: 'Internal server error' }); } } -} +}; diff --git a/server/routes/user.routes.ts b/server/routes/user.routes.ts index 15cdd4d429..74aa2a510f 100644 --- a/server/routes/user.routes.ts +++ b/server/routes/user.routes.ts @@ -1,7 +1,6 @@ import { Router } from 'express'; import * as UserController from '../controllers/user.controller'; import { isAuthenticated } from '../utils/isAuthenticated'; -import { RequestHandler } from 'express'; const router = Router(); From b47f8ec8af252a1013821675f161fde2f01faa28 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 27 Sep 2025 16:54:50 +0100 Subject: [PATCH 08/21] finish workaround for unable to attach user to Express Request --- server/controllers/user.controller/apiKey.ts | 11 ++++------- server/utils/isAuthenticated.ts | 17 ++++++----------- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/server/controllers/user.controller/apiKey.ts b/server/controllers/user.controller/apiKey.ts index 03ecaf56d3..213518a0a2 100644 --- a/server/controllers/user.controller/apiKey.ts +++ b/server/controllers/user.controller/apiKey.ts @@ -1,5 +1,5 @@ import crypto from 'crypto'; -import { RequestHandler } from 'express'; +import { RequestHandler, Request } from 'express'; import { User } from '../../models/user'; import { AuthenticatedRequest } from '../../types'; @@ -19,16 +19,13 @@ function generateApiKey(): Promise { }); } -export const createApiKey: RequestHandler = async ( - req: AuthenticatedRequest, - res -) => { +export const createApiKey: RequestHandler = async (req, res) => { function sendFailure(code: number, error: string) { res.status(code).json({ error }); } try { - const user = await User.findById(req.user.id); + const user = await User.findById((req as AuthenticatedRequest).user.id); if (!user) { sendFailure(404, 'User not found'); @@ -75,7 +72,7 @@ export const removeApiKey: RequestHandler = async (req, res) => { } try { - const user = await User.findById(req.user.id); + const user = await User.findById((req as AuthenticatedRequest).user.id); if (!user) { sendFailure(404, 'User not found'); diff --git a/server/utils/isAuthenticated.ts b/server/utils/isAuthenticated.ts index cbf3e30f24..1d12810de0 100644 --- a/server/utils/isAuthenticated.ts +++ b/server/utils/isAuthenticated.ts @@ -1,17 +1,12 @@ -import { Request, Response, NextFunction } from 'express'; -import { AuthenticatedRequest } from '../types'; +import { RequestHandler } from 'express'; -export function isAuthenticated( - req: Request, - res: Response, - next: NextFunction -): asserts req is AuthenticatedRequest { +export const isAuthenticated: RequestHandler = (req, res, next) => { if (!req.user) { - res.status(403).json({ + res.status(403).send({ success: false, - message: 'You must be logged in to perform this action.' + message: 'You must be logged in in order to perform the requested action.' }); - return; } + next(); -} +}; From 4cec0cf55abb138a1a04918f0b48f74a2ee0e7ca Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 27 Sep 2025 16:58:12 +0100 Subject: [PATCH 09/21] server/controller/user: migrate to ts, no-verify --- server/controllers/{user.controller.js => user.controller.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename server/controllers/{user.controller.js => user.controller.ts} (100%) diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.ts similarity index 100% rename from server/controllers/user.controller.js rename to server/controllers/user.controller.ts From b51df2e72f5003ee77c189dc00940be8ac65365f Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 27 Sep 2025 22:44:30 +0100 Subject: [PATCH 10/21] controllers/user: wip update types using RequestHandler, no-verify --- server/controllers/user.controller.ts | 112 +++++++++++++------ server/controllers/user.controller/apiKey.ts | 2 +- server/types/express.ts | 5 + 3 files changed, 85 insertions(+), 34 deletions(-) diff --git a/server/controllers/user.controller.ts b/server/controllers/user.controller.ts index 96f9401075..2500d3fd9d 100644 --- a/server/controllers/user.controller.ts +++ b/server/controllers/user.controller.ts @@ -1,19 +1,27 @@ +/* eslint-disable camelcase */ import crypto from 'crypto'; - +import { RequestHandler, Response } from 'express'; +import { + AuthenticatedRequest, + PublicUserDocument, + UserDocument, + UserPreferences, + Error +} from '../types'; 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) { +export function userResponse(user: UserDocument): PublicUserDocument { return { email: user.email, username: user.username, preferences: user.preferences, apiKeys: user.apiKeys, verified: user.verified, - id: user._id, + id: user.id ?? user._id.toHexString(), totalSize: user.totalSize, github: user.github, google: user.google, @@ -26,7 +34,7 @@ export function userResponse(user) { * Note: can be done synchronously - https://nodejs.org/api/crypto.html#cryptorandombytessize-callback * @return Promise */ -async function generateToken() { +async function generateToken(): Promise { return new Promise((resolve, reject) => { crypto.randomBytes(20, (err, buf) => { if (err) { @@ -39,7 +47,11 @@ async function generateToken() { }); } -export async function createUser(req, res) { +export const createUser: RequestHandler< + {}, + PublicUserDocument | Error, + { username: string; email: string; password: string } +> = async (req, res) => { try { const { username, email, password } = req.body; const emailLowerCase = email.toLowerCase(); @@ -94,11 +106,27 @@ export async function createUser(req, res) { console.error(err); res.status(500).json({ error: err }); } -} - -export async function duplicateUserCheck(req, res) { +}; + +export const duplicateUserCheck: RequestHandler< + {}, + | { + exists: boolean; + message?: string; + type: 'email' | 'username'; + } + | Error, + any, + { check_type: 'email' | 'username'; email?: string; username?: string } +> = async (req, res) => { const checkType = req.query.check_type; const value = req.query[checkType]; + if (!value) { + return res.status(500).json({ + error: + 'Invalid combination duplicate user check type and email or username' + }); + } const options = { caseInsensitive: true, valueType: checkType }; const user = await User.findByEmailOrUsername(value, options); if (user) { @@ -112,11 +140,17 @@ export async function duplicateUserCheck(req, res) { exists: false, type: checkType }); -} +}; -export async function updatePreferences(req, res) { +export const updatePreferences: RequestHandler< + {}, + UserPreferences | Error, + { preferences: UserPreferences } +> = async (req, res) => { try { - const user = await User.findById(req.user.id).exec(); + const user = await User.findById( + (req as AuthenticatedRequest).user.id + ).exec(); if (!user) { res.status(404).json({ error: 'User not found' }); return; @@ -128,9 +162,18 @@ export async function updatePreferences(req, res) { } catch (err) { res.status(500).json({ error: err }); } -} - -export async function resetPasswordInitiate(req, res) { +}; + +export const resetPasswordInitiate: RequestHandler< + {}, + { + success: boolean; + message?: string; + }, + { + email: string; + } +> = async (req, res) => { try { const token = await generateToken(); const user = await User.findByEmail(req.body.email); @@ -165,9 +208,9 @@ export async function resetPasswordInitiate(req, res) { console.log(err); res.json({ success: false }); } -} +}; -export async function validateResetPasswordToken(req, res) { +export const validateResetPasswordToken: RequestHandler = async (req, res) => { const user = await User.findOne({ resetPasswordToken: req.params.token, resetPasswordExpires: { $gt: Date.now() } @@ -180,9 +223,9 @@ export async function validateResetPasswordToken(req, res) { return; } res.json({ success: true }); -} +}; -export async function emailVerificationInitiate(req, res) { +export const emailVerificationInitiate: RequestHandler = async (req, res) => { try { const token = await generateToken(); const user = await User.findById(req.user.id).exec(); @@ -218,9 +261,9 @@ export async function emailVerificationInitiate(req, res) { } catch (err) { res.status(500).json({ error: err }); } -} +}; -export async function verifyEmail(req, res) { +export const verifyEmail: RequestHandler = async (req, res) => { const token = req.query.t; const user = await User.findOne({ verifiedToken: token, @@ -238,9 +281,9 @@ export async function verifyEmail(req, res) { user.verifiedTokenExpires = null; await user.save(); res.json({ success: true }); -} +}; -export async function updatePassword(req, res) { +export const updatePassword: RequestHandler = async (req, res) => { const user = await User.findOne({ resetPasswordToken: req.params.token, resetPasswordExpires: { $gt: Date.now() } @@ -260,13 +303,13 @@ export async function updatePassword(req, res) { 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) { +export async function userExists(username: string): Promise { const user = await User.findByUsername(username); return user != null; } @@ -277,7 +320,10 @@ export async function userExists(username) { * @param res * @param user */ -export async function saveUser(res, user) { +export async function saveUser( + res: Response, + user: UserDocument +): Promise { try { await user.save(); res.json(userResponse(user)); @@ -286,7 +332,7 @@ export async function saveUser(res, user) { } } -export async function updateSettings(req, res) { +export const updateSettings: RequestHandler = async (req, res) => { try { const user = await User.findById(req.user.id); if (!user) { @@ -341,9 +387,9 @@ export async function updateSettings(req, res) { } catch (err) { res.status(500).json({ error: err }); } -} +}; -export async function unlinkGithub(req, res) { +export const unlinkGithub: RequestHandler = async (req, res) => { if (req.user) { req.user.github = undefined; req.user.tokens = req.user.tokens.filter( @@ -356,9 +402,9 @@ export async function unlinkGithub(req, res) { success: false, message: 'You must be logged in to complete this action.' }); -} +}; -export async function unlinkGoogle(req, res) { +export const unlinkGoogle: RequestHandler = async (req, res) => { if (req.user) { req.user.google = undefined; req.user.tokens = req.user.tokens.filter( @@ -371,9 +417,9 @@ export async function unlinkGoogle(req, res) { success: false, message: 'You must be logged in to complete this action.' }); -} +}; -export async function updateCookieConsent(req, res) { +export const updateCookieConsent: RequestHandler = async (req, res) => { try { const user = await User.findById(req.user.id).exec(); if (!user) { @@ -386,4 +432,4 @@ export async function updateCookieConsent(req, res) { } catch (err) { res.status(500).json({ error: err }); } -} +}; diff --git a/server/controllers/user.controller/apiKey.ts b/server/controllers/user.controller/apiKey.ts index 213518a0a2..2cfd3d161f 100644 --- a/server/controllers/user.controller/apiKey.ts +++ b/server/controllers/user.controller/apiKey.ts @@ -1,5 +1,5 @@ import crypto from 'crypto'; -import { RequestHandler, Request } from 'express'; +import { RequestHandler } from 'express'; import { User } from '../../models/user'; import { AuthenticatedRequest } from '../../types'; diff --git a/server/types/express.ts b/server/types/express.ts index 4a888db160..52370e1b11 100644 --- a/server/types/express.ts +++ b/server/types/express.ts @@ -5,3 +5,8 @@ import { Request } from 'express'; export interface AuthenticatedRequest extends Request { user: { id: string }; } + +/** Simple error object for express requests */ +export interface Error { + error: string | unknown; +} From 5100162f2b405794422dc0038ce077b0a7df242f Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 27 Sep 2025 22:58:19 +0100 Subject: [PATCH 11/21] controller/users: wip add types, no-verify --- server/controllers/user.controller.ts | 39 ++++++++++++++++----------- server/types/express.ts | 3 ++- server/types/user.ts | 2 +- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/server/controllers/user.controller.ts b/server/controllers/user.controller.ts index 2500d3fd9d..6331985165 100644 --- a/server/controllers/user.controller.ts +++ b/server/controllers/user.controller.ts @@ -3,8 +3,9 @@ import crypto from 'crypto'; import { RequestHandler, Response } from 'express'; import { AuthenticatedRequest, - PublicUserDocument, + PublicUser, UserDocument, + User as UserType, UserPreferences, Error } from '../types'; @@ -14,14 +15,14 @@ import { renderEmailConfirmation, renderResetPassword } from '../views/mail'; export * from './user.controller/apiKey'; -export function userResponse(user: UserDocument): PublicUserDocument { +export function userResponse(user: UserType | UserDocument): PublicUser { return { email: user.email, username: user.username, preferences: user.preferences, apiKeys: user.apiKeys, verified: user.verified, - id: user.id ?? user._id.toHexString(), + id: typeof user === 'User' ? user.id : user._id!.toHexString(), totalSize: user.totalSize, github: user.github, google: user.google, @@ -49,7 +50,7 @@ async function generateToken(): Promise { export const createUser: RequestHandler< {}, - PublicUserDocument | Error, + PublicUser | Error, { username: string; email: string; password: string } > = async (req, res) => { try { @@ -91,7 +92,7 @@ export const createUser: RequestHandler< domain: `${protocol}://${req.headers.host}`, link: `${protocol}://${req.headers.host}/verify?t=${token}` }, - to: req.user.email + to: (req as AuthenticatedRequest).user.email }); try { @@ -228,7 +229,9 @@ export const validateResetPasswordToken: RequestHandler = async (req, res) => { export const emailVerificationInitiate: RequestHandler = async (req, res) => { try { const token = await generateToken(); - const user = await User.findById(req.user.id).exec(); + const user = await User.findById( + (req as AuthenticatedRequest).user.id + ).exec(); if (!user) { res.status(404).json({ error: 'User not found' }); return; @@ -257,7 +260,7 @@ export const emailVerificationInitiate: RequestHandler = async (req, res) => { user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; // 24 hours await user.save(); - res.json(userResponse(req.user)); + res.json(userResponse((req as AuthenticatedRequest).user)); } catch (err) { res.status(500).json({ error: err }); } @@ -301,7 +304,9 @@ export const updatePassword: RequestHandler = async (req, res) => { user.resetPasswordExpires = undefined; await user.save(); - req.logIn(user, (loginErr) => res.json(userResponse(req.user))); + req.logIn(user, (loginErr) => + res.json(userResponse((req as AuthenticatedRequest).user)) + ); // eventually send email that the password has been reset }; @@ -334,7 +339,7 @@ export async function saveUser( export const updateSettings: RequestHandler = async (req, res) => { try { - const user = await User.findById(req.user.id); + const user = await User.findById((req as AuthenticatedRequest).user.id); if (!user) { res.status(404).json({ error: 'User not found' }); return; @@ -391,11 +396,11 @@ export const updateSettings: RequestHandler = async (req, res) => { export const unlinkGithub: RequestHandler = async (req, res) => { if (req.user) { - req.user.github = undefined; - req.user.tokens = req.user.tokens.filter( + (req as AuthenticatedRequest).user.github = undefined; + (req as AuthenticatedRequest).user.tokens = (req as AuthenticatedRequest).user.tokens.filter( (token) => token.kind !== 'github' ); - await saveUser(res, req.user); + await saveUser(res, (req as AuthenticatedRequest).user); return; } res.status(404).json({ @@ -406,11 +411,11 @@ export const unlinkGithub: RequestHandler = async (req, res) => { export const unlinkGoogle: RequestHandler = async (req, res) => { if (req.user) { - req.user.google = undefined; - req.user.tokens = req.user.tokens.filter( + (req as AuthenticatedRequest).user.google = undefined; + (req as AuthenticatedRequest).user.tokens = (req as AuthenticatedRequest).user.tokens.filter( (token) => token.kind !== 'google' ); - await saveUser(res, req.user); + await saveUser(res, (req as AuthenticatedRequest).user); return; } res.status(404).json({ @@ -421,7 +426,9 @@ export const unlinkGoogle: RequestHandler = async (req, res) => { export const updateCookieConsent: RequestHandler = async (req, res) => { try { - const user = await User.findById(req.user.id).exec(); + const user = await User.findById( + (req as AuthenticatedRequest).user.id + ).exec(); if (!user) { res.status(404).json({ error: 'User not found' }); return; diff --git a/server/types/express.ts b/server/types/express.ts index 52370e1b11..b1ed8fc023 100644 --- a/server/types/express.ts +++ b/server/types/express.ts @@ -1,9 +1,10 @@ import { Request } from 'express'; +import { PublicUser, User } from './user'; // workaround for express.d.ts not working as expected /** Authenticated express request for routes that require authentication, which attaches user: {id:string} */ export interface AuthenticatedRequest extends Request { - user: { id: string }; + user: User; } /** Simple error object for express requests */ diff --git a/server/types/user.ts b/server/types/user.ts index c18d386113..7467d7ec9a 100644 --- a/server/types/user.ts +++ b/server/types/user.ts @@ -30,7 +30,7 @@ export interface IUser extends VirtualId, MongooseTimestamps { export interface User extends IUser {} /** Sanitised version of the user document without sensitive info */ -export interface PublicUserDocument +export interface PublicUser extends Pick< UserDocument, | 'email' From 85c5175e7b9f5676dd2c3c4500c34c1df2227ed0 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 27 Sep 2025 23:35:26 +0100 Subject: [PATCH 12/21] controller/user: add generic error and generic response types, no-verify --- server/controllers/user.controller.ts | 91 ++++++++++++++++++--------- server/types/express.ts | 8 ++- 2 files changed, 68 insertions(+), 31 deletions(-) diff --git a/server/controllers/user.controller.ts b/server/controllers/user.controller.ts index 6331985165..716d763849 100644 --- a/server/controllers/user.controller.ts +++ b/server/controllers/user.controller.ts @@ -7,7 +7,9 @@ import { UserDocument, User as UserType, UserPreferences, - Error + Error, + CookieConsentOptions, + ResponseWithMessageAndSuccess } from '../types'; import { User } from '../models/user'; import { mailerService } from '../utils/mail'; @@ -117,7 +119,7 @@ export const duplicateUserCheck: RequestHandler< type: 'email' | 'username'; } | Error, - any, + {}, { check_type: 'email' | 'username'; email?: string; username?: string } > = async (req, res) => { const checkType = req.query.check_type; @@ -125,7 +127,7 @@ export const duplicateUserCheck: RequestHandler< if (!value) { return res.status(500).json({ error: - 'Invalid combination duplicate user check type and email or username' + 'Invalid combination for duplicate user check-type and value of email or username' }); } const options = { caseInsensitive: true, valueType: checkType }; @@ -167,10 +169,7 @@ export const updatePreferences: RequestHandler< export const resetPasswordInitiate: RequestHandler< {}, - { - success: boolean; - message?: string; - }, + ResponseWithMessageAndSuccess, { email: string; } @@ -211,7 +210,12 @@ export const resetPasswordInitiate: RequestHandler< } }; -export const validateResetPasswordToken: RequestHandler = async (req, res) => { +export const validateResetPasswordToken: RequestHandler< + { + token: string; + }, + ResponseWithMessageAndSuccess +> = async (req, res) => { const user = await User.findOne({ resetPasswordToken: req.params.token, resetPasswordExpires: { $gt: Date.now() } @@ -226,12 +230,21 @@ export const validateResetPasswordToken: RequestHandler = async (req, res) => { res.json({ success: true }); }; -export const emailVerificationInitiate: RequestHandler = async (req, res) => { +export const emailVerificationInitiate: RequestHandler< + {}, + Error | PublicUser +> = async (req, res) => { try { const token = await generateToken(); - const user = await User.findById( - (req as AuthenticatedRequest).user.id - ).exec(); + + const { user: requestUser } = req as AuthenticatedRequest; + if (!requestUser) { + res.status(404).json({ + error: 'You must be logged in to complete this action.' + }); + } + + const user = await User.findById(requestUser.id).exec(); if (!user) { res.status(404).json({ error: 'User not found' }); return; @@ -266,7 +279,10 @@ export const emailVerificationInitiate: RequestHandler = async (req, res) => { } }; -export const verifyEmail: RequestHandler = async (req, res) => { +export const verifyEmail: RequestHandler< + { t: string }, + ResponseWithMessageAndSuccess +> = async (req, res) => { const token = req.query.t; const user = await User.findOne({ verifiedToken: token, @@ -286,7 +302,11 @@ export const verifyEmail: RequestHandler = async (req, res) => { res.json({ success: true }); }; -export const updatePassword: RequestHandler = async (req, res) => { +export const updatePassword: RequestHandler< + { token: string }, + ResponseWithMessageAndSuccess, + { password: string } +> = async (req, res) => { const user = await User.findOne({ resetPasswordToken: req.params.token, resetPasswordExpires: { $gt: Date.now() } @@ -337,9 +357,19 @@ export async function saveUser( } } -export const updateSettings: RequestHandler = async (req, res) => { +export const updateSettings: RequestHandler< + {}, + void | Error, + { + username: string; + newPassword: string; + currentPassword: string; + email: string; + } +> = async (req, res) => { try { - const user = await User.findById((req as AuthenticatedRequest).user.id); + const { user: requestUser } = req as AuthenticatedRequest; + const user = await User.findById(requestUser.id); if (!user) { res.status(404).json({ error: 'User not found' }); return; @@ -396,11 +426,10 @@ export const updateSettings: RequestHandler = async (req, res) => { export const unlinkGithub: RequestHandler = async (req, res) => { if (req.user) { - (req as AuthenticatedRequest).user.github = undefined; - (req as AuthenticatedRequest).user.tokens = (req as AuthenticatedRequest).user.tokens.filter( - (token) => token.kind !== 'github' - ); - await saveUser(res, (req as AuthenticatedRequest).user); + const { user } = req as AuthenticatedRequest; + user.github = undefined; + user.tokens = user.tokens.filter((token) => token.kind !== 'github'); + await saveUser(res, user); return; } res.status(404).json({ @@ -411,11 +440,10 @@ export const unlinkGithub: RequestHandler = async (req, res) => { export const unlinkGoogle: RequestHandler = async (req, res) => { if (req.user) { - (req as AuthenticatedRequest).user.google = undefined; - (req as AuthenticatedRequest).user.tokens = (req as AuthenticatedRequest).user.tokens.filter( - (token) => token.kind !== 'google' - ); - await saveUser(res, (req as AuthenticatedRequest).user); + const { user } = req as AuthenticatedRequest; + user.google = undefined; + user.tokens = user.tokens.filter((token) => token.kind !== 'google'); + await saveUser(res, user); return; } res.status(404).json({ @@ -424,11 +452,14 @@ export const unlinkGoogle: RequestHandler = async (req, res) => { }); }; -export const updateCookieConsent: RequestHandler = async (req, res) => { +export const updateCookieConsent: RequestHandler< + {}, + PublicUser | Error, + { cookieConsent: CookieConsentOptions } +> = async (req, res) => { try { - const user = await User.findById( - (req as AuthenticatedRequest).user.id - ).exec(); + const { user: requestUser } = req as AuthenticatedRequest; + const user = await User.findById(requestUser.id).exec(); if (!user) { res.status(404).json({ error: 'User not found' }); return; diff --git a/server/types/express.ts b/server/types/express.ts index b1ed8fc023..2b8319ae13 100644 --- a/server/types/express.ts +++ b/server/types/express.ts @@ -1,5 +1,5 @@ import { Request } from 'express'; -import { PublicUser, User } from './user'; +import { User } from './user'; // workaround for express.d.ts not working as expected /** Authenticated express request for routes that require authentication, which attaches user: {id:string} */ @@ -11,3 +11,9 @@ export interface AuthenticatedRequest extends Request { export interface Error { error: string | unknown; } + +/** Simple response object for express requests with success status and optional message */ +export interface ResponseWithMessageAndSuccess { + success: boolean; + message?: string; +} From 3c8350666fecbab1097841c0e4833387fdbefb8c Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 28 Sep 2025 00:00:37 +0100 Subject: [PATCH 13/21] controller/user: wip add types, define specific interfaces for request and response bodies for handlers, no-verify --- server/controllers/user.controller.ts | 94 ++++++++++++++++++--------- server/types/express.ts | 2 +- 2 files changed, 63 insertions(+), 33 deletions(-) diff --git a/server/controllers/user.controller.ts b/server/controllers/user.controller.ts index 716d763849..a8b0804a0c 100644 --- a/server/controllers/user.controller.ts +++ b/server/controllers/user.controller.ts @@ -9,7 +9,7 @@ import { UserPreferences, Error, CookieConsentOptions, - ResponseWithMessageAndSuccess + GenericResponseBody } from '../types'; import { User } from '../models/user'; import { mailerService } from '../utils/mail'; @@ -24,7 +24,7 @@ export function userResponse(user: UserType | UserDocument): PublicUser { preferences: user.preferences, apiKeys: user.apiKeys, verified: user.verified, - id: typeof user === 'User' ? user.id : user._id!.toHexString(), + id: user.id, totalSize: user.totalSize, github: user.github, google: user.google, @@ -50,10 +50,17 @@ async function generateToken(): Promise { }); } +/** POST /signup, UserController.createUser */ +export interface CreateUserRequestBody { + username: string; + email: string; + password: string; +} +/** POST /signup, UserController.createUser */ export const createUser: RequestHandler< {}, PublicUser | Error, - { username: string; email: string; password: string } + CreateUserRequestBody > = async (req, res) => { try { const { username, email, password } = req.body; @@ -111,16 +118,21 @@ export const createUser: RequestHandler< } }; +export interface DuplicateUserCheckResponseBody { + exists: boolean; + message?: string; + type: 'email' | 'username'; +} +export interface DuplicateUserQuery { + check_type: 'email' | 'username'; + email?: string; + username?: string; +} export const duplicateUserCheck: RequestHandler< {}, - | { - exists: boolean; - message?: string; - type: 'email' | 'username'; - } - | Error, + DuplicateUserCheckResponseBody | Error, {}, - { check_type: 'email' | 'username'; email?: string; username?: string } + DuplicateUserQuery > = async (req, res) => { const checkType = req.query.check_type; const value = req.query[checkType]; @@ -145,10 +157,13 @@ export const duplicateUserCheck: RequestHandler< }); }; +export interface UpdatePreferencesRequestBody { + preferences: UserPreferences; +} export const updatePreferences: RequestHandler< {}, UserPreferences | Error, - { preferences: UserPreferences } + UpdatePreferencesRequestBody > = async (req, res) => { try { const user = await User.findById( @@ -167,12 +182,13 @@ export const updatePreferences: RequestHandler< } }; +export interface ResetPasswordInitiateRequestBody { + email: string; +} export const resetPasswordInitiate: RequestHandler< {}, - ResponseWithMessageAndSuccess, - { - email: string; - } + GenericResponseBody, + ResetPasswordInitiateRequestBody > = async (req, res) => { try { const token = await generateToken(); @@ -210,11 +226,12 @@ export const resetPasswordInitiate: RequestHandler< } }; +export interface ValidateResetPasswordTokenRouteParams { + token: string; +} export const validateResetPasswordToken: RequestHandler< - { - token: string; - }, - ResponseWithMessageAndSuccess + ValidateResetPasswordTokenRouteParams, + GenericResponseBody > = async (req, res) => { const user = await User.findOne({ resetPasswordToken: req.params.token, @@ -232,7 +249,7 @@ export const validateResetPasswordToken: RequestHandler< export const emailVerificationInitiate: RequestHandler< {}, - Error | PublicUser + PublicUser | Error > = async (req, res) => { try { const token = await generateToken(); @@ -279,9 +296,12 @@ export const emailVerificationInitiate: RequestHandler< } }; +export interface VerifyEmailRouteParams { + t: string; +} export const verifyEmail: RequestHandler< - { t: string }, - ResponseWithMessageAndSuccess + VerifyEmailRouteParams, + GenericResponseBody > = async (req, res) => { const token = req.query.t; const user = await User.findOne({ @@ -302,10 +322,16 @@ export const verifyEmail: RequestHandler< res.json({ success: true }); }; +export interface UpdatePasswordRouteParams { + token: string; +} +export interface UpdatePasswordRequestBody { + password: string; +} export const updatePassword: RequestHandler< - { token: string }, - ResponseWithMessageAndSuccess, - { password: string } + UpdatePasswordRouteParams, + GenericResponseBody, + UpdatePasswordRequestBody > = async (req, res) => { const user = await User.findOne({ resetPasswordToken: req.params.token, @@ -357,15 +383,16 @@ export async function saveUser( } } +export interface UpdateSettingsRequestBody { + username: string; + newPassword: string; + currentPassword: string; + email: string; +} export const updateSettings: RequestHandler< {}, void | Error, - { - username: string; - newPassword: string; - currentPassword: string; - email: string; - } + UpdateSettingsRequestBody > = async (req, res) => { try { const { user: requestUser } = req as AuthenticatedRequest; @@ -452,10 +479,13 @@ export const unlinkGoogle: RequestHandler = async (req, res) => { }); }; +export interface UpdateCookieConsentResponse { + cookieConsent: CookieConsentOptions; +} export const updateCookieConsent: RequestHandler< {}, PublicUser | Error, - { cookieConsent: CookieConsentOptions } + UpdateCookieConsentResponse > = async (req, res) => { try { const { user: requestUser } = req as AuthenticatedRequest; diff --git a/server/types/express.ts b/server/types/express.ts index 2b8319ae13..a1b2fea58c 100644 --- a/server/types/express.ts +++ b/server/types/express.ts @@ -13,7 +13,7 @@ export interface Error { } /** Simple response object for express requests with success status and optional message */ -export interface ResponseWithMessageAndSuccess { +export interface GenericResponseBody { success: boolean; message?: string; } From 2a2a31e681aac74b07be88d01b8809114e787871 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 28 Sep 2025 00:42:11 +0100 Subject: [PATCH 14/21] controllers/user: resolve type errors with workaround --- server/controllers/user.controller.ts | 91 +++++-------------- server/controllers/user.controller/helpers.ts | 64 +++++++++++++ server/controllers/user.controller/index.ts | 2 + 3 files changed, 88 insertions(+), 69 deletions(-) create mode 100644 server/controllers/user.controller/helpers.ts create mode 100644 server/controllers/user.controller/index.ts diff --git a/server/controllers/user.controller.ts b/server/controllers/user.controller.ts index a8b0804a0c..7bdc805f93 100644 --- a/server/controllers/user.controller.ts +++ b/server/controllers/user.controller.ts @@ -1,11 +1,9 @@ /* eslint-disable camelcase */ -import crypto from 'crypto'; import { RequestHandler, Response } from 'express'; import { AuthenticatedRequest, PublicUser, UserDocument, - User as UserType, UserPreferences, Error, CookieConsentOptions, @@ -14,42 +12,14 @@ import { import { User } from '../models/user'; import { mailerService } from '../utils/mail'; import { renderEmailConfirmation, renderResetPassword } from '../views/mail'; +import { + userResponse, + generateToken, + saveUser +} from './user.controller/helpers'; export * from './user.controller/apiKey'; -export function userResponse(user: UserType | UserDocument): 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 - */ -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); - } - }); - }); -} - /** POST /signup, UserController.createUser */ export interface CreateUserRequestBody { username: string; @@ -166,9 +136,13 @@ export const updatePreferences: RequestHandler< UpdatePreferencesRequestBody > = async (req, res) => { try { - const user = await User.findById( - (req as AuthenticatedRequest).user.id - ).exec(); + const { user: requestUser } = req as AuthenticatedRequest; + if (!requestUser) { + res.status(404).json({ + error: 'You must be logged in to complete this action.' + }); + } + const user = await User.findById(requestUser.id).exec(); if (!user) { res.status(404).json({ error: 'User not found' }); return; @@ -330,7 +304,7 @@ export interface UpdatePasswordRequestBody { } export const updatePassword: RequestHandler< UpdatePasswordRouteParams, - GenericResponseBody, + GenericResponseBody | PublicUser, UpdatePasswordRequestBody > = async (req, res) => { const user = await User.findOne({ @@ -351,38 +325,11 @@ export const updatePassword: RequestHandler< await user.save(); req.logIn(user, (loginErr) => - res.json(userResponse((req as AuthenticatedRequest).user)) + res.json(userResponse(((req as unknown) as AuthenticatedRequest).user)) ); // eventually send email that the password has been reset }; -/** - * @param {string} username - * @return {Promise} - */ -export async function userExists(username: string): Promise { - 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: Response, - user: UserDocument -): Promise { - try { - await user.save(); - res.json(userResponse(user)); - } catch (error) { - res.status(500).json({ error }); - } -} - export interface UpdateSettingsRequestBody { username: string; newPassword: string; @@ -396,6 +343,12 @@ export const updateSettings: RequestHandler< > = async (req, res) => { try { const { user: requestUser } = req as AuthenticatedRequest; + if (!requestUser) { + res.status(404).json({ + error: 'You must be logged in to complete this action.' + }); + } + const user = await User.findById(requestUser.id); if (!user) { res.status(404).json({ error: 'User not found' }); @@ -456,7 +409,7 @@ export const unlinkGithub: RequestHandler = async (req, res) => { const { user } = req as AuthenticatedRequest; user.github = undefined; user.tokens = user.tokens.filter((token) => token.kind !== 'github'); - await saveUser(res, user); + await saveUser(res, user as UserDocument); return; } res.status(404).json({ @@ -470,7 +423,7 @@ export const unlinkGoogle: RequestHandler = async (req, res) => { const { user } = req as AuthenticatedRequest; user.google = undefined; user.tokens = user.tokens.filter((token) => token.kind !== 'google'); - await saveUser(res, user); + await saveUser(res, user as UserDocument); return; } res.status(404).json({ diff --git a/server/controllers/user.controller/helpers.ts b/server/controllers/user.controller/helpers.ts new file mode 100644 index 0000000000..4a48427a94 --- /dev/null +++ b/server/controllers/user.controller/helpers.ts @@ -0,0 +1,64 @@ +import crypto from 'crypto'; +import { Response } from 'express'; +import { PublicUser, UserDocument, User as UserType } from '../../types'; +import { User } from '../../models/user'; + +export function userResponse(user: UserType | PublicUser): PublicUser { + return { + email: user.email, + username: user.username, + preferences: user.preferences, + apiKeys: user.apiKeys, + verified: user.verified, + id: user.id, + totalSize: user.totalSize, + github: user.github, + google: user.google, + cookieConsent: user.cookieConsent + }; +} + +/** + * Create a new verification token. + * Note: can be done synchronously - https://nodejs.org/api/crypto.html#cryptorandombytessize-callback + * @return Promise + */ +export async function generateToken(): Promise { + return new Promise((resolve, reject) => { + crypto.randomBytes(20, (err, buf) => { + if (err) { + reject(err); + } else { + const token = buf.toString('hex'); + resolve(token); + } + }); + }); +} + +/** + * Updates the user object and sets the response. + * Response is the user or a 500 error. + * @param res + * @param user + */ +export async function saveUser( + res: Response, + user: UserDocument +): Promise { + try { + await user.save(); + res.json(userResponse(user)); + } catch (error) { + res.status(500).json({ error }); + } +} + +/** + * @param {string} username + * @return {Promise} + */ +export async function userExists(username: string): Promise { + const user = await User.findByUsername(username); + return user != null; +} diff --git a/server/controllers/user.controller/index.ts b/server/controllers/user.controller/index.ts new file mode 100644 index 0000000000..4e472f9bbb --- /dev/null +++ b/server/controllers/user.controller/index.ts @@ -0,0 +1,2 @@ +export * from './apiKey'; +export * from './helpers'; From 8455d7448f57923d23f6c02287e61aafe2cca7df Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 28 Sep 2025 01:05:58 +0100 Subject: [PATCH 15/21] add user controller.helpers tests --- .../user.controller/__tests__/apiKey.test.js | 2 +- .../user.controller/__tests__/helpers.test.js | 155 ++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 server/controllers/user.controller/__tests__/helpers.test.js diff --git a/server/controllers/user.controller/__tests__/apiKey.test.js b/server/controllers/user.controller/__tests__/apiKey.test.js index 87dc1320e8..641de79f04 100644 --- a/server/controllers/user.controller/__tests__/apiKey.test.js +++ b/server/controllers/user.controller/__tests__/apiKey.test.js @@ -4,7 +4,7 @@ import { last } from 'lodash'; import { Request, Response } from 'jest-express'; import { User } from '../../../models/user'; -import { createApiKey, removeApiKey } from '../apiKey'; +import { createApiKey, removeApiKey } from '../index'; jest.mock('../../../models/user'); diff --git a/server/controllers/user.controller/__tests__/helpers.test.js b/server/controllers/user.controller/__tests__/helpers.test.js new file mode 100644 index 0000000000..33e471fbd2 --- /dev/null +++ b/server/controllers/user.controller/__tests__/helpers.test.js @@ -0,0 +1,155 @@ +/* @jest-environment node */ + +import { Request, Response } from 'jest-express'; +import crypto from 'crypto'; + +import { userResponse, generateToken, saveUser, userExists } from '../index'; +import { User } from '../../../models/user'; +import { CookieConsentOptions, AppThemeOptions } from '../../../types'; + +jest.mock('../../../models/user'); + +const mockFullUser = { + email: 'test@example.com', + username: 'tester', + preferences: { + fontSize: 12, + lineNumbers: false, + indentationAmount: 10, + isTabIndent: false, + autosave: false, + linewrap: false, + lintWarning: false, + textOutput: false, + gridOutput: false, + theme: AppThemeOptions.CONTRAST, + autorefresh: false, + language: 'en-GB', + autocloseBracketsQuotes: false, + autocompleteHinter: false + }, + apiKeys: [], + verified: true, + id: 'abc123', + totalSize: 42, + cookieConsent: CookieConsentOptions.NONE, + + // to be removed: + password: 'abweorij', + resetPasswordToken: '1i14ij23', + banned: false +}; + +describe('user.helpers', () => { + let request; + let response; + + beforeEach(() => { + request = new Request(); + response = new Response(); + }); + + afterEach(() => { + request.resetMocked(); + response.resetMocked(); + jest.clearAllMocks(); + }); + + describe('userResponse', () => { + it('returns a sanitized PublicUser object', () => { + const result = userResponse(mockFullUser); + + // eslint-disable-next-line no-unused-vars + const { password, resetPasswordToken, banned, ...sanitised } = result; + + expect(result).toEqual(sanitised); + }); + it('gracefully handles objects with some, but not all properties of PublicUser', () => { + const fakeUser = { + email: 'test@example.com', + username: 'tester', + id: 'abc123', + totalSize: 42, + + // to be removed: + password: 'abweorij', + resetPasswordToken: '1i14ij23', + banned: false + }; + + const result = userResponse(fakeUser); + + // eslint-disable-next-line no-unused-vars + const { password, resetPasswordToken, banned, ...sanitised } = result; + + expect(result).toEqual(sanitised); + }); + }); + + describe('generateToken', () => { + it('generates a random hex string of length 40', async () => { + const token = await generateToken(); + expect(typeof token).toBe('string'); + expect(token).toMatch(/^[a-f0-9]+$/); + expect(token).toHaveLength(40); + }); + + it('rejects if crypto.randomBytes errors', async () => { + const spy = jest + .spyOn(crypto, 'randomBytes') + .mockImplementationOnce((_size, cb) => { + cb(new Error('fail'), Buffer.alloc(0)); + return {}; + }); + + await expect(generateToken()).rejects.toThrow('fail'); + + spy.mockRestore(); + }); + }); + + describe('saveUser', () => { + it('saves user and responds with sanitized user', async () => { + const user = { + ...mockFullUser, + save: jest.fn().mockRejectedValue(new Error('db error')) + }; + + await saveUser(response, user); + + expect(user.save).toHaveBeenCalled(); + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'x@y.com', + username: 'user1' + }) + ); + }); + + it('responds with 500 if save fails', async () => { + const user = { + ...mockFullUser, + save: jest.fn().mockRejectedValue(new Error('db error')) + }; + + await saveUser(response, user); + + expect(response.status).toHaveBeenCalledWith(500); + expect(response.json).toHaveBeenCalledWith({ error: expect.any(Error) }); + }); + }); + + describe('userExists', () => { + it('returns true if user is found', async () => { + User.findByUsername = jest.fn().mockResolvedValue({ id: '123' }); + const result = await userExists('someone'); + expect(result).toBe(true); + }); + + it('returns false if user not found', async () => { + User.findByUsername = jest.fn().mockResolvedValue(null); + const result = await userExists('nobody'); + expect(result).toBe(false); + }); + }); +}); From 6995754e241fa601b33200f8a49b79090d248e21 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Fri, 3 Oct 2025 01:15:27 +0200 Subject: [PATCH 16/21] fix failing test --- server/controllers/user.controller/__tests__/helpers.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/controllers/user.controller/__tests__/helpers.test.js b/server/controllers/user.controller/__tests__/helpers.test.js index 33e471fbd2..1c8d27d9c8 100644 --- a/server/controllers/user.controller/__tests__/helpers.test.js +++ b/server/controllers/user.controller/__tests__/helpers.test.js @@ -112,7 +112,7 @@ describe('user.helpers', () => { it('saves user and responds with sanitized user', async () => { const user = { ...mockFullUser, - save: jest.fn().mockRejectedValue(new Error('db error')) + save: jest.fn().mockResolvedValue(undefined) }; await saveUser(response, user); From cc36abaf2479287cc1242117990b5c57cf97bb48 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Fri, 3 Oct 2025 01:22:59 +0200 Subject: [PATCH 17/21] fix test --- .../user.controller/__tests__/helpers.test.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/server/controllers/user.controller/__tests__/helpers.test.js b/server/controllers/user.controller/__tests__/helpers.test.js index 1c8d27d9c8..2b4064e42b 100644 --- a/server/controllers/user.controller/__tests__/helpers.test.js +++ b/server/controllers/user.controller/__tests__/helpers.test.js @@ -118,11 +118,12 @@ describe('user.helpers', () => { await saveUser(response, user); expect(user.save).toHaveBeenCalled(); + + // eslint-disable-next-line no-unused-vars + const { password, resetPasswordToken, banned, ...sanitised } = user; + expect(response.json).toHaveBeenCalledWith( - expect.objectContaining({ - email: 'x@y.com', - username: 'user1' - }) + expect.objectContaining(sanitised) ); }); From afd404e09d7225ca8c0dcbf3f5a8c8919230da61 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 4 Oct 2025 12:52:53 +0100 Subject: [PATCH 18/21] fix failing test --- .../user.controller/__tests__/helpers.test.js | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/server/controllers/user.controller/__tests__/helpers.test.js b/server/controllers/user.controller/__tests__/helpers.test.js index 2b4064e42b..9b6f2ca96d 100644 --- a/server/controllers/user.controller/__tests__/helpers.test.js +++ b/server/controllers/user.controller/__tests__/helpers.test.js @@ -1,3 +1,4 @@ +/* eslint-disable no-unused-vars */ /* @jest-environment node */ import { Request, Response } from 'jest-express'; @@ -33,6 +34,8 @@ const mockFullUser = { id: 'abc123', totalSize: 42, cookieConsent: CookieConsentOptions.NONE, + google: 'user@gmail.com', + github: 'user123', // to be removed: password: 'abweorij', @@ -59,10 +62,15 @@ describe('user.helpers', () => { it('returns a sanitized PublicUser object', () => { const result = userResponse(mockFullUser); - // eslint-disable-next-line no-unused-vars - const { password, resetPasswordToken, banned, ...sanitised } = result; + const { + password, + resetPasswordToken, + banned, + save, + ...sanitised + } = result; - expect(result).toEqual(sanitised); + expect(result).toMatchObject(sanitised); }); it('gracefully handles objects with some, but not all properties of PublicUser', () => { const fakeUser = { @@ -79,10 +87,15 @@ describe('user.helpers', () => { const result = userResponse(fakeUser); - // eslint-disable-next-line no-unused-vars - const { password, resetPasswordToken, banned, ...sanitised } = result; + const { + password, + resetPasswordToken, + banned, + save, + ...sanitised + } = result; - expect(result).toEqual(sanitised); + expect(result).toMatchObject(sanitised); }); }); @@ -119,8 +132,7 @@ describe('user.helpers', () => { expect(user.save).toHaveBeenCalled(); - // eslint-disable-next-line no-unused-vars - const { password, resetPasswordToken, banned, ...sanitised } = user; + const { password, resetPasswordToken, banned, save, ...sanitised } = user; expect(response.json).toHaveBeenCalledWith( expect.objectContaining(sanitised) From a8e6c5b19aa6cbe57cd62d8b1993a07fb9d9b3fd Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 4 Oct 2025 13:11:35 +0100 Subject: [PATCH 19/21] add unit test draft for user controller, big wip, to be re-written on fresh PR --- .../user.controller/__tests__/signup.test.ts | 795 ++++++++++++++++++ 1 file changed, 795 insertions(+) create mode 100644 server/controllers/user.controller/__tests__/signup.test.ts 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..5d4b85005d --- /dev/null +++ b/server/controllers/user.controller/__tests__/signup.test.ts @@ -0,0 +1,795 @@ +/* @jest-environment node */ + +import { Request, Response } from 'jest-express'; +jest.mock('../../../utils/mail', () => ({ + mailerService: { + send: jest.fn().mockResolvedValue(true) + } +})); + +import * as controller from '../../user.controller'; +import { User } from '../../../models/user'; +import { mailerService } from '../../../utils/mail'; +import { + renderEmailConfirmation, + renderResetPassword +} from '../../../views/mail'; +import { userResponse, generateToken, saveUser } from '../helpers'; + +jest.mock('../../../models/user'); +jest.mock('../../../utils/mail'); +jest.mock('../../../views/mail'); +jest.mock('../helpers'); + +const mockUserBase = { + email: 'test@example.com', + username: 'tester', + preferences: { + fontSize: 12, + lineNumbers: false, + indentationAmount: 10, + isTabIndent: false, + autosave: false, + linewrap: false, + lintWarning: false, + textOutput: false, + gridOutput: false, + theme: 'contrast', + autorefresh: false, + language: 'en-GB', + autocloseBracketsQuotes: false, + autocompleteHinter: false + }, + apiKeys: [], + verified: true, + id: 'abc123', + totalSize: 42, + cookieConsent: 'none', + google: 'user@gmail.com', + github: 'user123', + tokens: [{ kind: 'github' }, { kind: 'google' }] +}; + +describe('user.controller unit tests', () => { + let req: Request; + let res: Response; + + beforeEach(() => { + req = new Request(); + res = new Response(); + jest.clearAllMocks(); + + // default mocks for helpers + (generateToken as jest.Mock).mockResolvedValue('tok-123'); + (userResponse as jest.Mock).mockImplementation((u) => + // return a shallow sanitized shape + ({ + email: u.email, + username: u.username, + id: u.id, + totalSize: u.totalSize, + apiKeys: u.apiKeys, + cookieConsent: u.cookieConsent, + preferences: u.preferences, + verified: u.verified, + google: u.google, + github: u.github + }) + ); + + // default mail renderer & mailer + (renderEmailConfirmation as jest.Mock).mockImplementation( + ({ body, to }) => ({ + to, + body + }) + ); + (renderResetPassword as jest.Mock).mockImplementation(({ body, to }) => ({ + to, + body + })); + (mailerService.send as jest.Mock).mockResolvedValue(undefined); + + // default EmailConfirmation helper on User + ((User as unknown) as any).EmailConfirmation = jest.fn().mockReturnValue({ + Sent: 'sent', + Resent: 'resent', + Verified: 'verified' + }); + }); + + /** ************************************************************************* + * createUser + ************************************************************************** */ + describe('createUser', () => { + it('responds 422 when email or username already in use', async () => { + (User as any).findByEmailAndUsername = jest + .fn() + .mockResolvedValue({ email: 'test@example.com' }); + + req.body = { + username: 'tester', + email: 'test@example.com', + password: 'pw' + }; + req.headers.host = 'example.test'; + + await controller.createUser(req as any, res as any); + + expect(res.status).toHaveBeenCalledWith(422); + expect(res.send).toHaveBeenCalledWith({ error: expect.any(String) }); + }); + + it('creates user, logs in, sends email and responds with sanitized user', async () => { + (User as any).findByEmailAndUsername = jest.fn().mockResolvedValue(null); + + // new User constructor should return an object with save + const newUser = { + ...mockUserBase, + password: 'pw', + verified: 'sent', + verifiedToken: 'tok-123', + verifiedTokenExpires: expect.any(Number), + save: jest.fn().mockResolvedValue(undefined) + }; + // mock User constructor + (User as any).mockImplementationOnce(() => newUser); + + // req.logIn to call callback with no error + req.logIn = jest.fn((user, cb) => cb && cb(null)); + req.body = { + username: 'tester', + email: 'TEST@EXAMPLE.COM', + password: 'pw' + }; + req.headers.host = 'example.test'; + + await controller.createUser(req as any, res as any); + + expect(newUser.save).toHaveBeenCalled(); + expect(mailerService.send).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining(userResponse(newUser)) + ); + }); + + it('handles login error by responding 500', async () => { + (User as any).findByEmailAndUsername = jest.fn().mockResolvedValue(null); + const newUser = { + ...mockUserBase, + password: 'pw', + verified: 'sent', + verifiedToken: 'tok-123', + verifiedTokenExpires: Date.now() + 1000, + save: jest.fn().mockResolvedValue(undefined) + }; + (User as any).mockImplementationOnce(() => newUser); + + req.logIn = jest.fn((user, cb) => cb && cb(new Error('login fail'))); + req.body = { + username: 'tester', + email: 'test@example.com', + password: 'pw' + }; + req.headers.host = 'example.test'; + + await controller.createUser(req as any, res as any); + + expect(newUser.save).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + error: 'Failed to log in user.' + }); + }); + + it('handles mailer failure by responding 500', async () => { + (User as any).findByEmailAndUsername = jest.fn().mockResolvedValue(null); + const newUser = { + ...mockUserBase, + password: 'pw', + verified: 'sent', + verifiedToken: 'tok-123', + verifiedTokenExpires: Date.now() + 1000, + save: jest.fn().mockResolvedValue(undefined) + }; + (User as any).mockImplementationOnce(() => newUser); + + req.logIn = jest.fn((user, cb) => cb && cb(null)); + (mailerService.send as jest.Mock).mockRejectedValue( + new Error('smtp fail') + ); + req.body = { + username: 'tester', + email: 'test@example.com', + password: 'pw' + }; + req.headers.host = 'example.test'; + + await controller.createUser(req as any, res as any); + + expect(newUser.save).toHaveBeenCalled(); + expect(mailerService.send).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + error: 'Failed to send verification email.' + }); + }); + }); + + /** ************************************************************************* + * duplicateUserCheck + ************************************************************************** */ + describe('duplicateUserCheck', () => { + it('responds 500 when missing value', async () => { + req.query = { check_type: 'email' }; // but email param missing + await controller.duplicateUserCheck(req as any, res as any); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + error: expect.any(String) + }); + }); + + it('returns exists:true when found', async () => { + (User as any).findByEmailOrUsername = jest + .fn() + .mockResolvedValue({ id: 'u1' }); + req.query = { check_type: 'email', email: 'a@b.com' }; + + await controller.duplicateUserCheck(req as any, res as any); + + expect(res.json).toHaveBeenCalledWith({ + exists: true, + message: 'This email is already taken.', + type: 'email' + }); + }); + + it('returns exists:false when not found', async () => { + (User as any).findByEmailOrUsername = jest.fn().mockResolvedValue(null); + req.query = { check_type: 'username', username: 'someone' }; + + await controller.duplicateUserCheck(req as any, res as any); + + expect(res.json).toHaveBeenCalledWith({ + exists: false, + type: 'username' + }); + }); + }); + + /** ************************************************************************* + * updatePreferences + ************************************************************************** */ + describe('updatePreferences', () => { + it('returns 404 if not logged in', async () => { + req.body = { preferences: { fontSize: 14 } }; + await controller.updatePreferences(req as any, res as any); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + error: 'You must be logged in to complete this action.' + }); + }); + + it('returns 404 if user not found', async () => { + req.user = { id: 'abc123' } as any; + (User as any).findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); + + req.body = { preferences: { fontSize: 14 } }; + + await controller.updatePreferences(req as any, res as any); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'User not found' }); + }); + + it('merges preferences, saves and returns preferences', async () => { + const user = { + ...mockUserBase, + preferences: { fontSize: 12, lineNumbers: false }, + save: jest.fn().mockResolvedValue(undefined) + }; + (User as any).findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(user) }); + + req.user = { id: 'abc123' } as any; + req.body = { preferences: { fontSize: 16 } }; + + await controller.updatePreferences(req as any, res as any); + + expect(user.save).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ fontSize: 16 }) + ); + }); + }); + + /** ************************************************************************* + * resetPasswordInitiate + ************************************************************************** */ + describe('resetPasswordInitiate', () => { + it('responds success true if user not found (no leak)', async () => { + (generateToken as jest.Mock).mockResolvedValue('tok-123'); + (User as any).findByEmail = jest.fn().mockResolvedValue(null); + req.body = { email: 'no-such@example.com' }; + req.headers.host = 'example.test'; + + await controller.resetPasswordInitiate(req as any, res as any); + + expect(res.json).toHaveBeenCalledWith({ + success: true, + message: expect.any(String) + }); + }); + + it('saves token, sends email and responds success true when user found', async () => { + (generateToken as jest.Mock).mockResolvedValue('tok-123'); + const user = { + ...mockUserBase, + save: jest.fn().mockResolvedValue(undefined) + }; + (User as any).findByEmail = jest.fn().mockResolvedValue(user); + req.body = { email: 'test@example.com' }; + req.headers.host = 'example.test'; + + await controller.resetPasswordInitiate(req as any, res as any); + + expect(user.save).toHaveBeenCalled(); + expect(mailerService.send).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ + success: true, + message: expect.any(String) + }); + }); + + it('returns success false on exception (mailer error)', async () => { + (generateToken as jest.Mock).mockResolvedValue('tok-123'); + const user = { + ...mockUserBase, + save: jest.fn().mockResolvedValue(undefined) + }; + (User as any).findByEmail = jest.fn().mockResolvedValue(user); + (mailerService.send as jest.Mock).mockRejectedValue( + new Error('mailfail') + ); + req.body = { email: 'test@example.com' }; + req.headers.host = 'example.test'; + + await controller.resetPasswordInitiate(req as any, res as any); + + expect(user.save).toHaveBeenCalled(); + expect(mailerService.send).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ success: false }); + }); + }); + + /** ************************************************************************* + * validateResetPasswordToken + ************************************************************************** */ + describe('validateResetPasswordToken', () => { + it('returns 401 when token invalid or expired', async () => { + (User as any).findOne = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); + req.params = { token: 'bad' }; + + await controller.validateResetPasswordToken(req as any, res as any); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + success: false, + message: 'Password reset token is invalid or has expired.' + }); + }); + + it('returns success true when token valid', async () => { + const user = { id: 'u1' }; + (User as any).findOne = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(user) }); + req.params = { token: 'good' }; + + await controller.validateResetPasswordToken(req as any, res as any); + + expect(res.json).toHaveBeenCalledWith({ success: true }); + }); + }); + + /** ************************************************************************* + * emailVerificationInitiate + ************************************************************************** */ + describe('emailVerificationInitiate', () => { + it('returns 404 if not logged in', async () => { + await controller.emailVerificationInitiate(req as any, res as any); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + error: 'You must be logged in to complete this action.' + }); + }); + + it('returns 404 if user not found', async () => { + req.user = { id: 'abc123' } as any; + (User as any).findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); + + await controller.emailVerificationInitiate(req as any, res as any); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'User not found' }); + }); + + it('returns 409 if already verified', async () => { + req.user = { id: 'abc123' } as any; + const user = { ...mockUserBase, verified: 'verified' }; + (User as any).findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(user) }); + + await controller.emailVerificationInitiate(req as any, res as any); + + expect(res.status).toHaveBeenCalledWith(409); + expect(res.json).toHaveBeenCalledWith({ + error: 'Email already verified' + }); + }); + + it('sends mail, updates user, saves and responds with sanitized user', async () => { + req.user = { id: 'abc123', email: 'test@example.com' } as any; + const user = { + ...mockUserBase, + verified: 'sent', + save: jest.fn().mockResolvedValue(undefined) + }; + (User as any).findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(user) }); + (mailerService.send as jest.Mock).mockResolvedValue(undefined); + + req.headers.host = 'example.test'; + + await controller.emailVerificationInitiate(req as any, res as any); + + expect(mailerService.send).toHaveBeenCalled(); + expect(user.save).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining(userResponse(req.user)) + ); + }); + + it('handles mail send error with 500', async () => { + req.user = { id: 'abc123', email: 'test@example.com' } as any; + const user = { + ...mockUserBase, + verified: 'sent', + save: jest.fn().mockResolvedValue(undefined) + }; + (User as any).findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(user) }); + (mailerService.send as jest.Mock).mockRejectedValue(new Error('fail')); + + await controller.emailVerificationInitiate(req as any, res as any); + + expect(mailerService.send).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.send).toHaveBeenCalledWith({ error: 'Error sending mail' }); + }); + }); + + /** ************************************************************************* + * verifyEmail + ************************************************************************** */ + describe('verifyEmail', () => { + it('returns 401 when token invalid', async () => { + (User as any).findOne = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); + req.query = { t: 'bad' }; + + await controller.verifyEmail(req as any, res as any); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + success: false, + message: 'Token is invalid or has expired.' + }); + }); + + it('verifies user, clears tokens and responds success', async () => { + const user = { save: jest.fn().mockResolvedValue(undefined) }; + (User as any).findOne = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(user) }); + req.query = { t: 'good' }; + + await controller.verifyEmail(req as any, res as any); + + expect(user.save).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ success: true }); + }); + }); + + /** ************************************************************************* + * updatePassword + ************************************************************************** */ + describe('updatePassword', () => { + it('returns 401 when token invalid', async () => { + (User as any).findOne = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); + req.params = { token: 'bad' }; + + await controller.updatePassword(req as any, res as any); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + success: false, + message: 'Password reset token is invalid or has expired.' + }); + }); + + it('updates password, saves and logs in user then responds with sanitized user', async () => { + const user = { + ...mockUserBase, + save: jest.fn().mockResolvedValue(undefined) + }; + (User as any).findOne = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(user) }); + + // set req.logIn and req.user for response + req.logIn = jest.fn((u, cb) => cb && cb(null)); + req.params = { token: 'good' }; + req.body = { password: 'newpass' }; + // req.user in this flow used by userResponse after logIn; simulate + (req as any).user = { + email: 'test@example.com', + username: 'tester', + id: 'abc123' + }; + + await controller.updatePassword(req as any, res as any); + + expect(user.save).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining(userResponse(req.user)) + ); + }); + }); + + /** ************************************************************************* + * updateSettings + ************************************************************************** */ + describe('updateSettings', () => { + it('returns 404 if not logged in', async () => { + await controller.updateSettings(req as any, res as any); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + error: 'You must be logged in to complete this action.' + }); + }); + + it('returns 404 if user not found', async () => { + req.user = { id: 'abc123' } as any; + (User as any).findById = jest.fn().mockResolvedValue(null); + await controller.updateSettings(req as any, res as any); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'User not found' }); + }); + + it('updates username and saves via saveUser when email unchanged', async () => { + const user = { + ...mockUserBase, + password: 'old', + save: jest.fn().mockResolvedValue(undefined) + }; + (User as any).findById = jest.fn().mockResolvedValue(user); + req.user = { id: 'abc123' } as any; + req.body = { username: 'newname', email: user.email }; + + (saveUser as jest.Mock).mockResolvedValue(undefined); + + await controller.updateSettings(req as any, res as any); + + expect(user.username).toBe('newname'); + expect(saveUser).toHaveBeenCalledWith(res, user); + }); + + it('changes email, generates token, saves, sends mail', async () => { + const user = { + ...mockUserBase, + save: jest.fn().mockResolvedValue(undefined) + }; + (User as any).findById = jest.fn().mockResolvedValue(user); + req.user = { id: 'abc123' } as any; + req.body = { + username: user.username, + email: 'new@example.com', + newPassword: '', + currentPassword: '' + }; + req.headers.host = 'example.test'; + + (generateToken as jest.Mock).mockResolvedValue('tok-123'); + (saveUser as jest.Mock).mockResolvedValue(undefined); + + await controller.updateSettings(req as any, res as any); + + expect(user.email).toBe('new@example.com'); + expect(user.verified).toBe('sent'); + expect(generateToken).toHaveBeenCalled(); + expect(saveUser).toHaveBeenCalledWith(res, user); + expect(mailerService.send).toHaveBeenCalled(); + }); + + it('when newPassword and user.password undefined -> set password and saveUser called', async () => { + const user = { + ...mockUserBase, + password: undefined, + save: jest.fn().mockResolvedValue(undefined) + }; + (User as any).findById = jest.fn().mockResolvedValue(user); + req.user = { id: 'abc123' } as any; + req.body = { username: 'u', newPassword: 'new', currentPassword: '' }; + + (saveUser as jest.Mock).mockResolvedValue(undefined); + + await controller.updateSettings(req as any, res as any); + + expect(user.password).toBe('new'); + expect(saveUser).toHaveBeenCalledWith(res, user); + }); + + it('returns 401 if newPassword provided but currentPassword missing', async () => { + const user = { ...mockUserBase, password: 'old' }; + (User as any).findById = jest.fn().mockResolvedValue(user); + req.user = { id: 'abc123' } as any; + req.body = { username: 'u', newPassword: 'new', currentPassword: '' }; + + await controller.updateSettings(req as any, res as any); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + error: 'Current password is not provided.' + }); + }); + + it('returns 401 if currentPassword invalid', async () => { + const user: any = { + ...mockUserBase, + password: 'old', + comparePassword: jest.fn().mockResolvedValue(false) + }; + (User as any).findById = jest.fn().mockResolvedValue(user); + req.user = { id: 'abc123' } as any; + req.body = { + username: 'u', + newPassword: 'new', + currentPassword: 'wrong' + }; + + await controller.updateSettings(req as any, res as any); + + expect(user.comparePassword).toHaveBeenCalledWith('wrong'); + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + error: 'Current password is invalid.' + }); + }); + + it('changes password when currentPassword valid and calls saveUser', async () => { + const user: any = { + ...mockUserBase, + password: 'old', + comparePassword: jest.fn().mockResolvedValue(true) + }; + (User as any).findById = jest.fn().mockResolvedValue(user); + req.user = { id: 'abc123' } as any; + req.body = { username: 'u', newPassword: 'new', currentPassword: 'old' }; + + (saveUser as jest.Mock).mockResolvedValue(undefined); + + await controller.updateSettings(req as any, res as any); + + expect(user.comparePassword).toHaveBeenCalledWith('old'); + expect(user.password).toBe('new'); + expect(saveUser).toHaveBeenCalledWith(res, user); + }); + }); + + /** ************************************************************************* + * unlinkGithub / unlinkGoogle + ************************************************************************** */ + describe('unlinkGithub & unlinkGoogle', () => { + it('returns 404 when not logged in for unlinkGithub', async () => { + await controller.unlinkGithub(req as any, res as any); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + success: false, + message: 'You must be logged in to complete this action.' + }); + }); + + it('unlinks github when logged in', async () => { + const user: any = { + ...mockUserBase, + tokens: [{ kind: 'github' }, { kind: 'other' }], + save: jest.fn().mockResolvedValue(undefined) + }; + (saveUser as jest.Mock).mockResolvedValue(undefined); + req.user = user as any; + (req as any).user = user; + + await controller.unlinkGithub(req as any, res as any); + + expect(user.github).toBeUndefined(); + expect(saveUser).toHaveBeenCalledWith(res, user); + }); + + it('returns 404 when not logged in for unlinkGoogle', async () => { + await controller.unlinkGoogle(req as any, res as any); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + success: false, + message: 'You must be logged in to complete this action.' + }); + }); + + it('unlinks google when logged in', async () => { + const user: any = { + ...mockUserBase, + tokens: [{ kind: 'google' }, { kind: 'other' }], + save: jest.fn().mockResolvedValue(undefined) + }; + (saveUser as jest.Mock).mockResolvedValue(undefined); + req.user = user as any; + (req as any).user = user; + + await controller.unlinkGoogle(req as any, res as any); + + expect(user.google).toBeUndefined(); + expect(saveUser).toHaveBeenCalledWith(res, user); + }); + }); + + /** ************************************************************************* + * updateCookieConsent + ************************************************************************** */ + describe('updateCookieConsent', () => { + it('returns 404 if user not found', async () => { + req.user = { id: 'abc123' } as any; + (User as any).findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); + req.body = { cookieConsent: 'none' }; + + await controller.updateCookieConsent(req as any, res as any); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'User not found' }); + }); + + it('sets cookieConsent and calls saveUser', async () => { + const user: any = { + ...mockUserBase, + save: jest.fn().mockResolvedValue(undefined) + }; + (User as any).findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(user) }); + (saveUser as jest.Mock).mockResolvedValue(undefined); + req.user = { id: 'abc123' } as any; + req.body = { cookieConsent: 'none' }; + + await controller.updateCookieConsent(req as any, res as any); + + expect(user.cookieConsent).toBe('none'); + expect(saveUser).toHaveBeenCalledWith(res, user); + }); + }); +}); From e84256b5f5d54777925e5ad79099edf8c55dcc02 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Wed, 8 Oct 2025 13:52:14 +0100 Subject: [PATCH 20/21] server/controllers/user.controller/apiKey: add types to apikey related requests --- server/controllers/user.controller/apiKey.ts | 21 ++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/server/controllers/user.controller/apiKey.ts b/server/controllers/user.controller/apiKey.ts index 2cfd3d161f..310da2a805 100644 --- a/server/controllers/user.controller/apiKey.ts +++ b/server/controllers/user.controller/apiKey.ts @@ -1,7 +1,7 @@ import crypto from 'crypto'; import { RequestHandler } from 'express'; import { User } from '../../models/user'; -import { AuthenticatedRequest } from '../../types'; +import type { AuthenticatedRequest, ApiKeyDocument, Error } from '../../types'; /** * Generates a unique token to be used as a Personal Access Token @@ -19,7 +19,12 @@ function generateApiKey(): Promise { }); } -export const createApiKey: RequestHandler = async (req, res) => { +/** POST /account/api-keys, UserController.createApiKey */ +export const createApiKey: RequestHandler< + {}, + { apiKeys: ApiKeyDocument[] } | Error, + { label: string } +> = async (req, res) => { function sendFailure(code: number, error: string) { res.status(code).json({ error }); } @@ -66,13 +71,21 @@ export const createApiKey: RequestHandler = async (req, res) => { } }; -export const removeApiKey: RequestHandler = async (req, res) => { +/** DELETE /account/api-keys/:keyId, UserController.removeApiKey */ +export const removeApiKey: RequestHandler< + { + keyId: string; + }, + { apiKeys: ApiKeyDocument[] } | Error +> = async (req, res) => { function sendFailure(code: number, error: string) { res.status(code).json({ error }); } try { - const user = await User.findById((req as AuthenticatedRequest).user.id); + const user = await User.findById( + ((req as unknown) as AuthenticatedRequest).user.id + ); if (!user) { sendFailure(404, 'User not found'); From 751328b5f9cccc141bbce520bef4f0a39e8e2c78 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Wed, 8 Oct 2025 13:52:54 +0100 Subject: [PATCH 21/21] wip test for user controller, no-verify --- .../user.controller/__tests__/signup.test.ts | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/server/controllers/user.controller/__tests__/signup.test.ts b/server/controllers/user.controller/__tests__/signup.test.ts index 5d4b85005d..652e74e53f 100644 --- a/server/controllers/user.controller/__tests__/signup.test.ts +++ b/server/controllers/user.controller/__tests__/signup.test.ts @@ -1,11 +1,6 @@ /* @jest-environment node */ import { Request, Response } from 'jest-express'; -jest.mock('../../../utils/mail', () => ({ - mailerService: { - send: jest.fn().mockResolvedValue(true) - } -})); import * as controller from '../../user.controller'; import { User } from '../../../models/user'; @@ -16,6 +11,12 @@ import { } from '../../../views/mail'; import { userResponse, generateToken, saveUser } from '../helpers'; +jest.mock('../../../utils/mail', () => ({ + mailerService: { + send: jest.fn().mockResolvedValue(true) + } +})); + jest.mock('../../../models/user'); jest.mock('../../../utils/mail'); jest.mock('../../../views/mail'); @@ -23,6 +24,7 @@ jest.mock('../helpers'); const mockUserBase = { email: 'test@example.com', + name: 'bob dylan', username: 'tester', preferences: { fontSize: 12, @@ -47,16 +49,20 @@ const mockUserBase = { cookieConsent: 'none', google: 'user@gmail.com', github: 'user123', - tokens: [{ kind: 'github' }, { kind: 'google' }] + tokens: [{ kind: 'github' }, { kind: 'google' }], + banned: false }; describe('user.controller unit tests', () => { let req: Request; let res: Response; + let next: () => void; beforeEach(() => { req = new Request(); res = new Response(); + next = jest.fn(); + jest.clearAllMocks(); // default mocks for helpers @@ -114,7 +120,7 @@ describe('user.controller unit tests', () => { }; req.headers.host = 'example.test'; - await controller.createUser(req as any, res as any); + await controller.createUser(req, res, next); expect(res.status).toHaveBeenCalledWith(422); expect(res.send).toHaveBeenCalledWith({ error: expect.any(String) }); @@ -144,7 +150,7 @@ describe('user.controller unit tests', () => { }; req.headers.host = 'example.test'; - await controller.createUser(req as any, res as any); + await controller.createUser(req, res, next); expect(newUser.save).toHaveBeenCalled(); expect(mailerService.send).toHaveBeenCalled();