From dcae2b73c69156437863b65514e74c3a87c18f96 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Wed, 8 Oct 2025 14:26:22 +0100 Subject: [PATCH 01/67] package.json: add @types/express --- package-lock.json | 170 ++++++++++++++++++++++++++++++++++------------ package.json | 1 + 2 files changed, 128 insertions(+), 43 deletions(-) diff --git a/package-lock.json b/package-lock.json index efcffd3beb..c9d2f21948 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@redux-devtools/dock-monitor": "^3.0.1", "@redux-devtools/log-monitor": "^4.0.2", "@reduxjs/toolkit": "^1.9.3", + "@types/express": "^5.0.3", "acorn": "^8.14.1", "acorn-walk": "^8.3.4", "async": "^3.2.3", @@ -13011,6 +13012,32 @@ "url": "https://opencollective.com/storybook" } }, + "node_modules/@storybook/addons/node_modules/@types/express": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@storybook/addons/node_modules/@types/express-serve-static-core": { + "version": "4.19.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", + "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@storybook/addons/node_modules/type-fest": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", @@ -15353,6 +15380,32 @@ "url": "https://opencollective.com/storybook" } }, + "node_modules/@storybook/types/node_modules/@types/express": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@storybook/types/node_modules/@types/express-serve-static-core": { + "version": "4.19.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", + "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-6.0.0.tgz", @@ -16170,7 +16223,6 @@ "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dev": true, "dependencies": { "@types/connect": "*", "@types/node": "*" @@ -16190,7 +16242,6 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "dependencies": { "@types/node": "*" } @@ -16264,22 +16315,21 @@ "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==" }, "node_modules/@types/express": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", - "dev": true, + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", + "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", + "license": "MIT", "dependencies": { "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", + "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "*" } }, "node_modules/@types/express-serve-static-core": { - "version": "4.19.5", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.5.tgz", - "integrity": "sha512-y6W03tvrACO72aijJ5uF02FRq5cgDR9lUxddQ8vyF+GvmjJQqbzDcJngEjURc+ZsG31VI3hODNZJ2URj86pzmg==", - "dev": true, + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", + "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", + "license": "MIT", "dependencies": { "@types/node": "*", "@types/qs": "*", @@ -16341,8 +16391,7 @@ "node_modules/@types/http-errors": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", - "dev": true + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" }, "node_modules/@types/inquirer": { "version": "7.3.3", @@ -16446,8 +16495,7 @@ "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" }, "node_modules/@types/mime-types": { "version": "2.1.4", @@ -16561,14 +16609,13 @@ "node_modules/@types/qs": { "version": "6.9.15", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz", - "integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==", - "dev": true + "integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true + "license": "MIT" }, "node_modules/@types/react": { "version": "16.14.65", @@ -16657,7 +16704,6 @@ "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "dev": true, "dependencies": { "@types/mime": "^1", "@types/node": "*" @@ -16667,7 +16713,6 @@ "version": "1.15.7", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", - "dev": true, "dependencies": { "@types/http-errors": "*", "@types/node": "*", @@ -50252,6 +50297,30 @@ "file-system-cache": "2.3.0" } }, + "@types/express": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.19.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", + "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "type-fest": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", @@ -51948,6 +52017,32 @@ "@types/babel__core": "^7.0.0", "@types/express": "^4.7.0", "file-system-cache": "2.3.0" + }, + "dependencies": { + "@types/express": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.19.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", + "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + } } }, "@svgr/babel-plugin-add-jsx-attribute": { @@ -52503,7 +52598,6 @@ "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dev": true, "requires": { "@types/connect": "*", "@types/node": "*" @@ -52522,7 +52616,6 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "requires": { "@types/node": "*" } @@ -52596,22 +52689,19 @@ "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==" }, "@types/express": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", - "dev": true, + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", + "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "requires": { "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", + "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "*" } }, "@types/express-serve-static-core": { - "version": "4.19.5", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.5.tgz", - "integrity": "sha512-y6W03tvrACO72aijJ5uF02FRq5cgDR9lUxddQ8vyF+GvmjJQqbzDcJngEjURc+ZsG31VI3hODNZJ2URj86pzmg==", - "dev": true, + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", + "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", "requires": { "@types/node": "*", "@types/qs": "*", @@ -52672,8 +52762,7 @@ "@types/http-errors": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", - "dev": true + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" }, "@types/inquirer": { "version": "7.3.3", @@ -52777,8 +52866,7 @@ "@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" }, "@types/mime-types": { "version": "2.1.4", @@ -52886,14 +52974,12 @@ "@types/qs": { "version": "6.9.15", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz", - "integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==", - "dev": true + "integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==" }, "@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" }, "@types/react": { "version": "16.14.65", @@ -52980,7 +53066,6 @@ "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "dev": true, "requires": { "@types/mime": "^1", "@types/node": "*" @@ -52990,7 +53075,6 @@ "version": "1.15.7", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", - "dev": true, "requires": { "@types/http-errors": "*", "@types/node": "*", diff --git a/package.json b/package.json index 80715f094e..57f0480adf 100644 --- a/package.json +++ b/package.json @@ -210,6 +210,7 @@ "@redux-devtools/dock-monitor": "^3.0.1", "@redux-devtools/log-monitor": "^4.0.2", "@reduxjs/toolkit": "^1.9.3", + "@types/express": "^5.0.3", "acorn": "^8.14.1", "acorn-walk": "^8.3.4", "async": "^3.2.3", From fbccc7d24409e15739dd7ee30323ec7534caa8a9 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Wed, 8 Oct 2025 14:38:00 +0100 Subject: [PATCH 02/67] server/utils/isAuthenticated: update 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 a84bf812c2a52f439b624f323056dfdd43fc889e Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Wed, 8 Oct 2025 14:42:39 +0100 Subject: [PATCH 03/67] server/utils/isAuthenticated: update to named export & add Express types and jsdoc --- 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 | 2 +- server/utils/isAuthenticated.ts | 9 ++++++++- 6 files changed, 13 insertions(+), 6 deletions(-) diff --git a/server/routes/aws.routes.ts b/server/routes/aws.routes.ts index 91a5751866..98a339a10c 100644 --- a/server/routes/aws.routes.ts +++ b/server/routes/aws.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import * as AWSController from '../controllers/aws.controller'; -import isAuthenticated from '../utils/isAuthenticated'; +import { isAuthenticated } from '../utils/isAuthenticated'; const router = Router(); diff --git a/server/routes/collection.routes.ts b/server/routes/collection.routes.ts index 4ec02961b1..2b25e042c7 100644 --- a/server/routes/collection.routes.ts +++ b/server/routes/collection.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import * as CollectionController from '../controllers/collection.controller'; -import isAuthenticated from '../utils/isAuthenticated'; +import { isAuthenticated } from '../utils/isAuthenticated'; const router = Router(); diff --git a/server/routes/file.routes.ts b/server/routes/file.routes.ts index c0bc434917..7498fbae24 100644 --- a/server/routes/file.routes.ts +++ b/server/routes/file.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import * as FileController from '../controllers/file.controller'; -import isAuthenticated from '../utils/isAuthenticated'; +import { isAuthenticated } from '../utils/isAuthenticated'; const router = Router(); diff --git a/server/routes/project.routes.ts b/server/routes/project.routes.ts index 26f6ee9501..826752e73f 100644 --- a/server/routes/project.routes.ts +++ b/server/routes/project.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import * as ProjectController from '../controllers/project.controller'; -import isAuthenticated from '../utils/isAuthenticated'; +import { isAuthenticated } from '../utils/isAuthenticated'; const router = Router(); diff --git a/server/routes/user.routes.ts b/server/routes/user.routes.ts index 532ade8923..5872a150e8 100644 --- a/server/routes/user.routes.ts +++ b/server/routes/user.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import * as UserController from '../controllers/user.controller'; -import isAuthenticated from '../utils/isAuthenticated'; +import { isAuthenticated } from '../utils/isAuthenticated'; const router = Router(); diff --git a/server/utils/isAuthenticated.ts b/server/utils/isAuthenticated.ts index 865075d864..a9b52ddec1 100644 --- a/server/utils/isAuthenticated.ts +++ b/server/utils/isAuthenticated.ts @@ -1,4 +1,11 @@ -export default function isAuthenticated(req, res, next) { +import { Request, Response, NextFunction } from 'express'; + +/** Middleware function to check if a request is authenticated prior to passing onto routes requiring user to be logged in */ +export function isAuthenticated( + req: Request, + res: Response, + next: NextFunction +) { if (req.user) { next(); return; From 7a61ed35d4b641db42b1678d4251b32b25fe849a Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Wed, 8 Oct 2025 14:48:05 +0100 Subject: [PATCH 04/67] server/routes/user.routes: organise routes 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 5872a150e8..74aa2a510f 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 d8435a46c454e49211d0c2b0fdf59dd065e5e9d6 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Wed, 8 Oct 2025 15:05:11 +0100 Subject: [PATCH 05/67] isAuthenticated: add test for middleware --- .../utils/__tests__/isAuthenticated.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 server/utils/__tests__/isAuthenticated.test.ts diff --git a/server/utils/__tests__/isAuthenticated.test.ts b/server/utils/__tests__/isAuthenticated.test.ts new file mode 100644 index 0000000000..b5f12d6b6f --- /dev/null +++ b/server/utils/__tests__/isAuthenticated.test.ts @@ -0,0 +1,31 @@ +import { Request, Response, NextFunction } from 'express'; +import { isAuthenticated } from '../isAuthenticated'; + +describe('isAuthenticated middleware', () => { + it('should call next() if user property is present', () => { + const req = ({ user: 'any_user' } as unknown) as Request; + const res = {} as Response; + const next = jest.fn() as NextFunction; + + isAuthenticated(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + it('should return 403 if user is missing', () => { + const req = { headers: {} } as Request; + const res = ({ + status: jest.fn().mockReturnThis(), + send: jest.fn() + } as unknown) as Response; + const next = jest.fn() as NextFunction; + + isAuthenticated(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.send).toHaveBeenCalledWith({ + success: false, + message: 'You must be logged in in order to perform the requested action.' + }); + }); +}); From f62e9c489ea76be69771c92086a850c12493704f Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Wed, 8 Oct 2025 15:10:15 +0100 Subject: [PATCH 06/67] move isAuthenticated to /server/middleware folder --- server/{utils => middleware}/__tests__/isAuthenticated.test.ts | 0 server/{utils => middleware}/isAuthenticated.ts | 0 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 | 2 +- 7 files changed, 5 insertions(+), 5 deletions(-) rename server/{utils => middleware}/__tests__/isAuthenticated.test.ts (100%) rename server/{utils => middleware}/isAuthenticated.ts (100%) diff --git a/server/utils/__tests__/isAuthenticated.test.ts b/server/middleware/__tests__/isAuthenticated.test.ts similarity index 100% rename from server/utils/__tests__/isAuthenticated.test.ts rename to server/middleware/__tests__/isAuthenticated.test.ts diff --git a/server/utils/isAuthenticated.ts b/server/middleware/isAuthenticated.ts similarity index 100% rename from server/utils/isAuthenticated.ts rename to server/middleware/isAuthenticated.ts diff --git a/server/routes/aws.routes.ts b/server/routes/aws.routes.ts index 98a339a10c..5469e7e6d0 100644 --- a/server/routes/aws.routes.ts +++ b/server/routes/aws.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import * as AWSController from '../controllers/aws.controller'; -import { isAuthenticated } from '../utils/isAuthenticated'; +import { isAuthenticated } from '../middleware/isAuthenticated'; const router = Router(); diff --git a/server/routes/collection.routes.ts b/server/routes/collection.routes.ts index 2b25e042c7..a764f48b2e 100644 --- a/server/routes/collection.routes.ts +++ b/server/routes/collection.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import * as CollectionController from '../controllers/collection.controller'; -import { isAuthenticated } from '../utils/isAuthenticated'; +import { isAuthenticated } from '../middleware/isAuthenticated'; const router = Router(); diff --git a/server/routes/file.routes.ts b/server/routes/file.routes.ts index 7498fbae24..36a793e7b4 100644 --- a/server/routes/file.routes.ts +++ b/server/routes/file.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import * as FileController from '../controllers/file.controller'; -import { isAuthenticated } from '../utils/isAuthenticated'; +import { isAuthenticated } from '../middleware/isAuthenticated'; const router = Router(); diff --git a/server/routes/project.routes.ts b/server/routes/project.routes.ts index 826752e73f..bf873a6b6f 100644 --- a/server/routes/project.routes.ts +++ b/server/routes/project.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import * as ProjectController from '../controllers/project.controller'; -import { isAuthenticated } from '../utils/isAuthenticated'; +import { isAuthenticated } from '../middleware/isAuthenticated'; const router = Router(); diff --git a/server/routes/user.routes.ts b/server/routes/user.routes.ts index 74aa2a510f..7daafc1321 100644 --- a/server/routes/user.routes.ts +++ b/server/routes/user.routes.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import * as UserController from '../controllers/user.controller'; -import { isAuthenticated } from '../utils/isAuthenticated'; +import { isAuthenticated } from '../middleware/isAuthenticated'; const router = Router(); From eae9ca6e19b714f64d9f8ee8bd7048a794ebfa63 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Wed, 8 Oct 2025 15:32:09 +0100 Subject: [PATCH 07/67] server/controllers/user.controller/apiKey.test: migrate to ts, no-verify --- .../user.controller/__tests__/{apiKey.test.js => apiKey.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename server/controllers/user.controller/__tests__/{apiKey.test.js => apiKey.test.ts} (100%) diff --git a/server/controllers/user.controller/__tests__/apiKey.test.js b/server/controllers/user.controller/__tests__/apiKey.test.ts similarity index 100% rename from server/controllers/user.controller/__tests__/apiKey.test.js rename to server/controllers/user.controller/__tests__/apiKey.test.ts From b19d61f709df87587a198aef8d88ee85469a43ff Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Wed, 8 Oct 2025 15:33:06 +0100 Subject: [PATCH 08/67] server/controllers/user.controller/apiKey.test: resolve type-errors --- .../user.controller/__tests__/apiKey.test.ts | 30 +++++++++++-------- server/types/user.ts | 2 +- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/server/controllers/user.controller/__tests__/apiKey.test.ts b/server/controllers/user.controller/__tests__/apiKey.test.ts index 87dc1320e8..bc4fbb919e 100644 --- a/server/controllers/user.controller/__tests__/apiKey.test.ts +++ b/server/controllers/user.controller/__tests__/apiKey.test.ts @@ -1,16 +1,17 @@ -/* @jest-environment node */ - import { last } from 'lodash'; -import { Request, Response } from 'jest-express'; +import { Request } from 'jest-express/lib/request'; +import { Response } from 'jest-express/lib/response'; +import { Types } from 'mongoose'; import { User } from '../../../models/user'; import { createApiKey, removeApiKey } from '../apiKey'; +import type { ApiKeyDocument } from '../../../types'; jest.mock('../../../models/user'); describe('user.controller', () => { - let request; - let response; + let request: Request & { user?: { id: string } }; + let response: Response; beforeEach(() => { request = new Request(); @@ -58,23 +59,23 @@ describe('user.controller', () => { request.user = { id: '1234' }; const user = new User(); - user.apiKeys = []; + user.apiKeys = ([] as unknown) as Types.DocumentArray; User.findById = jest.fn().mockResolvedValue(user); - user.save = jest.fn().mockResolvedValue(); + user.save = jest.fn(); await createApiKey(request, response); const lastKey = last(user.apiKeys); - expect(lastKey.label).toBe('my key'); - expect(typeof lastKey.hashedKey).toBe('string'); + expect(lastKey?.label).toBe('my key'); + expect(typeof lastKey?.hashedKey).toBe('string'); const responseData = response.json.mock.calls[0][0]; expect(responseData.apiKeys.length).toBe(1); expect(responseData.apiKeys[0]).toMatchObject({ label: 'my key', - token: lastKey.hashedKey + token: lastKey?.hashedKey }); }); }); @@ -98,7 +99,7 @@ describe('user.controller', () => { request.user = { id: '1234' }; request.params = { keyId: 'not-a-real-key' }; const user = new User(); - user.apiKeys = []; + user.apiKeys = ([] as unknown) as Types.DocumentArray; User.findById = jest.fn().mockResolvedValue(user); @@ -114,13 +115,16 @@ describe('user.controller', () => { const mockKey1 = { _id: 'id1', id: 'id1', label: 'first key' }; const mockKey2 = { _id: 'id2', id: 'id2', label: 'second key' }; - const apiKeys = [mockKey1, mockKey2]; + const apiKeys = ([ + mockKey1, + mockKey2 + ] as unknown) as Types.DocumentArray; apiKeys.find = Array.prototype.find; apiKeys.pull = jest.fn(); const user = { apiKeys, - save: jest.fn().mockResolvedValue() + save: jest.fn() }; request.user = { id: '1234' }; 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; From c2ea2b13a5a26abf44ac2b43a7d47e71a3a321d4 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Wed, 8 Oct 2025 17:54:04 +0100 Subject: [PATCH 09/67] server/controllers/user/apiKey: migrate 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 8848ed5422c97dad37d6560ae8884d2306040f32 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Wed, 8 Oct 2025 18:02:31 +0100 Subject: [PATCH 10/67] types/express: add AuthenticatedRequest type, no-verify --- server/middleware/isAuthenticated.ts | 3 ++- server/types/express.ts | 19 +++++++++++++++++++ server/types/index.ts | 1 + 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 server/types/express.ts diff --git a/server/middleware/isAuthenticated.ts b/server/middleware/isAuthenticated.ts index a9b52ddec1..6d1f2e70f0 100644 --- a/server/middleware/isAuthenticated.ts +++ b/server/middleware/isAuthenticated.ts @@ -1,11 +1,12 @@ import { Request, Response, NextFunction } from 'express'; +import { AuthenticatedRequest } from '../types'; /** Middleware function to check if a request is authenticated prior to passing onto routes requiring user to be logged in */ export function isAuthenticated( req: Request, res: Response, next: NextFunction -) { +): asserts req is AuthenticatedRequest { if (req.user) { next(); return; diff --git a/server/types/express.ts b/server/types/express.ts new file mode 100644 index 0000000000..9989be9126 --- /dev/null +++ b/server/types/express.ts @@ -0,0 +1,19 @@ +import { Request } from 'express'; +import { User } from './user'; + +// workaround for express.d.ts not working as expected +/** Express Request with an user property. Used for routes that require authentication. */ +export interface AuthenticatedRequest extends Request { + user: User; +} + +/** Simple error object for express requests */ +export interface Error { + error: string | unknown; +} + +/** Simple response object for express requests with success status and optional message */ +export interface GenericResponseBody { + success: boolean; + message?: string; +} diff --git a/server/types/index.ts b/server/types/index.ts index 6efd8a00fb..8511e3c860 100644 --- a/server/types/index.ts +++ b/server/types/index.ts @@ -1,5 +1,6 @@ export * from './apiKey'; export * from './email'; +export * from './express'; export * from './mongoose'; export * from './user'; export * from './userPreferences'; From 548e3452b76de6760bfc3402ccd5c39ee76dfacd Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Wed, 8 Oct 2025 19:45:47 +0100 Subject: [PATCH 11/67] correctly extend the Express User interface with custom properties --- server/tsconfig.json | 3 ++- server/types/express.ts | 3 +-- server/types/express/index.d.ts | 10 ++++++++++ 3 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 server/types/express/index.d.ts diff --git a/server/tsconfig.json b/server/tsconfig.json index e40550e1eb..50811e441c 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -4,7 +4,8 @@ "target": "ES2022", "module": "commonjs", "lib": ["ES2022"], - "types": ["node", "jest"] + "types": ["node", "jest", "express"], + "typeRoots": ["./types", "../node_modules/@types"] }, "strictNullChecks": true, "include": ["./**/*"], diff --git a/server/types/express.ts b/server/types/express.ts index 9989be9126..0835e77f30 100644 --- a/server/types/express.ts +++ b/server/types/express.ts @@ -1,8 +1,7 @@ import { Request } from 'express'; import { User } from './user'; -// workaround for express.d.ts not working as expected -/** Express Request with an user property. Used for routes that require authentication. */ +/** Authenticated express request for routes that require auth. Has a user property */ export interface AuthenticatedRequest extends Request { user: User; } diff --git a/server/types/express/index.d.ts b/server/types/express/index.d.ts new file mode 100644 index 0000000000..3f2c5d818e --- /dev/null +++ b/server/types/express/index.d.ts @@ -0,0 +1,10 @@ +import type { User as CustomUser } from '../user'; + +// to make the file a module and avoid the TypeScript error +export {}; + +declare global { + namespace Express { + export interface User extends CustomUser {} + } +} From 6ab4b3dd9753095d7b024ccb8b4b34a34f5336ee Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Wed, 8 Oct 2025 20:24:44 +0100 Subject: [PATCH 12/67] server/controllers/user.controller: finish typing request and response, and resolve type errors --- .../user.controller/__tests__/apiKey.test.ts | 55 ++++++++++++----- server/controllers/user.controller/apiKey.ts | 59 ++++++++++++++----- 2 files changed, 85 insertions(+), 29 deletions(-) diff --git a/server/controllers/user.controller/__tests__/apiKey.test.ts b/server/controllers/user.controller/__tests__/apiKey.test.ts index bc4fbb919e..80cfdd94c2 100644 --- a/server/controllers/user.controller/__tests__/apiKey.test.ts +++ b/server/controllers/user.controller/__tests__/apiKey.test.ts @@ -1,21 +1,26 @@ import { last } from 'lodash'; -import { Request } from 'jest-express/lib/request'; -import { Response } from 'jest-express/lib/response'; +import { Request as MockRequest } from 'jest-express/lib/request'; +import { Response as MockResponse } from 'jest-express/lib/response'; +import { NextFunction as MockNext } from 'jest-express/lib/next'; +import { Request, Response, NextFunction } from 'express'; import { Types } from 'mongoose'; import { User } from '../../../models/user'; import { createApiKey, removeApiKey } from '../apiKey'; import type { ApiKeyDocument } from '../../../types'; +import type { RemoveApiKeyRequestParams } from '../apiKey'; jest.mock('../../../models/user'); describe('user.controller', () => { - let request: Request & { user?: { id: string } }; - let response: Response; + let request: MockRequest & { user?: { id: string } }; + let response: MockResponse; + let next: MockNext; beforeEach(() => { - request = new Request(); - response = new Response(); + request = new MockRequest(); + response = new MockResponse(); + next = jest.fn(); }); afterEach(() => { @@ -27,11 +32,14 @@ describe('user.controller', () => { describe('createApiKey', () => { it("returns an error if user doesn't exist", async () => { request.user = { id: '1234' }; - response = new Response(); User.findById = jest.fn().mockResolvedValue(null); - await createApiKey(request, response); + await createApiKey( + (request as unknown) as Request, + (response as unknown) as Response, + (next as unknown) as NextFunction + ); expect(response.status).toHaveBeenCalledWith(404); expect(response.json).toHaveBeenCalledWith({ @@ -46,7 +54,11 @@ describe('user.controller', () => { const user = new User(); User.findById = jest.fn().mockResolvedValue(user); - await createApiKey(request, response); + await createApiKey( + (request as unknown) as Request, + (response as unknown) as Response, + (next as unknown) as NextFunction + ); expect(response.status).toHaveBeenCalledWith(400); expect(response.json).toHaveBeenCalledWith({ @@ -64,7 +76,11 @@ describe('user.controller', () => { User.findById = jest.fn().mockResolvedValue(user); user.save = jest.fn(); - await createApiKey(request, response); + await createApiKey( + (request as unknown) as Request, + (response as unknown) as Response, + (next as unknown) as NextFunction + ); const lastKey = last(user.apiKeys); @@ -83,11 +99,14 @@ describe('user.controller', () => { describe('removeApiKey', () => { it("returns an error if user doesn't exist", async () => { request.user = { id: '1234' }; - response = new Response(); User.findById = jest.fn().mockResolvedValue(null); - await removeApiKey(request, response); + await removeApiKey( + (request as unknown) as Request, + (response as unknown) as Response, + (next as unknown) as NextFunction + ); expect(response.status).toHaveBeenCalledWith(404); expect(response.json).toHaveBeenCalledWith({ @@ -103,7 +122,11 @@ describe('user.controller', () => { User.findById = jest.fn().mockResolvedValue(user); - await removeApiKey(request, response); + await removeApiKey( + (request as unknown) as Request, + (response as unknown) as Response, + (next as unknown) as NextFunction + ); expect(response.status).toHaveBeenCalledWith(404); expect(response.json).toHaveBeenCalledWith({ @@ -132,7 +155,11 @@ describe('user.controller', () => { User.findById = jest.fn().mockResolvedValue(user); - await removeApiKey(request, response); + await removeApiKey( + (request as unknown) as Request, + (response as unknown) as Response, + (next as unknown) as NextFunction + ); expect(user.apiKeys.pull).toHaveBeenCalledWith({ _id: 'id1' }); expect(user.save).toHaveBeenCalled(); diff --git a/server/controllers/user.controller/apiKey.ts b/server/controllers/user.controller/apiKey.ts index d614a27324..ec20340e78 100644 --- a/server/controllers/user.controller/apiKey.ts +++ b/server/controllers/user.controller/apiKey.ts @@ -1,12 +1,24 @@ import crypto from 'crypto'; - +import { RequestHandler } from 'express'; +import * as core from 'express-serve-static-core'; import { User } from '../../models/user'; +import type { ApiKeyDocument, Error } from '../../types'; + +export interface ApiKeyResponseBody { + apiKeys: ApiKeyDocument[]; +} +export interface CreateApiKeyRequestBody { + label: string; +} +export interface RemoveApiKeyRequestParams extends core.ParamsDictionary { + keyId: string; +} /** * Generates a unique token to be used as a Personal Access Token * @returns Promise A promise that resolves to the token, or an Error */ -function generateApiKey() { +function generateApiKey(): Promise { return new Promise((resolve, reject) => { crypto.randomBytes(20, (err, buf) => { if (err) { @@ -18,13 +30,18 @@ function generateApiKey() { }); } -export async function createApiKey(req, res) { - function sendFailure(code, error) { +/** POST /account/api-keys, UserController.createApiKey */ +export const createApiKey: RequestHandler< + {}, + ApiKeyResponseBody | Error, + CreateApiKeyRequestBody +> = async (req, res) => { + function sendFailure(code: number, error: string) { res.status(code).json({ error }); } try { - const user = await User.findById(req.user.id); + const user = await User.findById(req.user!.id); if (!user) { sendFailure(404, 'User not found'); @@ -49,7 +66,7 @@ export async function createApiKey(req, res) { await user.save(); const apiKeys = user.apiKeys.map((apiKey, index) => { - const fields = apiKey.toObject(); + const fields = apiKey.toObject!(); const shouldIncludeToken = index === addedApiKeyIndex - 1; return shouldIncludeToken ? { ...fields, token: keyToBeHashed } : fields; @@ -57,17 +74,25 @@ export async function createApiKey(req, res) { res.json({ apiKeys }); } catch (err) { - sendFailure(500, err.message || 'Internal server error'); + if (err instanceof Error) { + res.status(500).json({ error: err.message }); + } else { + res.status(500).json({ error: 'Internal server error' }); + } } -} - -export async function removeApiKey(req, res) { - function sendFailure(code, error) { +}; + +/** DELETE /account/api-keys/:keyId, UserController.removeApiKey */ +export const removeApiKey: RequestHandler< + RemoveApiKeyRequestParams, + ApiKeyResponseBody | Error +> = async (req, res) => { + function sendFailure(code: number, error: string) { res.status(code).json({ error }); } try { - const user = await User.findById(req.user.id); + const user = await User.findById(req.user!.id); if (!user) { sendFailure(404, 'User not found'); @@ -85,7 +110,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' }); + } } -} +}; From 3f012dda3d1c78432fc62d9a8fa364cac2725d14 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Wed, 8 Oct 2025 21:34:56 +0100 Subject: [PATCH 13/67] server/controllers/user.controller: Add tests for signup routes --- .../user.controller/__tests__/signup.test.ts | 200 ++++++++++++++++++ 1 file changed, 200 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..83942b6e97 --- /dev/null +++ b/server/controllers/user.controller/__tests__/signup.test.ts @@ -0,0 +1,200 @@ +import { Request as MockRequest } from 'jest-express/lib/request'; +import { Response as MockResponse } from 'jest-express/lib/response'; +import { NextFunction as MockNext } from 'jest-express/lib/next'; +import { User } from '../../../models/user'; +import { + createUser, + duplicateUserCheck, + verifyEmail +} from '../../user.controller'; + +import { mailerService } from '../../../utils/mail'; +import { renderEmailConfirmation } from '../../../views/mail'; + +jest.mock('../../../models/user'); +jest.mock('../../../utils/mail', () => ({ + mailerService: { + send: jest.fn() + } +})); +jest.mock('../../../views/mail', () => ({ + renderEmailConfirmation: jest + .fn() + .mockReturnValue({ to: 'test@example.com', subject: 'Confirm' }) +})); + +describe('user.controller', () => { + let request: any; + let response: any; + let next: MockNext; + + beforeEach(() => { + request = new MockRequest(); + response = new MockResponse(); + next = jest.fn(); + }); + + afterEach(() => { + request.resetMocked(); + response.resetMocked(); + jest.clearAllMocks(); + }); + + describe('createUser', () => { + it('should return 422 if email already exists', async () => { + User.findByEmailAndUsername = jest.fn().mockResolvedValue({ + email: 'existing@example.com', + username: 'anyusername' + }); + + request.setBody({ + username: 'testuser', + email: 'existing@example.com', + password: 'password' + }); + + await createUser(request, response); + + expect(User.findByEmailAndUsername).toHaveBeenCalledWith( + 'existing@example.com', + 'testuser' + ); + expect(response.status).toHaveBeenCalledWith(422); + expect(response.send).toHaveBeenCalledWith({ error: 'Email is in use' }); + }); + it('should return 422 if username already exists', async () => { + User.findByEmailAndUsername = jest.fn().mockResolvedValue({ + email: 'anyemail@example.com', + username: 'testuser' + }); + + request.setBody({ + username: 'testuser', + email: 'existing@example.com', + password: 'password' + }); + + await createUser(request, response); + + expect(User.findByEmailAndUsername).toHaveBeenCalledWith( + 'existing@example.com', + 'testuser' + ); + expect(response.status).toHaveBeenCalledWith(422); + expect(response.send).toHaveBeenCalledWith({ + error: 'Username is in use' + }); + }); + }); + + describe('duplicateUserCheck', () => { + it('calls findByEmailOrUsername with the correct params', async () => { + const mockFind = jest.fn().mockResolvedValue(null); + User.findByEmailOrUsername = mockFind; + + request.query = { check_type: 'email', email: 'test@example.com' }; + + await duplicateUserCheck(request, response); + + expect(mockFind).toHaveBeenCalledWith('test@example.com', { + caseInsensitive: true, + valueType: 'email' + }); + }); + + it('returns the correct response body when no matching user is found', async () => { + User.findByEmailOrUsername = jest.fn().mockResolvedValue(null); + + request.query = { check_type: 'username', username: 'newuser' }; + + await duplicateUserCheck(request, response); + + expect(response.json).toHaveBeenCalledWith({ + exists: false, + type: 'username' + }); + }); + + it('returns the correct response body when the username already exists', async () => { + User.findByEmailOrUsername = jest.fn().mockResolvedValue({ + username: 'existinguser' + }); + + request.query = { check_type: 'username', username: 'existinguser' }; + + await duplicateUserCheck(request, response); + + expect(response.json).toHaveBeenCalledWith({ + exists: true, + message: 'This username is already taken.', + type: 'username' + }); + }); + + it('returns the correct response body when the email already exists', async () => { + User.findByEmailOrUsername = jest.fn().mockResolvedValue({ + email: 'existing@example.com' + }); + + request.query = { check_type: 'email', email: 'existing@example.com' }; + + await duplicateUserCheck(request, response); + + expect(response.json).toHaveBeenCalledWith({ + exists: true, + message: 'This email is already taken.', + type: 'email' + }); + }); + }); + + describe('verifyEmail', () => { + it('returns 401 if token is invalid or expired', async () => { + User.findOne = jest.fn().mockReturnValue({ + exec: jest.fn().mockResolvedValue(null) + }); + + request.query = { t: 'invalidtoken' }; + + await verifyEmail(request, response); + + expect(User.findOne).toHaveBeenCalledWith({ + verifiedToken: 'invalidtoken', + verifiedTokenExpires: { $gt: expect.any(Date) } + }); + expect(response.status).toHaveBeenCalledWith(401); + expect(response.json).toHaveBeenCalledWith({ + success: false, + message: 'Token is invalid or has expired.' + }); + }); + + it('verifies the user and returns success if token is valid', async () => { + const saveMock = jest.fn().mockResolvedValue({}); + const mockUser = { + save: saveMock, + verified: 'verified', + verifiedToken: 'validtoken', + verifiedTokenExpires: new Date(Date.now() + 10000) + }; + + User.EmailConfirmation = jest.fn().mockReturnValue({ + Verified: 'verified' + }); + + User.findOne = jest.fn().mockReturnValue({ + exec: jest.fn().mockResolvedValue(mockUser) + }); + + request.query = { t: 'validtoken' }; + + await verifyEmail(request, response); + + expect(mockUser.verified).toBe('verified'); + expect(mockUser.verifiedToken).toBeNull(); + expect(mockUser.verifiedTokenExpires).toBeNull(); + expect(saveMock).toHaveBeenCalled(); + expect(response.json).toHaveBeenCalledWith({ success: true }); + }); + }); +}); From f4997b6ce73e1af0da76de37aa6943f456008348 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Wed, 8 Oct 2025 22:50:36 +0100 Subject: [PATCH 14/67] user controller: migrate relevant routes to /signup --- server/controllers/user.controller.js | 98 +------------------ server/controllers/user.controller/signup.js | 99 ++++++++++++++++++++ 2 files changed, 101 insertions(+), 96 deletions(-) create mode 100644 server/controllers/user.controller/signup.js diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.js index 96f9401075..54e60d260f 100644 --- a/server/controllers/user.controller.js +++ b/server/controllers/user.controller.js @@ -5,6 +5,7 @@ import { mailerService } from '../utils/mail'; import { renderEmailConfirmation, renderResetPassword } from '../views/mail'; export * from './user.controller/apiKey'; +export * from './user.controller/signup'; export function userResponse(user) { return { @@ -26,7 +27,7 @@ export function userResponse(user) { * Note: can be done synchronously - https://nodejs.org/api/crypto.html#cryptorandombytessize-callback * @return Promise */ -async function generateToken() { +export async function generateToken() { return new Promise((resolve, reject) => { crypto.randomBytes(20, (err, buf) => { if (err) { @@ -39,81 +40,6 @@ async function generateToken() { }); } -export async function createUser(req, res) { - try { - const { username, email, password } = req.body; - const emailLowerCase = email.toLowerCase(); - const existingUser = await User.findByEmailAndUsername(email, username); - if (existingUser) { - const fieldInUse = - existingUser.email.toLowerCase() === emailLowerCase - ? 'Email' - : 'Username'; - res.status(422).send({ error: `${fieldInUse} is in use` }); - return; - } - - const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours - const token = await generateToken(); - const user = new User({ - username, - email: emailLowerCase, - password, - verified: User.EmailConfirmation().Sent, - verifiedToken: token, - verifiedTokenExpires: EMAIL_VERIFY_TOKEN_EXPIRY_TIME - }); - - await user.save(); - - req.logIn(user, async (loginErr) => { - if (loginErr) { - console.error(loginErr); - res.status(500).json({ error: 'Failed to log in user.' }); - return; - } - - const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; - const mailOptions = renderEmailConfirmation({ - body: { - domain: `${protocol}://${req.headers.host}`, - link: `${protocol}://${req.headers.host}/verify?t=${token}` - }, - to: req.user.email - }); - - try { - await mailerService.send(mailOptions); - res.json(userResponse(user)); - } catch (mailErr) { - console.error(mailErr); - res.status(500).json({ error: 'Failed to send verification email.' }); - } - }); - } catch (err) { - console.error(err); - res.status(500).json({ error: err }); - } -} - -export async function duplicateUserCheck(req, res) { - const checkType = req.query.check_type; - const value = req.query[checkType]; - const options = { caseInsensitive: true, valueType: checkType }; - const user = await User.findByEmailOrUsername(value, options); - if (user) { - return res.json({ - exists: true, - message: `This ${checkType} is already taken.`, - type: checkType - }); - } - return res.json({ - exists: false, - type: checkType - }); -} - export async function updatePreferences(req, res) { try { const user = await User.findById(req.user.id).exec(); @@ -220,26 +146,6 @@ export async function emailVerificationInitiate(req, res) { } } -export async function verifyEmail(req, res) { - const token = req.query.t; - const user = await User.findOne({ - verifiedToken: token, - verifiedTokenExpires: { $gt: new Date() } - }).exec(); - if (!user) { - res.status(401).json({ - success: false, - message: 'Token is invalid or has expired.' - }); - return; - } - user.verified = User.EmailConfirmation().Verified; - user.verifiedToken = null; - user.verifiedTokenExpires = null; - await user.save(); - res.json({ success: true }); -} - export async function updatePassword(req, res) { const user = await User.findOne({ resetPasswordToken: req.params.token, diff --git a/server/controllers/user.controller/signup.js b/server/controllers/user.controller/signup.js new file mode 100644 index 0000000000..f3338cd5f2 --- /dev/null +++ b/server/controllers/user.controller/signup.js @@ -0,0 +1,99 @@ +import { User } from '../../models/user'; +import { generateToken, userResponse } from '../user.controller'; +import { renderEmailConfirmation } from '../../views/mail'; +import { mailerService } from '../../utils/mail'; + +export async function createUser(req, res) { + try { + const { username, email, password } = req.body; + const emailLowerCase = email.toLowerCase(); + const existingUser = await User.findByEmailAndUsername(email, username); + if (existingUser) { + const fieldInUse = + existingUser.email.toLowerCase() === emailLowerCase + ? 'Email' + : 'Username'; + res.status(422).send({ error: `${fieldInUse} is in use` }); + return; + } + + const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours + const token = await generateToken(); + const user = new User({ + username, + email: emailLowerCase, + password, + verified: User.EmailConfirmation().Sent, + verifiedToken: token, + verifiedTokenExpires: EMAIL_VERIFY_TOKEN_EXPIRY_TIME + }); + + await user.save(); + + req.logIn(user, async (loginErr) => { + if (loginErr) { + console.error(loginErr); + res.status(500).json({ error: 'Failed to log in user.' }); + return; + } + + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + const mailOptions = renderEmailConfirmation({ + body: { + domain: `${protocol}://${req.headers.host}`, + link: `${protocol}://${req.headers.host}/verify?t=${token}` + }, + to: req.user.email + }); + + try { + await mailerService.send(mailOptions); + res.json(userResponse(user)); + } catch (mailErr) { + console.error(mailErr); + res.status(500).json({ error: 'Failed to send verification email.' }); + } + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: err }); + } +} + +export async function duplicateUserCheck(req, res) { + const checkType = req.query.check_type; + const value = req.query[checkType]; + const options = { caseInsensitive: true, valueType: checkType }; + const user = await User.findByEmailOrUsername(value, options); + if (user) { + return res.json({ + exists: true, + message: `This ${checkType} is already taken.`, + type: checkType + }); + } + return res.json({ + exists: false, + type: checkType + }); +} + +export async function verifyEmail(req, res) { + const token = req.query.t; + const user = await User.findOne({ + verifiedToken: token, + verifiedTokenExpires: { $gt: new Date() } + }).exec(); + if (!user) { + res.status(401).json({ + success: false, + message: 'Token is invalid or has expired.' + }); + return; + } + user.verified = User.EmailConfirmation().Verified; + user.verifiedToken = null; + user.verifiedTokenExpires = null; + await user.save(); + res.json({ success: true }); +} From 5c2e5d969580c63011808e105d67844f75e8ffec Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Wed, 8 Oct 2025 22:58:29 +0100 Subject: [PATCH 15/67] create user.controller/helpers.js --- server/controllers/user.controller.js | 37 +------------------ server/controllers/user.controller/helpers.js | 34 +++++++++++++++++ server/controllers/user.controller/signup.js | 2 +- 3 files changed, 37 insertions(+), 36 deletions(-) create mode 100644 server/controllers/user.controller/helpers.js diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.js index 54e60d260f..810fe1cd0e 100644 --- a/server/controllers/user.controller.js +++ b/server/controllers/user.controller.js @@ -1,45 +1,12 @@ -import crypto from 'crypto'; - import { User } from '../models/user'; import { mailerService } from '../utils/mail'; import { renderEmailConfirmation, renderResetPassword } from '../views/mail'; +import { userResponse, generateToken } from './user.controller/helpers'; + export * from './user.controller/apiKey'; export * from './user.controller/signup'; -export function userResponse(user) { - return { - email: user.email, - username: user.username, - preferences: user.preferences, - apiKeys: user.apiKeys, - verified: user.verified, - id: user._id, - totalSize: user.totalSize, - github: user.github, - google: user.google, - cookieConsent: user.cookieConsent - }; -} - -/** - * Create a new verification token. - * Note: can be done synchronously - https://nodejs.org/api/crypto.html#cryptorandombytessize-callback - * @return Promise - */ -export async function generateToken() { - return new Promise((resolve, reject) => { - crypto.randomBytes(20, (err, buf) => { - if (err) { - reject(err); - } else { - const token = buf.toString('hex'); - resolve(token); - } - }); - }); -} - export async function updatePreferences(req, res) { try { const user = await User.findById(req.user.id).exec(); diff --git a/server/controllers/user.controller/helpers.js b/server/controllers/user.controller/helpers.js new file mode 100644 index 0000000000..fd01c8cfb8 --- /dev/null +++ b/server/controllers/user.controller/helpers.js @@ -0,0 +1,34 @@ +import crypto from 'crypto'; + +export function userResponse(user) { + return { + email: user.email, + username: user.username, + preferences: user.preferences, + apiKeys: user.apiKeys, + verified: user.verified, + id: user._id, + totalSize: user.totalSize, + github: user.github, + google: user.google, + cookieConsent: user.cookieConsent + }; +} + +/** + * Create a new verification token. + * Note: can be done synchronously - https://nodejs.org/api/crypto.html#cryptorandombytessize-callback + * @return Promise + */ +export async function generateToken() { + return new Promise((resolve, reject) => { + crypto.randomBytes(20, (err, buf) => { + if (err) { + reject(err); + } else { + const token = buf.toString('hex'); + resolve(token); + } + }); + }); +} diff --git a/server/controllers/user.controller/signup.js b/server/controllers/user.controller/signup.js index f3338cd5f2..78669fd7c3 100644 --- a/server/controllers/user.controller/signup.js +++ b/server/controllers/user.controller/signup.js @@ -1,5 +1,5 @@ import { User } from '../../models/user'; -import { generateToken, userResponse } from '../user.controller'; +import { generateToken, userResponse } from './helpers'; import { renderEmailConfirmation } from '../../views/mail'; import { mailerService } from '../../utils/mail'; From d293ee76cd12b1e08b863550f34327b4fa24552f Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Wed, 8 Oct 2025 23:19:38 +0100 Subject: [PATCH 16/67] controllers/user/helper: add tests and update to ts --- .../user.controller/__tests__/helpers.ts | 83 +++++++++++++++++++ .../{helpers.js => helpers.ts} | 12 ++- server/types/user.ts | 2 +- 3 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 server/controllers/user.controller/__tests__/helpers.ts rename server/controllers/user.controller/{helpers.js => helpers.ts} (75%) diff --git a/server/controllers/user.controller/__tests__/helpers.ts b/server/controllers/user.controller/__tests__/helpers.ts new file mode 100644 index 0000000000..a8fe3beee2 --- /dev/null +++ b/server/controllers/user.controller/__tests__/helpers.ts @@ -0,0 +1,83 @@ +/* eslint-disable no-unused-vars */ +import crypto from 'crypto'; + +import { Types } from 'mongoose'; +import { userResponse, generateToken } from '../helpers'; +import { CookieConsentOptions, AppThemeOptions } from '../../../types'; +import { ApiKeyDocument } 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: ([] as unknown) as Types.DocumentArray, + verified: 'verified', + id: 'abc123', + totalSize: 42, + cookieConsent: CookieConsentOptions.NONE, + google: 'user@gmail.com', + github: 'user123', + + // to be removed: + name: 'test user', + tokens: [], + password: 'abweorij', + resetPasswordToken: '1i14ij23', + banned: false +}; + +describe('user.helpers', () => { + describe('userResponse', () => { + it('returns a sanitized PublicUser object', () => { + const result = userResponse(mockFullUser); + + const { + password, + resetPasswordToken, + banned, + ...sanitised + } = mockFullUser; + + expect(result).toMatchObject(sanitised); + }); + }); + + describe('generateToken', () => { + it('generates a random hex string of length 40', async () => { + const token = await generateToken(); + expect(typeof token).toBe('string'); + expect(token).toMatch(/^[a-f0-9]+$/); + expect(token).toHaveLength(40); + }); + + it('rejects if crypto.randomBytes errors', async () => { + const spy = jest + .spyOn(crypto, 'randomBytes') + .mockImplementationOnce((_size, cb) => { + cb(new Error('fail'), Buffer.alloc(0)); + return {}; + }); + + await expect(generateToken()).rejects.toThrow('fail'); + + spy.mockRestore(); + }); + }); +}); diff --git a/server/controllers/user.controller/helpers.js b/server/controllers/user.controller/helpers.ts similarity index 75% rename from server/controllers/user.controller/helpers.js rename to server/controllers/user.controller/helpers.ts index fd01c8cfb8..7491a8d0eb 100644 --- a/server/controllers/user.controller/helpers.js +++ b/server/controllers/user.controller/helpers.ts @@ -1,13 +1,21 @@ import crypto from 'crypto'; +import { PublicUser } from '../../types'; -export function userResponse(user) { +/** + * Sanitise user objects to remove sensitive fields + * @param user + * @returns Sanitised user + */ +export function userResponse( + user: PublicUser & Record +): PublicUser { return { email: user.email, username: user.username, preferences: user.preferences, apiKeys: user.apiKeys, verified: user.verified, - id: user._id, + id: user.id, totalSize: user.totalSize, github: user.github, google: user.google, 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 f8a7891373a353376d96460c4d6d2095099390d0 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Wed, 8 Oct 2025 23:24:32 +0100 Subject: [PATCH 17/67] migrate emailVerificationInitiate to user.controller/signup & add tests --- server/controllers/user.controller.js | 38 ------ .../user.controller/__tests__/signup.test.ts | 111 +++++++++++++++++- server/controllers/user.controller/signup.js | 38 ++++++ 3 files changed, 148 insertions(+), 39 deletions(-) diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.js index 810fe1cd0e..9989e5ba03 100644 --- a/server/controllers/user.controller.js +++ b/server/controllers/user.controller.js @@ -75,44 +75,6 @@ export async function validateResetPasswordToken(req, res) { res.json({ success: true }); } -export async function emailVerificationInitiate(req, res) { - try { - const token = await generateToken(); - const user = await User.findById(req.user.id).exec(); - if (!user) { - res.status(404).json({ error: 'User not found' }); - return; - } - if (user.verified === User.EmailConfirmation().Verified) { - res.status(409).json({ error: 'Email already verified' }); - return; - } - const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; - const mailOptions = renderEmailConfirmation({ - body: { - domain: `${protocol}://${req.headers.host}`, - link: `${protocol}://${req.headers.host}/verify?t=${token}` - }, - to: user.email - }); - try { - await mailerService.send(mailOptions); - } catch (mailErr) { - res.status(500).send({ error: 'Error sending mail' }); - return; - } - const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours - user.verified = User.EmailConfirmation().Resent; - user.verifiedToken = token; - user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; // 24 hours - await user.save(); - - res.json(userResponse(req.user)); - } catch (err) { - res.status(500).json({ error: err }); - } -} - export async function updatePassword(req, res) { const user = await User.findOne({ resetPasswordToken: req.params.token, diff --git a/server/controllers/user.controller/__tests__/signup.test.ts b/server/controllers/user.controller/__tests__/signup.test.ts index 83942b6e97..d7d8983a05 100644 --- a/server/controllers/user.controller/__tests__/signup.test.ts +++ b/server/controllers/user.controller/__tests__/signup.test.ts @@ -5,7 +5,8 @@ import { User } from '../../../models/user'; import { createUser, duplicateUserCheck, - verifyEmail + verifyEmail, + emailVerificationInitiate } from '../../user.controller'; import { mailerService } from '../../../utils/mail'; @@ -197,4 +198,112 @@ describe('user.controller', () => { expect(response.json).toHaveBeenCalledWith({ success: true }); }); }); + + describe('emailVerificationInitiate', () => { + it('returns 404 if user is not found', async () => { + User.findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); + + request.user = { id: 'nonexistentid' }; + request.headers.host = 'localhost:3000'; + + await emailVerificationInitiate(request, response); + + expect(User.findById).toHaveBeenCalledWith('nonexistentid'); + expect(response.status).toHaveBeenCalledWith(404); + expect(response.json).toHaveBeenCalledWith({ error: 'User not found' }); + }); + + it('returns 409 if user is already verified', async () => { + User.EmailConfirmation = jest.fn().mockReturnValue({ + Verified: 'verified' + }); + + const mockUser = { + verified: 'verified' + }; + User.findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); + + request.user = { id: 'user1' }; + request.headers.host = 'localhost:3000'; + + await emailVerificationInitiate(request, response); + + expect(response.status).toHaveBeenCalledWith(409); + expect(response.json).toHaveBeenCalledWith({ + error: 'Email already verified' + }); + }); + + it('sends a new verification email and updates the user', async () => { + User.EmailConfirmation = jest.fn().mockReturnValue({ + Resent: 'resent' + }); + + const saveMock = jest.fn().mockResolvedValue({}); + const mockUser = { + verified: 'sent', + verifiedToken: null, + verifiedTokenExpires: null, + email: 'test@example.com', + save: saveMock + }; + + User.findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); + (mailerService.send as jest.Mock).mockResolvedValue(true); + + request.user = { id: 'user1' }; + request.headers.host = 'localhost:3000'; + + await emailVerificationInitiate(request, response); + + expect(User.findById).toHaveBeenCalledWith('user1'); + expect(mailerService.send).toHaveBeenCalledWith( + expect.objectContaining({ to: 'test@example.com' }) + ); + expect(mockUser.verified).toBe('resent'); + expect(mockUser.verifiedToken).toBeDefined(); + expect(mockUser.verifiedTokenExpires).toBeDefined(); + expect(saveMock).toHaveBeenCalled(); + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining({ + email: request.user.email, + username: request.user.username + }) + ); + }); + + it('returns 500 if mailer fails', async () => { + const saveMock = jest.fn().mockResolvedValue({}); + const mockUser = { + verified: 'sent', + verifiedToken: null, + verifiedTokenExpires: null, + email: 'test@example.com', + save: saveMock + }; + + User.findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); + (mailerService.send as jest.Mock).mockRejectedValue( + new Error('Mailer fail') + ); + + request.user = { id: 'user1' }; + request.headers.host = 'localhost:3000'; + + await emailVerificationInitiate(request, response); + + expect(response.status).toHaveBeenCalledWith(500); + expect(response.send).toHaveBeenCalledWith({ + error: 'Error sending mail' + }); + }); + }); }); diff --git a/server/controllers/user.controller/signup.js b/server/controllers/user.controller/signup.js index 78669fd7c3..d1c669d0da 100644 --- a/server/controllers/user.controller/signup.js +++ b/server/controllers/user.controller/signup.js @@ -97,3 +97,41 @@ export async function verifyEmail(req, res) { await user.save(); res.json({ success: true }); } + +export async function emailVerificationInitiate(req, res) { + try { + const token = await generateToken(); + const user = await User.findById(req.user.id).exec(); + if (!user) { + res.status(404).json({ error: 'User not found' }); + return; + } + if (user.verified === User.EmailConfirmation().Verified) { + res.status(409).json({ error: 'Email already verified' }); + return; + } + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + const mailOptions = renderEmailConfirmation({ + body: { + domain: `${protocol}://${req.headers.host}`, + link: `${protocol}://${req.headers.host}/verify?t=${token}` + }, + to: user.email + }); + try { + await mailerService.send(mailOptions); + } catch (mailErr) { + res.status(500).send({ error: 'Error sending mail' }); + return; + } + const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours + user.verified = User.EmailConfirmation().Resent; + user.verifiedToken = token; + user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; // 24 hours + await user.save(); + + res.json(userResponse(req.user)); + } catch (err) { + res.status(500).json({ error: err }); + } +} From e0e2aa37eb5796c67cbb7391c7fc3395e866ef85 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Wed, 8 Oct 2025 23:41:36 +0100 Subject: [PATCH 18/67] server/controllers/user.controller/signup: update to ts, no-verify --- server/controllers/user.controller/{signup.js => signup.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename server/controllers/user.controller/{signup.js => signup.ts} (100%) diff --git a/server/controllers/user.controller/signup.js b/server/controllers/user.controller/signup.ts similarity index 100% rename from server/controllers/user.controller/signup.js rename to server/controllers/user.controller/signup.ts From fff6e51f861bb06d9ed53fa97c46785c8b2d98c9 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Wed, 8 Oct 2025 23:56:11 +0100 Subject: [PATCH 19/67] server/controllers/user.controller/signup: add response and request types --- .../user.controller/__tests__/signup.test.ts | 24 ++++---- server/controllers/user.controller/helpers.ts | 2 +- server/controllers/user.controller/signup.ts | 56 +++++++++++++++---- 3 files changed, 57 insertions(+), 25 deletions(-) diff --git a/server/controllers/user.controller/__tests__/signup.test.ts b/server/controllers/user.controller/__tests__/signup.test.ts index d7d8983a05..70577191f8 100644 --- a/server/controllers/user.controller/__tests__/signup.test.ts +++ b/server/controllers/user.controller/__tests__/signup.test.ts @@ -54,7 +54,7 @@ describe('user.controller', () => { password: 'password' }); - await createUser(request, response); + await createUser(request, response, next); expect(User.findByEmailAndUsername).toHaveBeenCalledWith( 'existing@example.com', @@ -75,7 +75,7 @@ describe('user.controller', () => { password: 'password' }); - await createUser(request, response); + await createUser(request, response, next); expect(User.findByEmailAndUsername).toHaveBeenCalledWith( 'existing@example.com', @@ -95,7 +95,7 @@ describe('user.controller', () => { request.query = { check_type: 'email', email: 'test@example.com' }; - await duplicateUserCheck(request, response); + await duplicateUserCheck(request, response, next); expect(mockFind).toHaveBeenCalledWith('test@example.com', { caseInsensitive: true, @@ -108,7 +108,7 @@ describe('user.controller', () => { request.query = { check_type: 'username', username: 'newuser' }; - await duplicateUserCheck(request, response); + await duplicateUserCheck(request, response, next); expect(response.json).toHaveBeenCalledWith({ exists: false, @@ -123,7 +123,7 @@ describe('user.controller', () => { request.query = { check_type: 'username', username: 'existinguser' }; - await duplicateUserCheck(request, response); + await duplicateUserCheck(request, response, next); expect(response.json).toHaveBeenCalledWith({ exists: true, @@ -139,7 +139,7 @@ describe('user.controller', () => { request.query = { check_type: 'email', email: 'existing@example.com' }; - await duplicateUserCheck(request, response); + await duplicateUserCheck(request, response, next); expect(response.json).toHaveBeenCalledWith({ exists: true, @@ -157,7 +157,7 @@ describe('user.controller', () => { request.query = { t: 'invalidtoken' }; - await verifyEmail(request, response); + await verifyEmail(request, response, next); expect(User.findOne).toHaveBeenCalledWith({ verifiedToken: 'invalidtoken', @@ -189,7 +189,7 @@ describe('user.controller', () => { request.query = { t: 'validtoken' }; - await verifyEmail(request, response); + await verifyEmail(request, response, next); expect(mockUser.verified).toBe('verified'); expect(mockUser.verifiedToken).toBeNull(); @@ -208,7 +208,7 @@ describe('user.controller', () => { request.user = { id: 'nonexistentid' }; request.headers.host = 'localhost:3000'; - await emailVerificationInitiate(request, response); + await emailVerificationInitiate(request, response, next); expect(User.findById).toHaveBeenCalledWith('nonexistentid'); expect(response.status).toHaveBeenCalledWith(404); @@ -230,7 +230,7 @@ describe('user.controller', () => { request.user = { id: 'user1' }; request.headers.host = 'localhost:3000'; - await emailVerificationInitiate(request, response); + await emailVerificationInitiate(request, response, next); expect(response.status).toHaveBeenCalledWith(409); expect(response.json).toHaveBeenCalledWith({ @@ -260,7 +260,7 @@ describe('user.controller', () => { request.user = { id: 'user1' }; request.headers.host = 'localhost:3000'; - await emailVerificationInitiate(request, response); + await emailVerificationInitiate(request, response, next); expect(User.findById).toHaveBeenCalledWith('user1'); expect(mailerService.send).toHaveBeenCalledWith( @@ -298,7 +298,7 @@ describe('user.controller', () => { request.user = { id: 'user1' }; request.headers.host = 'localhost:3000'; - await emailVerificationInitiate(request, response); + await emailVerificationInitiate(request, response, next); expect(response.status).toHaveBeenCalledWith(500); expect(response.send).toHaveBeenCalledWith({ diff --git a/server/controllers/user.controller/helpers.ts b/server/controllers/user.controller/helpers.ts index 7491a8d0eb..917c41bdbd 100644 --- a/server/controllers/user.controller/helpers.ts +++ b/server/controllers/user.controller/helpers.ts @@ -28,7 +28,7 @@ export function userResponse( * Note: can be done synchronously - https://nodejs.org/api/crypto.html#cryptorandombytessize-callback * @return Promise */ -export async function generateToken() { +export async function generateToken(): Promise { return new Promise((resolve, reject) => { crypto.randomBytes(20, (err, buf) => { if (err) { diff --git a/server/controllers/user.controller/signup.ts b/server/controllers/user.controller/signup.ts index d1c669d0da..0eff4f918b 100644 --- a/server/controllers/user.controller/signup.ts +++ b/server/controllers/user.controller/signup.ts @@ -1,9 +1,30 @@ +import { RequestHandler } from 'express'; import { User } from '../../models/user'; import { generateToken, userResponse } from './helpers'; import { renderEmailConfirmation } from '../../views/mail'; import { mailerService } from '../../utils/mail'; +import { Error, PublicUser } from '../../types'; -export async function createUser(req, res) { +export interface CreateUserRequestBody { + username: string; + email: string; + password: string; +} +export interface DuplicateUserCheckQuery { + // eslint-disable-next-line camelcase + check_type: 'email' | 'username'; + email?: string; + username?: string; +} +export interface VerifyEmailQuery { + t: string; +} + +export const createUser: RequestHandler< + {}, + PublicUser | Error, + CreateUserRequestBody +> = async (req, res) => { try { const { username, email, password } = req.body; const emailLowerCase = email.toLowerCase(); @@ -43,7 +64,7 @@ export async function createUser(req, res) { domain: `${protocol}://${req.headers.host}`, link: `${protocol}://${req.headers.host}/verify?t=${token}` }, - to: req.user.email + to: req.user!.email }); try { @@ -58,13 +79,18 @@ 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< + {}, + {}, + {}, + DuplicateUserCheckQuery +> = async (req, res) => { const checkType = req.query.check_type; const value = req.query[checkType]; const options = { caseInsensitive: true, valueType: checkType }; - const user = await User.findByEmailOrUsername(value, options); + const user = await User.findByEmailOrUsername(value!, options); if (user) { return res.json({ exists: true, @@ -76,9 +102,12 @@ export async function duplicateUserCheck(req, res) { exists: false, type: checkType }); -} +}; -export async function verifyEmail(req, res) { +export const verifyEmail: RequestHandler<{}, {}, {}, VerifyEmailQuery> = async ( + req, + res +) => { const token = req.query.t; const user = await User.findOne({ verifiedToken: token, @@ -96,12 +125,15 @@ export async function verifyEmail(req, res) { user.verifiedTokenExpires = null; await user.save(); res.json({ success: true }); -} +}; -export async function emailVerificationInitiate(req, res) { +export const emailVerificationInitiate: RequestHandler< + {}, + PublicUser | Error +> = async (req, res) => { try { const token = await generateToken(); - const user = await User.findById(req.user.id).exec(); + const user = await User.findById(req.user!.id).exec(); if (!user) { res.status(404).json({ error: 'User not found' }); return; @@ -130,8 +162,8 @@ export async function emailVerificationInitiate(req, res) { user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; // 24 hours await user.save(); - res.json(userResponse(req.user)); + res.json(userResponse(req.user!)); } catch (err) { res.status(500).json({ error: err }); } -} +}; From 238f545c16633f1be07749cbefcc18216cf615ad Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 00:35:18 +0100 Subject: [PATCH 20/67] server/controllers/user.controller/userPreferences: add test for updatePreferences, no-verify --- .../__tests__/userPreferences.test.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 server/controllers/user.controller/__tests__/userPreferences.test.ts diff --git a/server/controllers/user.controller/__tests__/userPreferences.test.ts b/server/controllers/user.controller/__tests__/userPreferences.test.ts new file mode 100644 index 0000000000..cea9bb3fa9 --- /dev/null +++ b/server/controllers/user.controller/__tests__/userPreferences.test.ts @@ -0,0 +1,92 @@ +import { Request as MockRequest } from 'jest-express/lib/request'; +import { Response as MockResponse } from 'jest-express/lib/response'; +import { NextFunction as MockNext } from 'jest-express/lib/next'; +import { User } from '../../../models/user'; +import { updatePreferences } from '../../user.controller'; + +jest.mock('../../../models/user'); +jest.mock('../../../utils/mail', () => ({ + mailerService: { + send: jest.fn() + } +})); +jest.mock('../../../views/mail', () => ({ + renderEmailConfirmation: jest + .fn() + .mockReturnValue({ to: 'test@example.com', subject: 'Confirm' }) +})); + +describe('user.controller > user preferences', () => { + let request: any; + let response: any; + let next: MockNext; + + beforeEach(() => { + request = new MockRequest(); + response = new MockResponse(); + next = jest.fn(); + }); + + afterEach(() => { + request.resetMocked(); + response.resetMocked(); + jest.clearAllMocks(); + }); + + describe('updatePreferences', () => { + it('saves user preferences when user exists', async () => { + const saveMock = jest.fn().mockResolvedValue({}); + const mockUser = { + preferences: { theme: 'light' }, + save: saveMock + }; + User.findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); + + request.user = { id: 'user1' }; + request.body = { preferences: { theme: 'dark', notifications: true } }; + + await updatePreferences(request, response); + + // Check that preferences were merged correctly + expect(mockUser.preferences).toEqual({ + theme: 'dark', + notifications: true + }); + expect(saveMock).toHaveBeenCalled(); + expect(response.json).toHaveBeenCalledWith(mockUser.preferences); + }); + it('returns 404 when no user is found', async () => { + User.findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); + + request.user = { id: 'nonexistentid' }; + + await updatePreferences(request, response); + + expect(User.findById).toHaveBeenCalledWith('nonexistentid'); + expect(response.status).toHaveBeenCalledWith(404); + expect(response.json).toHaveBeenCalledWith({ error: 'User not found' }); + }); + it('returns 500 if saving preferences fails', async () => { + const saveMock = jest.fn().mockRejectedValue(new Error('DB error')); + const mockUser = { + preferences: { theme: 'light' }, + save: saveMock + }; + User.findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); + + request.user = { id: 'user1' }; + request.body = { preferences: { theme: 'dark' } }; + + await updatePreferences(request, response); + + expect(response.status).toHaveBeenCalledWith(500); + expect(response.json).toHaveBeenCalledWith({ error: expect.any(Error) }); + }); + }); +}); From df35eb4ac5ac1cfee1ec7ed1712bd1a441fab1c2 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 00:36:45 +0100 Subject: [PATCH 21/67] server/controllers/user.controller/userPreferences: move updateUserPreferences to /userPreferences & add response and request types --- server/controllers/user.controller.js | 17 +----------- .../__tests__/userPreferences.test.ts | 6 ++--- .../user.controller/userPreferences.ts | 27 +++++++++++++++++++ 3 files changed, 31 insertions(+), 19 deletions(-) create mode 100644 server/controllers/user.controller/userPreferences.ts diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.js index 9989e5ba03..6bf131665e 100644 --- a/server/controllers/user.controller.js +++ b/server/controllers/user.controller.js @@ -6,22 +6,7 @@ import { userResponse, generateToken } from './user.controller/helpers'; export * from './user.controller/apiKey'; export * from './user.controller/signup'; - -export async function updatePreferences(req, res) { - try { - const user = await User.findById(req.user.id).exec(); - if (!user) { - res.status(404).json({ error: 'User not found' }); - return; - } - // Shallow merge the new preferences with the existing. - user.preferences = { ...user.preferences, ...req.body.preferences }; - await user.save(); - res.json(user.preferences); - } catch (err) { - res.status(500).json({ error: err }); - } -} +export * from './user.controller/userPreferences'; export async function resetPasswordInitiate(req, res) { try { diff --git a/server/controllers/user.controller/__tests__/userPreferences.test.ts b/server/controllers/user.controller/__tests__/userPreferences.test.ts index cea9bb3fa9..a1f8ac18d9 100644 --- a/server/controllers/user.controller/__tests__/userPreferences.test.ts +++ b/server/controllers/user.controller/__tests__/userPreferences.test.ts @@ -47,7 +47,7 @@ describe('user.controller > user preferences', () => { request.user = { id: 'user1' }; request.body = { preferences: { theme: 'dark', notifications: true } }; - await updatePreferences(request, response); + await updatePreferences(request, response, next); // Check that preferences were merged correctly expect(mockUser.preferences).toEqual({ @@ -64,7 +64,7 @@ describe('user.controller > user preferences', () => { request.user = { id: 'nonexistentid' }; - await updatePreferences(request, response); + await updatePreferences(request, response, next); expect(User.findById).toHaveBeenCalledWith('nonexistentid'); expect(response.status).toHaveBeenCalledWith(404); @@ -83,7 +83,7 @@ describe('user.controller > user preferences', () => { request.user = { id: 'user1' }; request.body = { preferences: { theme: 'dark' } }; - await updatePreferences(request, response); + await updatePreferences(request, response, next); expect(response.status).toHaveBeenCalledWith(500); expect(response.json).toHaveBeenCalledWith({ error: expect.any(Error) }); diff --git a/server/controllers/user.controller/userPreferences.ts b/server/controllers/user.controller/userPreferences.ts new file mode 100644 index 0000000000..89f31e2169 --- /dev/null +++ b/server/controllers/user.controller/userPreferences.ts @@ -0,0 +1,27 @@ +import { RequestHandler } from 'express'; +import { User } from '../../models/user'; +import { Error, UserPreferences } from '../../types'; + +export interface UpdatePreferencesRequestBody { + preferences: UserPreferences; +} + +export const updatePreferences: RequestHandler< + {}, + UserPreferences | Error, + UpdatePreferencesRequestBody +> = async (req, res) => { + try { + const user = await User.findById(req.user!.id).exec(); + if (!user) { + res.status(404).json({ error: 'User not found' }); + return; + } + // Shallow merge the new preferences with the existing. + user.preferences = { ...user.preferences, ...req.body.preferences }; + await user.save(); + res.json(user.preferences); + } catch (err) { + res.status(500).json({ error: err }); + } +}; From c455dd683de8f413123e2a11073d2a1a7de447a0 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 09:06:11 +0100 Subject: [PATCH 22/67] server/controllers/user.controller/userPreferences: add test for updateCookiePreferences & move to /userPreferences --- server/controllers/user.controller.js | 36 ++------ .../__tests__/userPreferences.test.ts | 84 ++++++++++++++++++- server/controllers/user.controller/helpers.ts | 18 +++- .../user.controller/userPreferences.ts | 30 ++++++- 4 files changed, 134 insertions(+), 34 deletions(-) diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.js index 6bf131665e..0c5d0677ec 100644 --- a/server/controllers/user.controller.js +++ b/server/controllers/user.controller.js @@ -2,7 +2,11 @@ import { User } from '../models/user'; import { mailerService } from '../utils/mail'; import { renderEmailConfirmation, renderResetPassword } from '../views/mail'; -import { userResponse, generateToken } from './user.controller/helpers'; +import { + userResponse, + generateToken, + saveUser +} from './user.controller/helpers'; export * from './user.controller/apiKey'; export * from './user.controller/signup'; @@ -91,21 +95,6 @@ export async function userExists(username) { return user != null; } -/** - * Updates the user object and sets the response. - * Response is the user or a 500 error. - * @param res - * @param user - */ -export async function saveUser(res, user) { - try { - await user.save(); - res.json(userResponse(user)); - } catch (error) { - res.status(500).json({ error }); - } -} - export async function updateSettings(req, res) { try { const user = await User.findById(req.user.id); @@ -192,18 +181,3 @@ export async function unlinkGoogle(req, res) { message: 'You must be logged in to complete this action.' }); } - -export async function updateCookieConsent(req, res) { - try { - const user = await User.findById(req.user.id).exec(); - if (!user) { - res.status(404).json({ error: 'User not found' }); - return; - } - const { cookieConsent } = req.body; - user.cookieConsent = cookieConsent; - await saveUser(res, user); - } catch (err) { - res.status(500).json({ error: err }); - } -} diff --git a/server/controllers/user.controller/__tests__/userPreferences.test.ts b/server/controllers/user.controller/__tests__/userPreferences.test.ts index a1f8ac18d9..2db7d0051a 100644 --- a/server/controllers/user.controller/__tests__/userPreferences.test.ts +++ b/server/controllers/user.controller/__tests__/userPreferences.test.ts @@ -1,8 +1,11 @@ import { Request as MockRequest } from 'jest-express/lib/request'; import { Response as MockResponse } from 'jest-express/lib/response'; import { NextFunction as MockNext } from 'jest-express/lib/next'; +import { Types } from 'mongoose'; import { User } from '../../../models/user'; -import { updatePreferences } from '../../user.controller'; +import { updatePreferences, updateCookieConsent } from '../../user.controller'; +import { ApiKeyDocument } from '../../../types'; +import { CookieConsentOptions, AppThemeOptions } from '../../../types'; jest.mock('../../../models/user'); jest.mock('../../../utils/mail', () => ({ @@ -16,6 +19,19 @@ jest.mock('../../../views/mail', () => ({ .mockReturnValue({ to: 'test@example.com', subject: 'Confirm' }) })); +const mockBaseUser = { + email: 'test@example.com', + username: 'tester', + preferences: {}, + apiKeys: ([] as unknown) as Types.DocumentArray, + verified: 'verified', + id: 'abc123', + totalSize: 42, + cookieConsent: CookieConsentOptions.NONE, + google: 'user@gmail.com', + github: 'user123' +}; + describe('user.controller > user preferences', () => { let request: any; let response: any; @@ -37,6 +53,7 @@ describe('user.controller > user preferences', () => { it('saves user preferences when user exists', async () => { const saveMock = jest.fn().mockResolvedValue({}); const mockUser = { + ...mockBaseUser, preferences: { theme: 'light' }, save: saveMock }; @@ -73,6 +90,7 @@ describe('user.controller > user preferences', () => { it('returns 500 if saving preferences fails', async () => { const saveMock = jest.fn().mockRejectedValue(new Error('DB error')); const mockUser = { + ...mockBaseUser, preferences: { theme: 'light' }, save: saveMock }; @@ -89,4 +107,68 @@ describe('user.controller > user preferences', () => { expect(response.json).toHaveBeenCalledWith({ error: expect.any(Error) }); }); }); + + describe('updateCookieConsent', () => { + it('updates cookieConsent when user exists', async () => { + const saveMock = jest.fn().mockResolvedValue({}); + const mockUser = { + ...mockBaseUser, + cookieConsent: false, + save: saveMock + }; + + User.findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); + + request.user = { id: 'user1' }; + request.body = { cookieConsent: true }; + + await updateCookieConsent(request, response, next); + + expect(User.findById).toHaveBeenCalledWith('user1'); + expect(mockUser.cookieConsent).toBe(true); + expect(saveMock).toHaveBeenCalled(); + expect(response.json).toHaveBeenCalledWith({ + ...mockBaseUser, + cookieConsent: true + }); + }); + + it('returns 404 when no user is found', async () => { + User.findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); + + request.user = { id: 'nonexistentid' }; + request.body = { cookieConsent: true }; + + await updateCookieConsent(request, response, next); + + expect(User.findById).toHaveBeenCalledWith('nonexistentid'); + expect(response.status).toHaveBeenCalledWith(404); + expect(response.json).toHaveBeenCalledWith({ error: 'User not found' }); + }); + + it('returns 500 if saving cookieConsent fails', async () => { + const saveMock = jest.fn().mockRejectedValue(new Error('DB error')); + const mockUser = { + ...mockBaseUser, + cookieConsent: true, + save: saveMock + }; + + User.findById = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); + + request.user = { id: 'user1' }; + request.body = { cookieConsent: true }; + + await updateCookieConsent(request, response, next); + + expect(response.status).toHaveBeenCalledWith(500); + expect(response.json).toHaveBeenCalledWith({ error: expect.any(Error) }); + }); + }); }); diff --git a/server/controllers/user.controller/helpers.ts b/server/controllers/user.controller/helpers.ts index 917c41bdbd..05554f6914 100644 --- a/server/controllers/user.controller/helpers.ts +++ b/server/controllers/user.controller/helpers.ts @@ -1,5 +1,6 @@ import crypto from 'crypto'; -import { PublicUser } from '../../types'; +import type { Response } from 'express'; +import { PublicUser, UserDocument } from '../../types'; /** * Sanitise user objects to remove sensitive fields @@ -40,3 +41,18 @@ export async function generateToken(): Promise { }); }); } + +/** + * Updates the user object and sets the response. + * Response is the sanitised user or a 500 error. + * @param res + * @param user + */ +export async function saveUser(res: Response, user: UserDocument) { + try { + await user.save(); + res.json(userResponse(user)); + } catch (error) { + res.status(500).json({ error }); + } +} diff --git a/server/controllers/user.controller/userPreferences.ts b/server/controllers/user.controller/userPreferences.ts index 89f31e2169..0ba6e141c8 100644 --- a/server/controllers/user.controller/userPreferences.ts +++ b/server/controllers/user.controller/userPreferences.ts @@ -1,10 +1,19 @@ import { RequestHandler } from 'express'; import { User } from '../../models/user'; -import { Error, UserPreferences } from '../../types'; +import { + Error, + UserPreferences, + PublicUser, + CookieConsentOptions +} from '../../types'; +import { saveUser } from './helpers'; export interface UpdatePreferencesRequestBody { preferences: UserPreferences; } +export interface UpdateCookieConsentRequestBody { + cookieConsent: CookieConsentOptions; +} export const updatePreferences: RequestHandler< {}, @@ -25,3 +34,22 @@ export const updatePreferences: RequestHandler< res.status(500).json({ error: err }); } }; + +export const updateCookieConsent: RequestHandler< + {}, + PublicUser | Error, + UpdateCookieConsentRequestBody +> = async (req, res) => { + try { + const user = await User.findById(req.user!.id).exec(); + if (!user) { + res.status(404).json({ error: 'User not found' }); + return; + } + const { cookieConsent } = req.body; + user.cookieConsent = cookieConsent; + await saveUser(res, user); + } catch (err) { + res.status(500).json({ error: err }); + } +}; From 6708adba1a7fd62b405ecb3fc92549c69c6e20a8 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 09:18:28 +0100 Subject: [PATCH 23/67] user controller tests: correct names in top-level describe --- .../user.controller/__tests__/apiKey.test.ts | 2 +- .../__tests__/authManagement.test.ts | 50 +++++++++++++++++++ .../user.controller/__tests__/helpers.ts | 2 +- .../user.controller/__tests__/signup.test.ts | 2 +- 4 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 server/controllers/user.controller/__tests__/authManagement.test.ts diff --git a/server/controllers/user.controller/__tests__/apiKey.test.ts b/server/controllers/user.controller/__tests__/apiKey.test.ts index 80cfdd94c2..c78c50cf53 100644 --- a/server/controllers/user.controller/__tests__/apiKey.test.ts +++ b/server/controllers/user.controller/__tests__/apiKey.test.ts @@ -12,7 +12,7 @@ import type { RemoveApiKeyRequestParams } from '../apiKey'; jest.mock('../../../models/user'); -describe('user.controller', () => { +describe('user.controller > api key', () => { let request: MockRequest & { user?: { id: string } }; let response: MockResponse; let next: MockNext; diff --git a/server/controllers/user.controller/__tests__/authManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement.test.ts new file mode 100644 index 0000000000..1214f8bc75 --- /dev/null +++ b/server/controllers/user.controller/__tests__/authManagement.test.ts @@ -0,0 +1,50 @@ +import { Request as MockRequest } from 'jest-express/lib/request'; +import { Response as MockResponse } from 'jest-express/lib/response'; +import { NextFunction as MockNext } from 'jest-express/lib/next'; +import { User } from '../../../models/user'; +import { unlinkGithub, unlinkGoogle } from '../../user.controller'; + +import { mailerService } from '../../../utils/mail'; +import { renderEmailConfirmation } from '../../../views/mail'; + +jest.mock('../../../models/user'); +jest.mock('../../../utils/mail', () => ({ + mailerService: { + send: jest.fn() + } +})); +jest.mock('../../../views/mail', () => ({ + renderEmailConfirmation: jest + .fn() + .mockReturnValue({ to: 'test@example.com', subject: 'Confirm' }) +})); + +describe('user.controller > 3rd party auth management', () => { + let request: any; + let response: any; + let next: MockNext; + + beforeEach(() => { + request = new MockRequest(); + response = new MockResponse(); + next = jest.fn(); + }); + + afterEach(() => { + request.resetMocked(); + response.resetMocked(); + jest.clearAllMocks(); + }); + + describe('unlinkGithub', () => { + it('returns 404 if user is not found', async () => { + await unlinkGithub(request, response); + + expect(response.status).toHaveBeenCalledWith(404); + expect(response.json).toHaveBeenCalledWith({ + success: false, + message: 'You must be logged in to complete this action.' + }); + }); + }); +}); diff --git a/server/controllers/user.controller/__tests__/helpers.ts b/server/controllers/user.controller/__tests__/helpers.ts index a8fe3beee2..488f0ca195 100644 --- a/server/controllers/user.controller/__tests__/helpers.ts +++ b/server/controllers/user.controller/__tests__/helpers.ts @@ -43,7 +43,7 @@ const mockFullUser = { banned: false }; -describe('user.helpers', () => { +describe('user.controller > helpers', () => { describe('userResponse', () => { it('returns a sanitized PublicUser object', () => { const result = userResponse(mockFullUser); diff --git a/server/controllers/user.controller/__tests__/signup.test.ts b/server/controllers/user.controller/__tests__/signup.test.ts index 70577191f8..04cfa71ec1 100644 --- a/server/controllers/user.controller/__tests__/signup.test.ts +++ b/server/controllers/user.controller/__tests__/signup.test.ts @@ -24,7 +24,7 @@ jest.mock('../../../views/mail', () => ({ .mockReturnValue({ to: 'test@example.com', subject: 'Confirm' }) })); -describe('user.controller', () => { +describe('user.controller > signup', () => { let request: any; let response: any; let next: MockNext; From 2350199647e0dd18cce4ed1ee5a8dd0b2076a67d Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 09:32:01 +0100 Subject: [PATCH 24/67] user controller: add 3rd party auth management tests --- .../__tests__/authManagement.test.ts | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/server/controllers/user.controller/__tests__/authManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement.test.ts index 1214f8bc75..ac970a54d0 100644 --- a/server/controllers/user.controller/__tests__/authManagement.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement.test.ts @@ -3,6 +3,7 @@ import { Response as MockResponse } from 'jest-express/lib/response'; import { NextFunction as MockNext } from 'jest-express/lib/next'; import { User } from '../../../models/user'; import { unlinkGithub, unlinkGoogle } from '../../user.controller'; +import { saveUser } from '../helpers'; import { mailerService } from '../../../utils/mail'; import { renderEmailConfirmation } from '../../../views/mail'; @@ -18,6 +19,9 @@ jest.mock('../../../views/mail', () => ({ .fn() .mockReturnValue({ to: 'test@example.com', subject: 'Confirm' }) })); +jest.mock('../helpers', () => ({ + saveUser: jest.fn() +})); describe('user.controller > 3rd party auth management', () => { let request: any; @@ -37,7 +41,7 @@ describe('user.controller > 3rd party auth management', () => { }); describe('unlinkGithub', () => { - it('returns 404 if user is not found', async () => { + it('returns 404 if user is not logged in', async () => { await unlinkGithub(request, response); expect(response.status).toHaveBeenCalledWith(404); @@ -46,5 +50,51 @@ describe('user.controller > 3rd party auth management', () => { message: 'You must be logged in to complete this action.' }); }); + it('removes the users github & filters out github tokens if user is logged in', async () => { + const user = { + github: { id: '123', username: 'testuser' }, + tokens: [ + { kind: 'github', accessToken: 'abc' }, + { kind: 'google', accessToken: 'xyz' } + ] + }; + + request.user = user; + + await unlinkGithub(request, response); + + expect(user.github).toBeUndefined(); + expect(user.tokens).toEqual([{ kind: 'google', accessToken: 'xyz' }]); + expect(saveUser).toHaveBeenCalledWith(response, user); + }); + }); + + describe('unlinkGoogle', () => { + it('returns 404 if user is not logged in', async () => { + await unlinkGoogle(request, response); + + expect(response.status).toHaveBeenCalledWith(404); + expect(response.json).toHaveBeenCalledWith({ + success: false, + message: 'You must be logged in to complete this action.' + }); + }); + it('removes the users google & filters out google tokens if user is logged in', async () => { + const user = { + google: { id: '123', username: 'testuser' }, + tokens: [ + { kind: 'github', accessToken: 'abc' }, + { kind: 'google', accessToken: 'xyz' } + ] + }; + + request.user = user; + + await unlinkGoogle(request, response); + + expect(user.google).toBeUndefined(); + expect(user.tokens).toEqual([{ kind: 'github', accessToken: 'abc' }]); + expect(saveUser).toHaveBeenCalledWith(response, user); + }); }); }); From 1afa044055530397f717717b3f1ad93c07e5984d Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 09:44:26 +0100 Subject: [PATCH 25/67] user controller/auth management: migrate unlinkGithub and unlinkGoogle to /authmanagement and add request and response types --- server/controllers/user.controller.js | 31 +-------------- .../__tests__/authManagement.test.ts | 8 ++-- .../user.controller/authManagement.ts | 39 +++++++++++++++++++ server/types/express.ts | 4 +- server/types/express/index.d.ts | 4 +- 5 files changed, 48 insertions(+), 38 deletions(-) create mode 100644 server/controllers/user.controller/authManagement.ts diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.js index 0c5d0677ec..407281ff28 100644 --- a/server/controllers/user.controller.js +++ b/server/controllers/user.controller.js @@ -11,6 +11,7 @@ import { export * from './user.controller/apiKey'; export * from './user.controller/signup'; export * from './user.controller/userPreferences'; +export * from './user.controller/authManagement'; export async function resetPasswordInitiate(req, res) { try { @@ -151,33 +152,3 @@ export async function updateSettings(req, res) { res.status(500).json({ error: err }); } } - -export async function unlinkGithub(req, res) { - if (req.user) { - req.user.github = undefined; - req.user.tokens = req.user.tokens.filter( - (token) => token.kind !== 'github' - ); - await saveUser(res, req.user); - return; - } - res.status(404).json({ - success: false, - message: 'You must be logged in to complete this action.' - }); -} - -export async function unlinkGoogle(req, res) { - if (req.user) { - req.user.google = undefined; - req.user.tokens = req.user.tokens.filter( - (token) => token.kind !== 'google' - ); - await saveUser(res, req.user); - return; - } - res.status(404).json({ - success: false, - message: 'You must be logged in to complete this action.' - }); -} diff --git a/server/controllers/user.controller/__tests__/authManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement.test.ts index ac970a54d0..624a48fab4 100644 --- a/server/controllers/user.controller/__tests__/authManagement.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement.test.ts @@ -42,7 +42,7 @@ describe('user.controller > 3rd party auth management', () => { describe('unlinkGithub', () => { it('returns 404 if user is not logged in', async () => { - await unlinkGithub(request, response); + await unlinkGithub(request, response, next); expect(response.status).toHaveBeenCalledWith(404); expect(response.json).toHaveBeenCalledWith({ @@ -61,7 +61,7 @@ describe('user.controller > 3rd party auth management', () => { request.user = user; - await unlinkGithub(request, response); + await unlinkGithub(request, response, next); expect(user.github).toBeUndefined(); expect(user.tokens).toEqual([{ kind: 'google', accessToken: 'xyz' }]); @@ -71,7 +71,7 @@ describe('user.controller > 3rd party auth management', () => { describe('unlinkGoogle', () => { it('returns 404 if user is not logged in', async () => { - await unlinkGoogle(request, response); + await unlinkGoogle(request, response, next); expect(response.status).toHaveBeenCalledWith(404); expect(response.json).toHaveBeenCalledWith({ @@ -90,7 +90,7 @@ describe('user.controller > 3rd party auth management', () => { request.user = user; - await unlinkGoogle(request, response); + await unlinkGoogle(request, response, next); expect(user.google).toBeUndefined(); expect(user.tokens).toEqual([{ kind: 'github', accessToken: 'abc' }]); diff --git a/server/controllers/user.controller/authManagement.ts b/server/controllers/user.controller/authManagement.ts new file mode 100644 index 0000000000..184733a397 --- /dev/null +++ b/server/controllers/user.controller/authManagement.ts @@ -0,0 +1,39 @@ +import { RequestHandler } from 'express'; +import { saveUser } from './helpers'; +import { PublicUser, GenericResponseBody } from '../../types'; + +export const unlinkGithub: RequestHandler< + {}, + PublicUser | Error | GenericResponseBody +> = async (req, res) => { + if (req.user) { + req.user.github = undefined; + req.user.tokens = req.user.tokens.filter( + (token) => token.kind !== 'github' + ); + await saveUser(res, req.user); + return; + } + res.status(404).json({ + success: false, + message: 'You must be logged in to complete this action.' + }); +}; + +export const unlinkGoogle: RequestHandler< + {}, + PublicUser | Error | GenericResponseBody +> = async (req, res) => { + if (req.user) { + req.user.google = undefined; + req.user.tokens = req.user.tokens.filter( + (token) => token.kind !== 'google' + ); + await saveUser(res, req.user); + return; + } + res.status(404).json({ + success: false, + message: 'You must be logged in to complete this action.' + }); +}; diff --git a/server/types/express.ts b/server/types/express.ts index 0835e77f30..d17df36d6b 100644 --- a/server/types/express.ts +++ b/server/types/express.ts @@ -1,9 +1,9 @@ import { Request } from 'express'; -import { User } from './user'; +import { UserDocument } from './user'; /** Authenticated express request for routes that require auth. Has a user property */ export interface AuthenticatedRequest extends Request { - user: User; + user: UserDocument; } /** Simple error object for express requests */ diff --git a/server/types/express/index.d.ts b/server/types/express/index.d.ts index 3f2c5d818e..8caa4943bd 100644 --- a/server/types/express/index.d.ts +++ b/server/types/express/index.d.ts @@ -1,10 +1,10 @@ -import type { User as CustomUser } from '../user'; +import type { UserDocument } from '../user'; // to make the file a module and avoid the TypeScript error export {}; declare global { namespace Express { - export interface User extends CustomUser {} + export interface User extends UserDocument {} } } From 85ef277221874472012ae5afff0c5320f33ad5c0 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 10:44:10 +0100 Subject: [PATCH 26/67] fix usercontroller helpers test --- .../user.controller/__tests__/{helpers.ts => helpers.test.ts} | 2 ++ 1 file changed, 2 insertions(+) rename server/controllers/user.controller/__tests__/{helpers.ts => helpers.test.ts} (98%) diff --git a/server/controllers/user.controller/__tests__/helpers.ts b/server/controllers/user.controller/__tests__/helpers.test.ts similarity index 98% rename from server/controllers/user.controller/__tests__/helpers.ts rename to server/controllers/user.controller/__tests__/helpers.test.ts index 488f0ca195..4de5e7f24a 100644 --- a/server/controllers/user.controller/__tests__/helpers.ts +++ b/server/controllers/user.controller/__tests__/helpers.test.ts @@ -49,6 +49,8 @@ describe('user.controller > helpers', () => { const result = userResponse(mockFullUser); const { + name, + tokens, password, resetPasswordToken, banned, From 13403dd74b0ca929a9be39ef068e48288493dcca Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 10:53:13 +0100 Subject: [PATCH 27/67] add test for resetPasswordInitiate --- .../__tests__/authManagement.test.ts | 142 ++++++++++++++++-- 1 file changed, 133 insertions(+), 9 deletions(-) diff --git a/server/controllers/user.controller/__tests__/authManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement.test.ts index 624a48fab4..fe959fc9ee 100644 --- a/server/controllers/user.controller/__tests__/authManagement.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement.test.ts @@ -2,11 +2,19 @@ import { Request as MockRequest } from 'jest-express/lib/request'; import { Response as MockResponse } from 'jest-express/lib/response'; import { NextFunction as MockNext } from 'jest-express/lib/next'; import { User } from '../../../models/user'; -import { unlinkGithub, unlinkGoogle } from '../../user.controller'; -import { saveUser } from '../helpers'; +import { + resetPasswordInitiate, + unlinkGithub, + unlinkGoogle +} from '../../user.controller'; +import { saveUser, generateToken } from '../helpers'; import { mailerService } from '../../../utils/mail'; -import { renderEmailConfirmation } from '../../../views/mail'; +import { + renderEmailConfirmation, + renderResetPassword +} from '../../../views/mail'; +import { UserDocument } from '../../../types'; jest.mock('../../../models/user'); jest.mock('../../../utils/mail', () => ({ @@ -14,13 +22,17 @@ jest.mock('../../../utils/mail', () => ({ send: jest.fn() } })); -jest.mock('../../../views/mail', () => ({ - renderEmailConfirmation: jest - .fn() - .mockReturnValue({ to: 'test@example.com', subject: 'Confirm' }) -})); +// jest.mock('../../../views/mail', () => ({ +// renderEmailConfirmation: jest +// .fn() +// .mockReturnValue({ to: 'test@example.com', subject: 'Confirm' }), +// renderResetPassword: jest +// .fn() +// .mockReturnValue({ to: 'test@example.com', subject: 'Reset password' }) +// })); jest.mock('../helpers', () => ({ - saveUser: jest.fn() + saveUser: jest.fn(), + generateToken: jest.fn() })); describe('user.controller > 3rd party auth management', () => { @@ -40,6 +52,118 @@ describe('user.controller > 3rd party auth management', () => { jest.clearAllMocks(); }); + describe('resetPasswordInitiate', () => { + const fixedTime = 100000000; // arbitrary fixed timestamp + let mockToken: string; + let saveMock: jest.Mock; + let mockUser: Partial; + + beforeAll(() => { + jest.useFakeTimers().setSystemTime(fixedTime); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + describe('if the user is found', () => { + beforeEach(() => { + mockToken = 'mock-token'; + saveMock = jest.fn().mockResolvedValue({}); + mockUser = { + email: 'test@example.com', + save: saveMock + }; + + (generateToken as jest.Mock).mockResolvedValue(mockToken); + User.findByEmail = jest.fn().mockResolvedValue(mockUser); + + request.body = { email: 'test@example.com' }; + request.headers.host = 'localhost:3000'; + }); + it('sets a resetPasswordToken with an expiry of 1h to the user', async () => { + await resetPasswordInitiate(request, response); + + expect(mockUser.resetPasswordToken).toBe(mockToken); + expect(mockUser.resetPasswordExpires).toBe(fixedTime + 3600000); + expect(saveMock).toHaveBeenCalled(); + }); + it('sends the reset password email', async () => { + await resetPasswordInitiate(request, response); + + expect(mailerService.send).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'test@example.com', + body: expect.objectContaining({ + link: expect.stringContaining(mockToken) + }) + }) + ); + }); + it('returns a success message that does not indicate if the user exists, for security purposes', async () => { + await resetPasswordInitiate(request, response); + + expect(response.json).toHaveBeenCalledWith({ + success: true, + message: + 'If the email is registered with the editor, an email has been sent.' + }); + }); + }); + describe('if the user is not found', () => { + beforeEach(() => { + mockToken = 'mock-token'; + saveMock = jest.fn().mockResolvedValue({}); + mockUser = { + email: 'test@example.com', + save: saveMock + }; + + (generateToken as jest.Mock).mockResolvedValue(mockToken); + User.findByEmail = jest.fn().mockResolvedValue(null); + + request.body = { email: 'test@example.com' }; + request.headers.host = 'localhost:3000'; + }); + it('does not send the reset password email', async () => { + await resetPasswordInitiate(request, response); + + expect(mailerService.send).not.toHaveBeenCalledWith(); + }); + it('returns a success message that does not indicate if the user exists, for security purposes', async () => { + await resetPasswordInitiate(request, response); + + expect(response.json).toHaveBeenCalledWith({ + success: true, + message: + 'If the email is registered with the editor, an email has been sent.' + }); + }); + }); + it('returns unsuccessful for all other errors', async () => { + mockToken = 'mock-token'; + saveMock = jest.fn().mockResolvedValue({}); + mockUser = { + email: 'test@example.com', + save: saveMock + }; + + (generateToken as jest.Mock).mockRejectedValue( + new Error('network error') + ); + User.findByEmail = jest.fn().mockResolvedValue(null); + + request.body = { email: 'test@example.com' }; + request.headers.host = 'localhost:3000'; + + await resetPasswordInitiate(request, response); + + expect(response.json).toHaveBeenCalledWith({ + success: false + }); + }); + }); + describe('unlinkGithub', () => { it('returns 404 if user is not logged in', async () => { await unlinkGithub(request, response, next); From f475d5a04405cd54ec84124679460b9da3ab6fdf Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 11:27:16 +0100 Subject: [PATCH 28/67] migrate resetPasswordInitiate to /authManagement and add req res types --- server/controllers/user.controller.js | 37 -------------- .../__tests__/authManagement.test.ts | 12 ++--- .../user.controller/authManagement.ts | 50 ++++++++++++++++++- 3 files changed, 55 insertions(+), 44 deletions(-) diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.js index 407281ff28..01516c999e 100644 --- a/server/controllers/user.controller.js +++ b/server/controllers/user.controller.js @@ -13,43 +13,6 @@ export * from './user.controller/signup'; export * from './user.controller/userPreferences'; export * from './user.controller/authManagement'; -export async function resetPasswordInitiate(req, res) { - try { - const token = await generateToken(); - const user = await User.findByEmail(req.body.email); - if (!user) { - res.json({ - success: true, - message: - 'If the email is registered with the editor, an email has been sent.' - }); - return; - } - user.resetPasswordToken = token; - user.resetPasswordExpires = Date.now() + 3600000; // 1 hour - - await user.save(); - const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; - const mailOptions = renderResetPassword({ - body: { - domain: `${protocol}://${req.headers.host}`, - link: `${protocol}://${req.headers.host}/reset-password/${token}` - }, - to: user.email - }); - - await mailerService.send(mailOptions); - res.json({ - success: true, - message: - 'If the email is registered with the editor, an email has been sent.' - }); - } catch (err) { - console.log(err); - res.json({ success: false }); - } -} - export async function validateResetPasswordToken(req, res) { const user = await User.findOne({ resetPasswordToken: req.params.token, diff --git a/server/controllers/user.controller/__tests__/authManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement.test.ts index fe959fc9ee..749567aaf3 100644 --- a/server/controllers/user.controller/__tests__/authManagement.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement.test.ts @@ -82,14 +82,14 @@ describe('user.controller > 3rd party auth management', () => { request.headers.host = 'localhost:3000'; }); it('sets a resetPasswordToken with an expiry of 1h to the user', async () => { - await resetPasswordInitiate(request, response); + await resetPasswordInitiate(request, response, next); expect(mockUser.resetPasswordToken).toBe(mockToken); expect(mockUser.resetPasswordExpires).toBe(fixedTime + 3600000); expect(saveMock).toHaveBeenCalled(); }); it('sends the reset password email', async () => { - await resetPasswordInitiate(request, response); + await resetPasswordInitiate(request, response, next); expect(mailerService.send).toHaveBeenCalledWith( expect.objectContaining({ @@ -101,7 +101,7 @@ describe('user.controller > 3rd party auth management', () => { ); }); it('returns a success message that does not indicate if the user exists, for security purposes', async () => { - await resetPasswordInitiate(request, response); + await resetPasswordInitiate(request, response, next); expect(response.json).toHaveBeenCalledWith({ success: true, @@ -126,12 +126,12 @@ describe('user.controller > 3rd party auth management', () => { request.headers.host = 'localhost:3000'; }); it('does not send the reset password email', async () => { - await resetPasswordInitiate(request, response); + await resetPasswordInitiate(request, response, next); expect(mailerService.send).not.toHaveBeenCalledWith(); }); it('returns a success message that does not indicate if the user exists, for security purposes', async () => { - await resetPasswordInitiate(request, response); + await resetPasswordInitiate(request, response, next); expect(response.json).toHaveBeenCalledWith({ success: true, @@ -156,7 +156,7 @@ describe('user.controller > 3rd party auth management', () => { request.body = { email: 'test@example.com' }; request.headers.host = 'localhost:3000'; - await resetPasswordInitiate(request, response); + await resetPasswordInitiate(request, response, next); expect(response.json).toHaveBeenCalledWith({ success: false diff --git a/server/controllers/user.controller/authManagement.ts b/server/controllers/user.controller/authManagement.ts index 184733a397..ba1794ad00 100644 --- a/server/controllers/user.controller/authManagement.ts +++ b/server/controllers/user.controller/authManagement.ts @@ -1,6 +1,54 @@ import { RequestHandler } from 'express'; -import { saveUser } from './helpers'; +import { User } from '../../models/user'; +import { saveUser, generateToken } from './helpers'; import { PublicUser, GenericResponseBody } from '../../types'; +import { mailerService } from '../../utils/mail'; +import { renderResetPassword } from '../../views/mail'; + +export interface ResetPasswordRequestBody { + email: string; +} + +export const resetPasswordInitiate: RequestHandler< + {}, + GenericResponseBody, + ResetPasswordRequestBody +> = async (req, res) => { + try { + const token = await generateToken(); + const user = await User.findByEmail(req.body.email); + if (!user) { + res.json({ + success: true, + message: + 'If the email is registered with the editor, an email has been sent.' + }); + return; + } + user.resetPasswordToken = token; + user.resetPasswordExpires = Date.now() + 3600000; // 1 hour + + await user.save(); + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + const mailOptions = renderResetPassword({ + body: { + domain: `${protocol}://${req.headers.host}`, + link: `${protocol}://${req.headers.host}/reset-password/${token}` + }, + to: user.email + }); + + await mailerService.send(mailOptions); + res.json({ + success: true, + message: + 'If the email is registered with the editor, an email has been sent.' + }); + } catch (err) { + console.log(err); + res.json({ success: false }); + } +}; export const unlinkGithub: RequestHandler< {}, From d61ef5f9809d3a2a1c526ccb87574fc360f814f7 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 12:03:28 +0100 Subject: [PATCH 29/67] user controller: validResetPasswordToken, add test --- .../__tests__/authManagement.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/server/controllers/user.controller/__tests__/authManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement.test.ts index 749567aaf3..43f8a9e1b7 100644 --- a/server/controllers/user.controller/__tests__/authManagement.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement.test.ts @@ -4,6 +4,7 @@ import { NextFunction as MockNext } from 'jest-express/lib/next'; import { User } from '../../../models/user'; import { resetPasswordInitiate, + validateResetPasswordToken, unlinkGithub, unlinkGoogle } from '../../user.controller'; @@ -164,6 +165,54 @@ describe('user.controller > 3rd party auth management', () => { }); }); + describe('validateResetPasswordToken', () => { + const fixedTime = 100000000; + beforeAll(() => jest.useFakeTimers().setSystemTime(fixedTime)); + afterAll(() => jest.useRealTimers()); + + it('returns 401 if no user is found or token has expired', async () => { + User.findOne = jest.fn().mockReturnValue({ + exec: jest.fn().mockResolvedValue(null) + }); + + request.params = { token: 'invalid-token' }; + + await validateResetPasswordToken(request, response); + + expect(User.findOne).toHaveBeenCalledWith({ + resetPasswordToken: 'invalid-token', + resetPasswordExpires: { $gt: fixedTime } + }); + expect(response.status).toHaveBeenCalledWith(401); + expect(response.json).toHaveBeenCalledWith({ + success: false, + message: 'Password reset token is invalid or has expired.' + }); + }); + + it('returns success if a user with a valid token is found', async () => { + const fakeUser = { + email: 'test@example.com', + resetPasswordToken: 'valid-token', + resetPasswordExpires: fixedTime + 10000 // still valid + }; + + User.findOne = jest.fn().mockReturnValue({ + exec: jest.fn().mockResolvedValue(fakeUser) + }); + + request.params = { token: 'valid-token' }; + + await validateResetPasswordToken(request, response); + + expect(User.findOne).toHaveBeenCalledWith({ + resetPasswordToken: 'valid-token', + resetPasswordExpires: { $gt: fixedTime } + }); + expect(response.json).toHaveBeenCalledWith({ success: true }); + }); + }); + describe('unlinkGithub', () => { it('returns 404 if user is not logged in', async () => { await unlinkGithub(request, response, next); From 93695ad84dc299f35cf1dc4b275940b74d3f35f1 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 12:10:47 +0100 Subject: [PATCH 30/67] user controller: validateResetPasswordToken, migrate to /authManagement and add req res types --- server/controllers/user.controller.js | 15 ------------ .../__tests__/authManagement.test.ts | 6 ++--- .../user.controller/authManagement.ts | 23 +++++++++++++++++++ server/routes/user.routes.ts | 8 +------ 4 files changed, 27 insertions(+), 25 deletions(-) diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.js index 01516c999e..3ce15cd5da 100644 --- a/server/controllers/user.controller.js +++ b/server/controllers/user.controller.js @@ -13,21 +13,6 @@ export * from './user.controller/signup'; export * from './user.controller/userPreferences'; export * from './user.controller/authManagement'; -export async function validateResetPasswordToken(req, res) { - const user = await User.findOne({ - resetPasswordToken: req.params.token, - resetPasswordExpires: { $gt: Date.now() } - }).exec(); - if (!user) { - res.status(401).json({ - success: false, - message: 'Password reset token is invalid or has expired.' - }); - return; - } - res.json({ success: true }); -} - export async function updatePassword(req, res) { const user = await User.findOne({ resetPasswordToken: req.params.token, diff --git a/server/controllers/user.controller/__tests__/authManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement.test.ts index 43f8a9e1b7..2e3508a5c3 100644 --- a/server/controllers/user.controller/__tests__/authManagement.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement.test.ts @@ -36,7 +36,7 @@ jest.mock('../helpers', () => ({ generateToken: jest.fn() })); -describe('user.controller > 3rd party auth management', () => { +describe('user.controller > auth management', () => { let request: any; let response: any; let next: MockNext; @@ -177,7 +177,7 @@ describe('user.controller > 3rd party auth management', () => { request.params = { token: 'invalid-token' }; - await validateResetPasswordToken(request, response); + await validateResetPasswordToken(request, response, next); expect(User.findOne).toHaveBeenCalledWith({ resetPasswordToken: 'invalid-token', @@ -203,7 +203,7 @@ describe('user.controller > 3rd party auth management', () => { request.params = { token: 'valid-token' }; - await validateResetPasswordToken(request, response); + await validateResetPasswordToken(request, response, next); expect(User.findOne).toHaveBeenCalledWith({ resetPasswordToken: 'valid-token', diff --git a/server/controllers/user.controller/authManagement.ts b/server/controllers/user.controller/authManagement.ts index ba1794ad00..68b098c4ae 100644 --- a/server/controllers/user.controller/authManagement.ts +++ b/server/controllers/user.controller/authManagement.ts @@ -1,4 +1,5 @@ import { RequestHandler } from 'express'; +import * as core from 'express-serve-static-core'; import { User } from '../../models/user'; import { saveUser, generateToken } from './helpers'; import { PublicUser, GenericResponseBody } from '../../types'; @@ -8,6 +9,10 @@ import { renderResetPassword } from '../../views/mail'; export interface ResetPasswordRequestBody { email: string; } +export interface ValidateResetPasswordRequestParams + extends core.ParamsDictionary { + token: string; +} export const resetPasswordInitiate: RequestHandler< {}, @@ -50,6 +55,24 @@ export const resetPasswordInitiate: RequestHandler< } }; +export const validateResetPasswordToken: RequestHandler = async ( + req, + res +) => { + const user = await User.findOne({ + resetPasswordToken: req.params.token, + resetPasswordExpires: { $gt: Date.now() } + }).exec(); + if (!user) { + res.status(401).json({ + success: false, + message: 'Password reset token is invalid or has expired.' + }); + return; + } + res.json({ success: true }); +}; + export const unlinkGithub: RequestHandler< {}, PublicUser | Error | GenericResponseBody diff --git a/server/routes/user.routes.ts b/server/routes/user.routes.ts index 7daafc1321..aeb31d7aaf 100644 --- a/server/routes/user.routes.ts +++ b/server/routes/user.routes.ts @@ -34,7 +34,7 @@ router.delete( /** * =============== - * PASSWORD MANAGEMENT + * AUTH MANAGEMENT * =============== */ // POST /reset-password @@ -45,12 +45,6 @@ router.get('/reset-password/:token', UserController.validateResetPasswordToken); 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 From b82f9747e705b1dc026cff68e5621d29036592c0 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 12:49:59 +0100 Subject: [PATCH 31/67] add test for user.controller updateSettings --- .../__tests__/authManagement.test.ts | 107 ++++++++++++++++-- .../user.controller/authManagement.ts | 5 +- 2 files changed, 103 insertions(+), 9 deletions(-) diff --git a/server/controllers/user.controller/__tests__/authManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement.test.ts index 2e3508a5c3..20c8c2794a 100644 --- a/server/controllers/user.controller/__tests__/authManagement.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement.test.ts @@ -5,6 +5,7 @@ import { User } from '../../../models/user'; import { resetPasswordInitiate, validateResetPasswordToken, + updateSettings, unlinkGithub, unlinkGoogle } from '../../user.controller'; @@ -23,14 +24,6 @@ jest.mock('../../../utils/mail', () => ({ send: jest.fn() } })); -// jest.mock('../../../views/mail', () => ({ -// renderEmailConfirmation: jest -// .fn() -// .mockReturnValue({ to: 'test@example.com', subject: 'Confirm' }), -// renderResetPassword: jest -// .fn() -// .mockReturnValue({ to: 'test@example.com', subject: 'Reset password' }) -// })); jest.mock('../helpers', () => ({ saveUser: jest.fn(), generateToken: jest.fn() @@ -213,10 +206,107 @@ describe('user.controller > auth management', () => { }); }); + describe('updateSettings', () => { + const fixedTime = 100000000; // arbitrary fixed timestamp + let saveMock: jest.Mock; + let mockUser: Partial; + + beforeAll(() => { + jest.useFakeTimers().setSystemTime(fixedTime); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + describe('if the user is not found', () => { + beforeEach(() => { + User.findById = jest.fn().mockResolvedValue(null); + request.user = { id: 'nonexistent-id' }; + }); + + it('returns 404 and a user-not-found error', async () => { + await updateSettings(request, response); + + expect(response.status).toHaveBeenCalledWith(404); + expect(response.json).toHaveBeenCalledWith({ + error: 'User not found' + }); + }); + it('does not save the user', () => { + expect(saveUser).not.toHaveBeenCalled(); + }); + }); + + // the below tests match the current logic, but logic can be improved + describe('if the user is found', () => { + const startingUser = { + username: 'oldusername', + email: 'old@email.com', + id: 'valid-id' + }; + + beforeEach(() => { + User.findById = jest.fn().mockResolvedValue(startingUser); + request.user = { id: 'valid-id' }; + }); + + describe('and when there is a username in the request', () => { + beforeEach(async () => { + request.setBody({ + username: 'newusername' + }); + await updateSettings(request, response); + }); + it('calls saveUser with the new username', () => { + expect(saveUser).toHaveBeenCalledWith(response, { + ...startingUser, + username: 'newusername' + }); + }); + }); + + // describe('and when there is an email in the request', () => { + // beforeEach(async () => { + // request.setBody({ + // username: 'oldusername', + // email: 'new@email.com' + // }); + // await updateSettings(request, response); + // }); + // it('calls saveUser with the new email', () => { + // expect(saveUser).toHaveBeenCalledWith(response, { + // ...startingUser, + // email: 'new@email.com' + // }); + // }); + // it('sends an email to confirm the email update', () => {}); + // }); + + // currently frontend doesn't seem to call the below + describe('and when there is a newPassword in the request', () => { + describe('and the current password is not provided', () => { + it('returns 401 with a "current password not provided" message', () => {}); + it('does not save the user with the new password', () => {}); + }); + }); + describe('and when there is a currentPassword in the request', () => { + describe('and the current password does not match', () => { + it('returns 401 with a "current password invalid" message', () => {}); + it('does not save the user with the new password', () => {}); + }); + describe('and when the current password does match', () => { + it('calls saveUser with the new password', () => {}); + }); + }); + }); + }); + describe('unlinkGithub', () => { it('returns 404 if user is not logged in', async () => { await unlinkGithub(request, response, next); + expect(saveUser).not.toHaveBeenCalled(); expect(response.status).toHaveBeenCalledWith(404); expect(response.json).toHaveBeenCalledWith({ success: false, @@ -251,6 +341,7 @@ describe('user.controller > auth management', () => { success: false, message: 'You must be logged in to complete this action.' }); + expect(saveUser).not.toHaveBeenCalled(); }); it('removes the users google & filters out google tokens if user is logged in', async () => { const user = { diff --git a/server/controllers/user.controller/authManagement.ts b/server/controllers/user.controller/authManagement.ts index 68b098c4ae..c220e51b24 100644 --- a/server/controllers/user.controller/authManagement.ts +++ b/server/controllers/user.controller/authManagement.ts @@ -50,7 +50,10 @@ export const resetPasswordInitiate: RequestHandler< 'If the email is registered with the editor, an email has been sent.' }); } catch (err) { - console.log(err); + if (process.env.NODE_ENV !== 'test') { + // don't log in test env + console.log(err); + } res.json({ success: false }); } }; From c7cd791ee5241615219e12b1187d6783992ff56e Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 14:58:27 +0100 Subject: [PATCH 32/67] user.controller/authManagement tests: re-organise with clearer describes --- .../__tests__/authManagement.test.ts | 165 +++++++++++------- 1 file changed, 101 insertions(+), 64 deletions(-) diff --git a/server/controllers/user.controller/__tests__/authManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement.test.ts index 20c8c2794a..317f78bc71 100644 --- a/server/controllers/user.controller/__tests__/authManagement.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement.test.ts @@ -47,7 +47,7 @@ describe('user.controller > auth management', () => { }); describe('resetPasswordInitiate', () => { - const fixedTime = 100000000; // arbitrary fixed timestamp + const fixedTime = 100000000; let mockToken: string; let saveMock: jest.Mock; let mockUser: Partial; @@ -60,6 +60,14 @@ describe('user.controller > auth management', () => { jest.useRealTimers(); }); + it('calls User.findByEmail with the correct email', async () => { + User.findByEmail = jest.fn().mockResolvedValue({}); + request.body = { email: 'email@gmail.com' }; + await resetPasswordInitiate(request, response, next); + + expect(User.findByEmail).toHaveBeenCalledWith('email@gmail.com'); + }); + describe('if the user is found', () => { beforeEach(() => { mockToken = 'mock-token'; @@ -163,46 +171,54 @@ describe('user.controller > auth management', () => { beforeAll(() => jest.useFakeTimers().setSystemTime(fixedTime)); afterAll(() => jest.useRealTimers()); - it('returns 401 if no user is found or token has expired', async () => { + it('calls User.findone with the correct token and expiry', async () => { User.findOne = jest.fn().mockReturnValue({ - exec: jest.fn().mockResolvedValue(null) + exec: jest.fn() }); - - request.params = { token: 'invalid-token' }; - + request.params = { token: 'some-token' }; await validateResetPasswordToken(request, response, next); expect(User.findOne).toHaveBeenCalledWith({ - resetPasswordToken: 'invalid-token', + resetPasswordToken: 'some-token', resetPasswordExpires: { $gt: fixedTime } }); - expect(response.status).toHaveBeenCalledWith(401); - expect(response.json).toHaveBeenCalledWith({ - success: false, - message: 'Password reset token is invalid or has expired.' - }); }); - it('returns success if a user with a valid token is found', async () => { - const fakeUser = { - email: 'test@example.com', - resetPasswordToken: 'valid-token', - resetPasswordExpires: fixedTime + 10000 // still valid - }; - - User.findOne = jest.fn().mockReturnValue({ - exec: jest.fn().mockResolvedValue(fakeUser) + describe('and when no user is found', () => { + beforeEach(async () => { + User.findOne = jest.fn().mockReturnValue({ + exec: jest.fn().mockResolvedValue(null) + }); + request.params = { token: 'invalid-token' }; + await validateResetPasswordToken(request, response, next); }); + it('returns a 401', () => { + expect(response.status).toHaveBeenCalledWith(401); + }); + it('returns a "invalid or expired" token message', () => { + expect(response.json).toHaveBeenCalledWith({ + success: false, + message: 'Password reset token is invalid or has expired.' + }); + }); + }); - request.params = { token: 'valid-token' }; - - await validateResetPasswordToken(request, response, next); - - expect(User.findOne).toHaveBeenCalledWith({ - resetPasswordToken: 'valid-token', - resetPasswordExpires: { $gt: fixedTime } + describe('and when there is a user with valid token', () => { + beforeEach(async () => { + const fakeUser = { + email: 'test@example.com', + resetPasswordToken: 'valid-token', + resetPasswordExpires: fixedTime + 10000 // still valid + }; + User.findOne = jest.fn().mockReturnValue({ + exec: jest.fn().mockResolvedValue(fakeUser) + }); + request.params = { token: 'valid-token' }; + await validateResetPasswordToken(request, response, next); + }); + it('returns a success response', () => { + expect(response.json).toHaveBeenCalledWith({ success: true }); }); - expect(response.json).toHaveBeenCalledWith({ success: true }); }); }); @@ -220,14 +236,13 @@ describe('user.controller > auth management', () => { }); describe('if the user is not found', () => { - beforeEach(() => { + beforeEach(async () => { User.findById = jest.fn().mockResolvedValue(null); request.user = { id: 'nonexistent-id' }; + await updateSettings(request, response); }); it('returns 404 and a user-not-found error', async () => { - await updateSettings(request, response); - expect(response.status).toHaveBeenCalledWith(404); expect(response.json).toHaveBeenCalledWith({ error: 'User not found' @@ -303,17 +318,22 @@ describe('user.controller > auth management', () => { }); describe('unlinkGithub', () => { - it('returns 404 if user is not logged in', async () => { - await unlinkGithub(request, response, next); - - expect(saveUser).not.toHaveBeenCalled(); - expect(response.status).toHaveBeenCalledWith(404); - expect(response.json).toHaveBeenCalledWith({ - success: false, - message: 'You must be logged in to complete this action.' + describe('and when there is no user in the request', () => { + beforeEach(async () => { + await unlinkGithub(request, response, next); + }); + it('does not call saveUser', () => { + expect(saveUser).not.toHaveBeenCalled(); + }); + it('returns a 404 with the correct status and message', () => { + expect(response.status).toHaveBeenCalledWith(404); + expect(response.json).toHaveBeenCalledWith({ + success: false, + message: 'You must be logged in to complete this action.' + }); }); }); - it('removes the users github & filters out github tokens if user is logged in', async () => { + describe('and when there is a user in the request', () => { const user = { github: { id: '123', username: 'testuser' }, tokens: [ @@ -322,28 +342,39 @@ describe('user.controller > auth management', () => { ] }; - request.user = user; - - await unlinkGithub(request, response, next); - - expect(user.github).toBeUndefined(); - expect(user.tokens).toEqual([{ kind: 'google', accessToken: 'xyz' }]); - expect(saveUser).toHaveBeenCalledWith(response, user); + beforeEach(async () => { + request.user = user; + await unlinkGithub(request, response, next); + }); + it('removes the users github property', () => { + expect(user.github).toBeUndefined(); + }); + it('filters out the github token', () => { + expect(user.tokens).toEqual([{ kind: 'google', accessToken: 'xyz' }]); + }); + it('does calls saveUser', () => { + expect(saveUser).toHaveBeenCalledWith(response, user); + }); }); }); describe('unlinkGoogle', () => { - it('returns 404 if user is not logged in', async () => { - await unlinkGoogle(request, response, next); - - expect(response.status).toHaveBeenCalledWith(404); - expect(response.json).toHaveBeenCalledWith({ - success: false, - message: 'You must be logged in to complete this action.' + describe('and when there is no user in the request', () => { + beforeEach(async () => { + await unlinkGoogle(request, response, next); + }); + it('does not call saveUser', () => { + expect(saveUser).not.toHaveBeenCalled(); + }); + it('returns a 404 with the correct status and message', () => { + expect(response.status).toHaveBeenCalledWith(404); + expect(response.json).toHaveBeenCalledWith({ + success: false, + message: 'You must be logged in to complete this action.' + }); }); - expect(saveUser).not.toHaveBeenCalled(); }); - it('removes the users google & filters out google tokens if user is logged in', async () => { + describe('and when there is a user in the request', () => { const user = { google: { id: '123', username: 'testuser' }, tokens: [ @@ -352,13 +383,19 @@ describe('user.controller > auth management', () => { ] }; - request.user = user; - - await unlinkGoogle(request, response, next); - - expect(user.google).toBeUndefined(); - expect(user.tokens).toEqual([{ kind: 'github', accessToken: 'abc' }]); - expect(saveUser).toHaveBeenCalledWith(response, user); + beforeEach(async () => { + request.user = user; + await unlinkGoogle(request, response, next); + }); + it('removes the users google property', () => { + expect(user.google).toBeUndefined(); + }); + it('filters out the google token', () => { + expect(user.tokens).toEqual([{ kind: 'github', accessToken: 'abc' }]); + }); + it('does calls saveUser', () => { + expect(saveUser).toHaveBeenCalledWith(response, user); + }); }); }); }); From 12a98d9479c57c1430f96a98c835a4000da3b6c4 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 15:14:01 +0100 Subject: [PATCH 33/67] user.controller updatePassword: add tests --- .../__tests__/authManagement.test.ts | 71 ++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/server/controllers/user.controller/__tests__/authManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement.test.ts index 317f78bc71..1ae861f1b1 100644 --- a/server/controllers/user.controller/__tests__/authManagement.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement.test.ts @@ -5,11 +5,12 @@ import { User } from '../../../models/user'; import { resetPasswordInitiate, validateResetPasswordToken, + updatePassword, updateSettings, unlinkGithub, unlinkGoogle } from '../../user.controller'; -import { saveUser, generateToken } from '../helpers'; +import { saveUser, generateToken, userResponse } from '../helpers'; import { mailerService } from '../../../utils/mail'; import { @@ -222,6 +223,74 @@ describe('user.controller > auth management', () => { }); }); + describe('updatePassword', () => { + const fixedTime = 100000000; + beforeAll(() => jest.useFakeTimers().setSystemTime(fixedTime)); + afterAll(() => jest.useRealTimers()); + + it('calls User.findone with the correct token and expiry', async () => { + User.findOne = jest.fn().mockReturnValue({ + exec: jest.fn() + }); + request.params = { token: 'some-token' }; + await updatePassword(request, response); + + expect(User.findOne).toHaveBeenCalledWith({ + resetPasswordToken: 'some-token', + resetPasswordExpires: { $gt: fixedTime } + }); + }); + + describe('and when no user is found', () => { + beforeEach(async () => { + User.findOne = jest.fn().mockReturnValue({ + exec: jest.fn().mockResolvedValue(null) + }); + request.params = { token: 'invalid-token' }; + await updatePassword(request, response); + }); + it('returns a 401', () => { + expect(response.status).toHaveBeenCalledWith(401); + }); + it('returns a "invalid or expired" token message', () => { + expect(response.json).toHaveBeenCalledWith({ + success: false, + message: 'Password reset token is invalid or has expired.' + }); + }); + }); + + describe('and when there is a user with valid token', () => { + const fakeUser = { + email: 'test@example.com', + password: 'oldpassword', + resetPasswordToken: 'valid-token', + resetPasswordExpires: fixedTime + 10000, // still valid + save: jest.fn() + }; + beforeEach(async () => { + User.findOne = jest.fn().mockReturnValue({ + exec: jest.fn().mockResolvedValue(fakeUser) + }); + request.params = { token: 'valid-token' }; + request.setBody({ + password: 'newpassword' + }); + request.logIn = jest.fn(); + await updatePassword(request, response); + }); + it('calls user.save with the updated password and removes the reset password token', () => { + expect(fakeUser.password).toBe('newpassword'); + expect(fakeUser.resetPasswordToken).toBeUndefined(); + expect(fakeUser.resetPasswordExpires).toBeUndefined(); + expect(fakeUser.save).toHaveBeenCalled(); + }); + // it('returns a success response with the sanitised user', () => { + // expect(response.json).toHaveBeenCalledWith({ success: true }); + // }); + }); + }); + describe('updateSettings', () => { const fixedTime = 100000000; // arbitrary fixed timestamp let saveMock: jest.Mock; From 498cfe4d1f83fb19e5bc79872ed86e7b1605948a Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 15:22:05 +0100 Subject: [PATCH 34/67] user.controller updatePassword: migrate to /authManagement, add req and res types --- server/controllers/user.controller.js | 22 ------------ .../__tests__/authManagement.test.ts | 6 ++-- .../user.controller/authManagement.ts | 35 +++++++++++++++++-- 3 files changed, 35 insertions(+), 28 deletions(-) diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.js index 3ce15cd5da..2ac239833b 100644 --- a/server/controllers/user.controller.js +++ b/server/controllers/user.controller.js @@ -13,28 +13,6 @@ export * from './user.controller/signup'; export * from './user.controller/userPreferences'; export * from './user.controller/authManagement'; -export async function updatePassword(req, res) { - const user = await User.findOne({ - resetPasswordToken: req.params.token, - resetPasswordExpires: { $gt: Date.now() } - }).exec(); - if (!user) { - res.status(401).json({ - success: false, - message: 'Password reset token is invalid or has expired.' - }); - return; - } - - user.password = req.body.password; - user.resetPasswordToken = undefined; - user.resetPasswordExpires = undefined; - - await user.save(); - req.logIn(user, (loginErr) => res.json(userResponse(req.user))); - // eventually send email that the password has been reset -} - /** * @param {string} username * @return {Promise} diff --git a/server/controllers/user.controller/__tests__/authManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement.test.ts index 1ae861f1b1..e618c5658a 100644 --- a/server/controllers/user.controller/__tests__/authManagement.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement.test.ts @@ -233,7 +233,7 @@ describe('user.controller > auth management', () => { exec: jest.fn() }); request.params = { token: 'some-token' }; - await updatePassword(request, response); + await updatePassword(request, response, next); expect(User.findOne).toHaveBeenCalledWith({ resetPasswordToken: 'some-token', @@ -247,7 +247,7 @@ describe('user.controller > auth management', () => { exec: jest.fn().mockResolvedValue(null) }); request.params = { token: 'invalid-token' }; - await updatePassword(request, response); + await updatePassword(request, response, next); }); it('returns a 401', () => { expect(response.status).toHaveBeenCalledWith(401); @@ -277,7 +277,7 @@ describe('user.controller > auth management', () => { password: 'newpassword' }); request.logIn = jest.fn(); - await updatePassword(request, response); + await updatePassword(request, response, next); }); it('calls user.save with the updated password and removes the reset password token', () => { expect(fakeUser.password).toBe('newpassword'); diff --git a/server/controllers/user.controller/authManagement.ts b/server/controllers/user.controller/authManagement.ts index c220e51b24..7e6912addf 100644 --- a/server/controllers/user.controller/authManagement.ts +++ b/server/controllers/user.controller/authManagement.ts @@ -1,7 +1,7 @@ import { RequestHandler } from 'express'; import * as core from 'express-serve-static-core'; import { User } from '../../models/user'; -import { saveUser, generateToken } from './helpers'; +import { saveUser, generateToken, userResponse } from './helpers'; import { PublicUser, GenericResponseBody } from '../../types'; import { mailerService } from '../../utils/mail'; import { renderResetPassword } from '../../views/mail'; @@ -9,10 +9,13 @@ import { renderResetPassword } from '../../views/mail'; export interface ResetPasswordRequestBody { email: string; } -export interface ValidateResetPasswordRequestParams +export interface ResetOrUpdatePasswordRequestParams extends core.ParamsDictionary { token: string; } +export interface UpdatePasswordRequestBody { + password: string; +} export const resetPasswordInitiate: RequestHandler< {}, @@ -58,7 +61,7 @@ export const resetPasswordInitiate: RequestHandler< } }; -export const validateResetPasswordToken: RequestHandler = async ( +export const validateResetPasswordToken: RequestHandler = async ( req, res ) => { @@ -76,6 +79,32 @@ export const validateResetPasswordToken: RequestHandler = async (req, res) => { + const user = await User.findOne({ + resetPasswordToken: req.params.token, + resetPasswordExpires: { $gt: Date.now() } + }).exec(); + if (!user) { + res.status(401).json({ + success: false, + message: 'Password reset token is invalid or has expired.' + }); + return; + } + + user.password = req.body.password; + user.resetPasswordToken = undefined; + user.resetPasswordExpires = undefined; + + await user.save(); + req.logIn(user, (loginErr) => res.json(userResponse(req.user!))); + // eventually send email that the password has been reset +}; + export const unlinkGithub: RequestHandler< {}, PublicUser | Error | GenericResponseBody From e845914f4988d30932df017b87dea8e06612c060 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 15:32:31 +0100 Subject: [PATCH 35/67] updateSettings: migrate to /authManagement, add req and res types, no-verify due to logic in password checks --- server/controllers/user.controller.js | 57 -------------- .../__tests__/authManagement.test.ts | 4 +- .../user.controller/authManagement.ts | 75 ++++++++++++++++++- server/routes/user.routes.ts | 2 +- 4 files changed, 76 insertions(+), 62 deletions(-) diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.js index 2ac239833b..1ce52dda56 100644 --- a/server/controllers/user.controller.js +++ b/server/controllers/user.controller.js @@ -21,60 +21,3 @@ export async function userExists(username) { const user = await User.findByUsername(username); return user != null; } - -export async function updateSettings(req, res) { - try { - const user = await User.findById(req.user.id); - if (!user) { - res.status(404).json({ error: 'User not found' }); - return; - } - user.username = req.body.username; - - if (req.body.newPassword) { - if (user.password === undefined) { - user.password = req.body.newPassword; - saveUser(res, user); - } - if (!req.body.currentPassword) { - res.status(401).json({ error: 'Current password is not provided.' }); - return; - } - } - if (req.body.currentPassword) { - const isMatch = await user.comparePassword(req.body.currentPassword); - if (!isMatch) { - res.status(401).json({ error: 'Current password is invalid.' }); - return; - } - user.password = req.body.newPassword; - await saveUser(res, user); - } else if (user.email !== req.body.email) { - const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours - user.verified = User.EmailConfirmation().Sent; - - user.email = req.body.email; - - const token = await generateToken(); - user.verifiedToken = token; - user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; - - await saveUser(res, user); - - const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; - const mailOptions = renderEmailConfirmation({ - body: { - domain: `${protocol}://${req.headers.host}`, - link: `${protocol}://${req.headers.host}/verify?t=${token}` - }, - to: user.email - }); - - await mailerService.send(mailOptions); - } else { - await saveUser(res, user); - } - } catch (err) { - res.status(500).json({ error: err }); - } -} diff --git a/server/controllers/user.controller/__tests__/authManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement.test.ts index e618c5658a..465b4d81f3 100644 --- a/server/controllers/user.controller/__tests__/authManagement.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement.test.ts @@ -308,7 +308,7 @@ describe('user.controller > auth management', () => { beforeEach(async () => { User.findById = jest.fn().mockResolvedValue(null); request.user = { id: 'nonexistent-id' }; - await updateSettings(request, response); + await updateSettings(request, response, next); }); it('returns 404 and a user-not-found error', async () => { @@ -340,7 +340,7 @@ describe('user.controller > auth management', () => { request.setBody({ username: 'newusername' }); - await updateSettings(request, response); + await updateSettings(request, response, next); }); it('calls saveUser with the new username', () => { expect(saveUser).toHaveBeenCalledWith(response, { diff --git a/server/controllers/user.controller/authManagement.ts b/server/controllers/user.controller/authManagement.ts index 7e6912addf..2602932ee3 100644 --- a/server/controllers/user.controller/authManagement.ts +++ b/server/controllers/user.controller/authManagement.ts @@ -2,9 +2,14 @@ import { RequestHandler } from 'express'; import * as core from 'express-serve-static-core'; import { User } from '../../models/user'; import { saveUser, generateToken, userResponse } from './helpers'; -import { PublicUser, GenericResponseBody } from '../../types'; +import { + PublicUser, + GenericResponseBody, + Error, + SanitisedApiKey +} from '../../types'; import { mailerService } from '../../utils/mail'; -import { renderResetPassword } from '../../views/mail'; +import { renderResetPassword, renderEmailConfirmation } from '../../views/mail'; export interface ResetPasswordRequestBody { email: string; @@ -105,6 +110,72 @@ export const updatePassword: RequestHandler< // eventually send email that the password has been reset }; +export const updateSettings: RequestHandler< + {}, + Error | SanitisedApiKey, + { + username: string; + email: string; + newPassword?: string; + currentPassword?: string; + } +> = async (req, res) => { + try { + const user = await User.findById(req.user!.id); + if (!user) { + res.status(404).json({ error: 'User not found' }); + return; + } + user.username = req.body.username; + + if (req.body.newPassword) { + if (user.password === undefined) { + user.password = req.body.newPassword; + saveUser(res, user); + } + if (!req.body.currentPassword) { + res.status(401).json({ error: 'Current password is not provided.' }); + return; + } + } + if (req.body.currentPassword) { + const isMatch = await user.comparePassword(req.body.currentPassword); + if (!isMatch) { + res.status(401).json({ error: 'Current password is invalid.' }); + return; + } + user.password = req.body.newPassword; + await saveUser(res, user); + } else if (user.email !== req.body.email) { + const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours + user.verified = User.EmailConfirmation().Sent; + + user.email = req.body.email; + + const token = await generateToken(); + user.verifiedToken = token; + user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; + + await saveUser(res, user); + + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + const mailOptions = renderEmailConfirmation({ + body: { + domain: `${protocol}://${req.headers.host}`, + link: `${protocol}://${req.headers.host}/verify?t=${token}` + }, + to: user.email + }); + + await mailerService.send(mailOptions); + } else { + await saveUser(res, user); + } + } catch (err) { + res.status(500).json({ error: err }); + } +}; + export const unlinkGithub: RequestHandler< {}, PublicUser | Error | GenericResponseBody diff --git a/server/routes/user.routes.ts b/server/routes/user.routes.ts index aeb31d7aaf..26fdbb2589 100644 --- a/server/routes/user.routes.ts +++ b/server/routes/user.routes.ts @@ -43,7 +43,7 @@ router.post('/reset-password', UserController.resetPasswordInitiate); router.get('/reset-password/:token', UserController.validateResetPasswordToken); // POST /reset-password/:token router.post('/reset-password/:token', UserController.updatePassword); -// PUT /account (updating password) +// PUT /account (updating username, email or password while logged in) router.put('/account', isAuthenticated, UserController.updateSettings); // DELETE /auth/github router.delete('/auth/github', UserController.unlinkGithub); From 544bcb9db0221f680f78b918e28ed1d77ed39a40 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 15:37:45 +0100 Subject: [PATCH 36/67] user.controller: migrate userExists to helpers & clean up file --- server/controllers/user.controller.js | 20 +------------------ server/controllers/user.controller/helpers.ts | 11 ++++++++++ 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.js index 1ce52dda56..f16459e67d 100644 --- a/server/controllers/user.controller.js +++ b/server/controllers/user.controller.js @@ -1,23 +1,5 @@ -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 * from './user.controller/signup'; export * from './user.controller/userPreferences'; export * from './user.controller/authManagement'; - -/** - * @param {string} username - * @return {Promise} - */ -export async function userExists(username) { - const user = await User.findByUsername(username); - return user != null; -} +export * from './user.controller/helpers'; diff --git a/server/controllers/user.controller/helpers.ts b/server/controllers/user.controller/helpers.ts index 05554f6914..0aad047b50 100644 --- a/server/controllers/user.controller/helpers.ts +++ b/server/controllers/user.controller/helpers.ts @@ -1,5 +1,6 @@ import crypto from 'crypto'; import type { Response } from 'express'; +import { User } from '../../models/user'; import { PublicUser, UserDocument } from '../../types'; /** @@ -56,3 +57,13 @@ export async function saveUser(res: Response, user: UserDocument) { res.status(500).json({ error }); } } + +/** + * Helper used in other controllers to check if user by username exists. + * @param {string} username + * @return {Promise} + */ +export async function userExists(username: string) { + const user = await User.findByUsername(username); + return user != null; +} From c496b67e4946421c391457c592a8f754020d3ac2 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 15:42:25 +0100 Subject: [PATCH 37/67] migrate user.controller file into index file --- server/controllers/user.controller.js | 5 ----- .../user.controller/__tests__/authManagement.test.ts | 6 +----- server/controllers/user.controller/__tests__/signup.test.ts | 3 +-- .../user.controller/__tests__/userPreferences.test.ts | 4 ++-- server/controllers/user.controller/index.ts | 5 +++++ 5 files changed, 9 insertions(+), 14 deletions(-) delete mode 100644 server/controllers/user.controller.js create mode 100644 server/controllers/user.controller/index.ts diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.js deleted file mode 100644 index f16459e67d..0000000000 --- a/server/controllers/user.controller.js +++ /dev/null @@ -1,5 +0,0 @@ -export * from './user.controller/apiKey'; -export * from './user.controller/signup'; -export * from './user.controller/userPreferences'; -export * from './user.controller/authManagement'; -export * from './user.controller/helpers'; diff --git a/server/controllers/user.controller/__tests__/authManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement.test.ts index 465b4d81f3..27f210f0b2 100644 --- a/server/controllers/user.controller/__tests__/authManagement.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement.test.ts @@ -9,14 +9,10 @@ import { updateSettings, unlinkGithub, unlinkGoogle -} from '../../user.controller'; +} from '../authManagement'; import { saveUser, generateToken, userResponse } from '../helpers'; import { mailerService } from '../../../utils/mail'; -import { - renderEmailConfirmation, - renderResetPassword -} from '../../../views/mail'; import { UserDocument } from '../../../types'; jest.mock('../../../models/user'); diff --git a/server/controllers/user.controller/__tests__/signup.test.ts b/server/controllers/user.controller/__tests__/signup.test.ts index 04cfa71ec1..1c91506d9c 100644 --- a/server/controllers/user.controller/__tests__/signup.test.ts +++ b/server/controllers/user.controller/__tests__/signup.test.ts @@ -7,10 +7,9 @@ import { duplicateUserCheck, verifyEmail, emailVerificationInitiate -} from '../../user.controller'; +} from '../signup'; import { mailerService } from '../../../utils/mail'; -import { renderEmailConfirmation } from '../../../views/mail'; jest.mock('../../../models/user'); jest.mock('../../../utils/mail', () => ({ diff --git a/server/controllers/user.controller/__tests__/userPreferences.test.ts b/server/controllers/user.controller/__tests__/userPreferences.test.ts index 2db7d0051a..de9192b798 100644 --- a/server/controllers/user.controller/__tests__/userPreferences.test.ts +++ b/server/controllers/user.controller/__tests__/userPreferences.test.ts @@ -3,9 +3,9 @@ import { Response as MockResponse } from 'jest-express/lib/response'; import { NextFunction as MockNext } from 'jest-express/lib/next'; import { Types } from 'mongoose'; import { User } from '../../../models/user'; -import { updatePreferences, updateCookieConsent } from '../../user.controller'; +import { updatePreferences, updateCookieConsent } from '../userPreferences'; import { ApiKeyDocument } from '../../../types'; -import { CookieConsentOptions, AppThemeOptions } from '../../../types'; +import { CookieConsentOptions } from '../../../types'; jest.mock('../../../models/user'); jest.mock('../../../utils/mail', () => ({ diff --git a/server/controllers/user.controller/index.ts b/server/controllers/user.controller/index.ts new file mode 100644 index 0000000000..3a7dc9b141 --- /dev/null +++ b/server/controllers/user.controller/index.ts @@ -0,0 +1,5 @@ +export * from './apiKey'; +export * from './authManagement'; +export * from './helpers'; +export * from './signup'; +export * from './userPreferences'; From 08fccf76cde7ffec29b659202fe3a48531f1c1c0 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 16:08:11 +0100 Subject: [PATCH 38/67] migrate types for userPreferences to types folder --- .../user.controller/userPreferences.ts | 19 ++++++------------- server/types/user.ts | 8 ++++++++ server/types/userPreferences.ts | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/server/controllers/user.controller/userPreferences.ts b/server/controllers/user.controller/userPreferences.ts index 0ba6e141c8..a60fe4c64f 100644 --- a/server/controllers/user.controller/userPreferences.ts +++ b/server/controllers/user.controller/userPreferences.ts @@ -1,23 +1,16 @@ import { RequestHandler } from 'express'; import { User } from '../../models/user'; import { - Error, - UserPreferences, - PublicUser, - CookieConsentOptions + UpdatePreferencesRequestBody, + UpdateCookieConsentRequestBody, + UpdatePreferencesResponseBody, + PublicUserOrError } from '../../types'; import { saveUser } from './helpers'; -export interface UpdatePreferencesRequestBody { - preferences: UserPreferences; -} -export interface UpdateCookieConsentRequestBody { - cookieConsent: CookieConsentOptions; -} - export const updatePreferences: RequestHandler< {}, - UserPreferences | Error, + UpdatePreferencesResponseBody, UpdatePreferencesRequestBody > = async (req, res) => { try { @@ -37,7 +30,7 @@ export const updatePreferences: RequestHandler< export const updateCookieConsent: RequestHandler< {}, - PublicUser | Error, + PublicUserOrError, UpdateCookieConsentRequestBody > = async (req, res) => { try { diff --git a/server/types/user.ts b/server/types/user.ts index 7467d7ec9a..eaebf1d67d 100644 --- a/server/types/user.ts +++ b/server/types/user.ts @@ -3,6 +3,7 @@ import { VirtualId, MongooseTimestamps } from './mongoose'; import { UserPreferences, CookieConsentOptions } from './userPreferences'; import { EmailConfirmationStates } from './email'; import { ApiKeyDocument } from './apiKey'; +import { Error } from './express'; /** Full User interface */ export interface IUser extends VirtualId, MongooseTimestamps { @@ -74,3 +75,10 @@ export interface UserModel extends Model { EmailConfirmation(): typeof EmailConfirmationStates; } + +// HTTP: +/** + * Response body used for User related routes + * Contains either the Public (sanitised) User or an Error + */ +export type PublicUserOrError = PublicUser | Error; diff --git a/server/types/userPreferences.ts b/server/types/userPreferences.ts index 20fe3b116f..eca5f4ca41 100644 --- a/server/types/userPreferences.ts +++ b/server/types/userPreferences.ts @@ -1,3 +1,5 @@ +import { Error } from './express'; + export enum AppThemeOptions { LIGHT = 'light', DARK = 'dark', @@ -26,3 +28,20 @@ export enum CookieConsentOptions { ESSENTIAL = 'essential', ALL = 'all' } + +// HTTP: + +export type UpdatePreferencesResponseBody = UserPreferences | Error; +/** + * user.controller.updatePreferences + */ +export interface UpdatePreferencesRequestBody { + preferences: UserPreferences; +} + +/** + * user.controller.updateCookieConsent + */ +export interface UpdateCookieConsentRequestBody { + cookieConsent: CookieConsentOptions; +} From 5fadd83501f7f81ff2e6ee1e1de569a96aaada8d Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 16:11:12 +0100 Subject: [PATCH 39/67] migrate types for user controller/signup to types folder --- server/controllers/user.controller/signup.ts | 26 ++++++-------------- server/types/user.ts | 16 ++++++++++++ 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/server/controllers/user.controller/signup.ts b/server/controllers/user.controller/signup.ts index 0eff4f918b..3261ace7e2 100644 --- a/server/controllers/user.controller/signup.ts +++ b/server/controllers/user.controller/signup.ts @@ -3,26 +3,16 @@ import { User } from '../../models/user'; import { generateToken, userResponse } from './helpers'; import { renderEmailConfirmation } from '../../views/mail'; import { mailerService } from '../../utils/mail'; -import { Error, PublicUser } from '../../types'; - -export interface CreateUserRequestBody { - username: string; - email: string; - password: string; -} -export interface DuplicateUserCheckQuery { - // eslint-disable-next-line camelcase - check_type: 'email' | 'username'; - email?: string; - username?: string; -} -export interface VerifyEmailQuery { - t: string; -} +import { + PublicUserOrError, + CreateUserRequestBody, + DuplicateUserCheckQuery, + VerifyEmailQuery +} from '../../types'; export const createUser: RequestHandler< {}, - PublicUser | Error, + PublicUserOrError, CreateUserRequestBody > = async (req, res) => { try { @@ -129,7 +119,7 @@ export const verifyEmail: RequestHandler<{}, {}, {}, VerifyEmailQuery> = async ( export const emailVerificationInitiate: RequestHandler< {}, - PublicUser | Error + PublicUserOrError > = async (req, res) => { try { const token = await generateToken(); diff --git a/server/types/user.ts b/server/types/user.ts index eaebf1d67d..36e16c8e56 100644 --- a/server/types/user.ts +++ b/server/types/user.ts @@ -82,3 +82,19 @@ export interface UserModel extends Model { * Contains either the Public (sanitised) User or an Error */ export type PublicUserOrError = PublicUser | Error; + +// signup: +export interface CreateUserRequestBody { + username: string; + email: string; + password: string; +} +export interface DuplicateUserCheckQuery { + // eslint-disable-next-line camelcase + check_type: 'email' | 'username'; + email?: string; + username?: string; +} +export interface VerifyEmailQuery { + t: string; +} From 2c14442b330644a4730c4d7e72d9ae874c21cd38 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 16:41:56 +0100 Subject: [PATCH 40/67] migrate types for usercontroller/apikey to types folder --- server/controllers/user.controller/apiKey.ts | 21 +++++++------------- server/types/apiKey.ts | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/server/controllers/user.controller/apiKey.ts b/server/controllers/user.controller/apiKey.ts index ec20340e78..a17544621f 100644 --- a/server/controllers/user.controller/apiKey.ts +++ b/server/controllers/user.controller/apiKey.ts @@ -1,18 +1,11 @@ import crypto from 'crypto'; import { RequestHandler } from 'express'; -import * as core from 'express-serve-static-core'; import { User } from '../../models/user'; -import type { ApiKeyDocument, Error } from '../../types'; - -export interface ApiKeyResponseBody { - apiKeys: ApiKeyDocument[]; -} -export interface CreateApiKeyRequestBody { - label: string; -} -export interface RemoveApiKeyRequestParams extends core.ParamsDictionary { - keyId: string; -} +import type { + ApiKeyResponseOrError, + CreateApiKeyRequestBody, + RemoveApiKeyRequestParams +} from '../../types'; /** * Generates a unique token to be used as a Personal Access Token @@ -33,7 +26,7 @@ function generateApiKey(): Promise { /** POST /account/api-keys, UserController.createApiKey */ export const createApiKey: RequestHandler< {}, - ApiKeyResponseBody | Error, + ApiKeyResponseOrError, CreateApiKeyRequestBody > = async (req, res) => { function sendFailure(code: number, error: string) { @@ -85,7 +78,7 @@ export const createApiKey: RequestHandler< /** DELETE /account/api-keys/:keyId, UserController.removeApiKey */ export const removeApiKey: RequestHandler< RemoveApiKeyRequestParams, - ApiKeyResponseBody | Error + ApiKeyResponseOrError > = async (req, res) => { function sendFailure(code: number, error: string) { res.status(code).json({ error }); diff --git a/server/types/apiKey.ts b/server/types/apiKey.ts index 10fd86a1f4..2684cf802b 100644 --- a/server/types/apiKey.ts +++ b/server/types/apiKey.ts @@ -1,5 +1,7 @@ import { Model, Document, Types } from 'mongoose'; +import * as core from 'express-serve-static-core'; import { VirtualId, MongooseTimestamps } from './mongoose'; +import { Error } from './express'; /** Full Api Key interface */ export interface IApiKey extends VirtualId, MongooseTimestamps { @@ -25,3 +27,21 @@ export interface SanitisedApiKey /** Mongoose model for API Key */ export interface ApiKeyModel extends Model {} + +// HTTP +/** + * Response body for userController.createApiKey & userController.removeApiKey + * - Either an ApiKeyResponse or Error + */ +export type ApiKeyResponseOrError = ApiKeyResponse | Error; + +export interface ApiKeyResponse { + apiKeys: ApiKeyDocument[]; +} + +export interface CreateApiKeyRequestBody { + label: string; +} +export interface RemoveApiKeyRequestParams extends core.ParamsDictionary { + keyId: string; +} From 2e1bbe433fea9d9555eddeba5184c12175be038a Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 16:42:51 +0100 Subject: [PATCH 41/67] migrate tpyes for usercontroller/authManagement to types folder --- .../user.controller/authManagement.ts | 38 ++++++++----------- server/controllers/user.controller/helpers.ts | 3 +- server/types/user.ts | 35 ++++++++++++++++- 3 files changed, 51 insertions(+), 25 deletions(-) diff --git a/server/controllers/user.controller/authManagement.ts b/server/controllers/user.controller/authManagement.ts index 2602932ee3..92c1f5247c 100644 --- a/server/controllers/user.controller/authManagement.ts +++ b/server/controllers/user.controller/authManagement.ts @@ -3,29 +3,21 @@ import * as core from 'express-serve-static-core'; import { User } from '../../models/user'; import { saveUser, generateToken, userResponse } from './helpers'; import { - PublicUser, GenericResponseBody, - Error, - SanitisedApiKey + PublicUserOrErrorOrGeneric, + UnlinkThirdPartyResponseBody, + PublicUserOrError, + ResetPasswordInitiateRequestBody, + ResetOrUpdatePasswordRequestParams, + UpdatePasswordRequestBody } from '../../types'; import { mailerService } from '../../utils/mail'; import { renderResetPassword, renderEmailConfirmation } from '../../views/mail'; -export interface ResetPasswordRequestBody { - email: string; -} -export interface ResetOrUpdatePasswordRequestParams - extends core.ParamsDictionary { - token: string; -} -export interface UpdatePasswordRequestBody { - password: string; -} - export const resetPasswordInitiate: RequestHandler< {}, GenericResponseBody, - ResetPasswordRequestBody + ResetPasswordInitiateRequestBody > = async (req, res) => { try { const token = await generateToken(); @@ -66,10 +58,10 @@ export const resetPasswordInitiate: RequestHandler< } }; -export const validateResetPasswordToken: RequestHandler = async ( - req, - res -) => { +export const validateResetPasswordToken: RequestHandler< + ResetOrUpdatePasswordRequestParams, + GenericResponseBody +> = async (req, res) => { const user = await User.findOne({ resetPasswordToken: req.params.token, resetPasswordExpires: { $gt: Date.now() } @@ -86,7 +78,7 @@ export const validateResetPasswordToken: RequestHandler = async (req, res) => { const user = await User.findOne({ @@ -112,7 +104,7 @@ export const updatePassword: RequestHandler< export const updateSettings: RequestHandler< {}, - Error | SanitisedApiKey, + PublicUserOrError, { username: string; email: string; @@ -178,7 +170,7 @@ export const updateSettings: RequestHandler< export const unlinkGithub: RequestHandler< {}, - PublicUser | Error | GenericResponseBody + UnlinkThirdPartyResponseBody > = async (req, res) => { if (req.user) { req.user.github = undefined; @@ -196,7 +188,7 @@ export const unlinkGithub: RequestHandler< export const unlinkGoogle: RequestHandler< {}, - PublicUser | Error | GenericResponseBody + UnlinkThirdPartyResponseBody > = async (req, res) => { if (req.user) { req.user.google = undefined; diff --git a/server/controllers/user.controller/helpers.ts b/server/controllers/user.controller/helpers.ts index 0aad047b50..e921a97d6e 100644 --- a/server/controllers/user.controller/helpers.ts +++ b/server/controllers/user.controller/helpers.ts @@ -45,7 +45,8 @@ export async function generateToken(): Promise { /** * Updates the user object and sets the response. - * Response is the sanitised user or a 500 error. + * Response is of type PublicUserOrError + * - The sanitised user or a 500 error. * @param res * @param user */ diff --git a/server/types/user.ts b/server/types/user.ts index 36e16c8e56..33fdb9d374 100644 --- a/server/types/user.ts +++ b/server/types/user.ts @@ -1,9 +1,10 @@ import { Document, Model, Types } from 'mongoose'; +import * as core from 'express-serve-static-core'; import { VirtualId, MongooseTimestamps } from './mongoose'; import { UserPreferences, CookieConsentOptions } from './userPreferences'; import { EmailConfirmationStates } from './email'; import { ApiKeyDocument } from './apiKey'; -import { Error } from './express'; +import { Error, GenericResponseBody } from './express'; /** Full User interface */ export interface IUser extends VirtualId, MongooseTimestamps { @@ -83,6 +84,38 @@ export interface UserModel extends Model { */ export type PublicUserOrError = PublicUser | Error; +// authManagement: +/** + * Note: This type should probably be updated to be removed in the future and use just PublicUserOrError + * - Contains either a GenericResponseBody for when there is no user found or attached to a request + * - Or a PublicUserOrError resulting from calling the `saveUser` helper. + */ +export type PublicUserOrErrorOrGeneric = + | PublicUserOrError + | GenericResponseBody; + +/** + * Response body used for unlinkGithub and unlinkGoogle + * - If user is not logged in, a GenericResponseBody with 404 is returned + * - If user is logged in, PublicUserOrError is returned + */ +export type UnlinkThirdPartyResponseBody = PublicUserOrErrorOrGeneric; + +export interface ResetPasswordInitiateRequestBody { + email: string; +} + +/** + * Request params used for validateResetPasswordToken & updatePassword + */ +export interface ResetOrUpdatePasswordRequestParams + extends core.ParamsDictionary { + token: string; +} +export interface UpdatePasswordRequestBody { + password: string; +} + // signup: export interface CreateUserRequestBody { username: string; From ea9290510beeb4c6755369057b656a0bfb36bb75 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 16:47:26 +0100 Subject: [PATCH 42/67] add helper type in types/express to simplify defining route params --- server/types/apiKey.ts | 5 ++--- server/types/express.ts | 4 ++++ server/types/user.ts | 6 ++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/server/types/apiKey.ts b/server/types/apiKey.ts index 2684cf802b..9876d2c986 100644 --- a/server/types/apiKey.ts +++ b/server/types/apiKey.ts @@ -1,7 +1,6 @@ import { Model, Document, Types } from 'mongoose'; -import * as core from 'express-serve-static-core'; import { VirtualId, MongooseTimestamps } from './mongoose'; -import { Error } from './express'; +import { Error, RouteParam } from './express'; /** Full Api Key interface */ export interface IApiKey extends VirtualId, MongooseTimestamps { @@ -42,6 +41,6 @@ export interface ApiKeyResponse { export interface CreateApiKeyRequestBody { label: string; } -export interface RemoveApiKeyRequestParams extends core.ParamsDictionary { +export interface RemoveApiKeyRequestParams extends RouteParam { keyId: string; } diff --git a/server/types/express.ts b/server/types/express.ts index d17df36d6b..7b8a49a7bb 100644 --- a/server/types/express.ts +++ b/server/types/express.ts @@ -1,3 +1,4 @@ +import * as core from 'express-serve-static-core'; import { Request } from 'express'; import { UserDocument } from './user'; @@ -16,3 +17,6 @@ export interface GenericResponseBody { success: boolean; message?: string; } + +/** Wrapper around Express core.ParamsDictionary to prevent repeated importing when defining RequestHandler route params */ +export interface RouteParam extends core.ParamsDictionary {} diff --git a/server/types/user.ts b/server/types/user.ts index 33fdb9d374..a5878875f5 100644 --- a/server/types/user.ts +++ b/server/types/user.ts @@ -1,10 +1,9 @@ import { Document, Model, Types } from 'mongoose'; -import * as core from 'express-serve-static-core'; import { VirtualId, MongooseTimestamps } from './mongoose'; import { UserPreferences, CookieConsentOptions } from './userPreferences'; import { EmailConfirmationStates } from './email'; import { ApiKeyDocument } from './apiKey'; -import { Error, GenericResponseBody } from './express'; +import { Error, GenericResponseBody, RouteParam } from './express'; /** Full User interface */ export interface IUser extends VirtualId, MongooseTimestamps { @@ -108,8 +107,7 @@ export interface ResetPasswordInitiateRequestBody { /** * Request params used for validateResetPasswordToken & updatePassword */ -export interface ResetOrUpdatePasswordRequestParams - extends core.ParamsDictionary { +export interface ResetOrUpdatePasswordRequestParams extends RouteParam { token: string; } export interface UpdatePasswordRequestBody { From 3bce71f0977806edd8486f530bb68932157a38d4 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 16:54:09 +0100 Subject: [PATCH 43/67] fix logic in updateSettings to resolve type error, nest the currentPassword match check in newPassword block --- .../user.controller/__tests__/apiKey.test.ts | 3 +-- .../controllers/user.controller/authManagement.ts | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/server/controllers/user.controller/__tests__/apiKey.test.ts b/server/controllers/user.controller/__tests__/apiKey.test.ts index c78c50cf53..c136b8d35a 100644 --- a/server/controllers/user.controller/__tests__/apiKey.test.ts +++ b/server/controllers/user.controller/__tests__/apiKey.test.ts @@ -7,8 +7,7 @@ import { Types } from 'mongoose'; import { User } from '../../../models/user'; import { createApiKey, removeApiKey } from '../apiKey'; -import type { ApiKeyDocument } from '../../../types'; -import type { RemoveApiKeyRequestParams } from '../apiKey'; +import type { ApiKeyDocument, RemoveApiKeyRequestParams } from '../../../types'; jest.mock('../../../models/user'); diff --git a/server/controllers/user.controller/authManagement.ts b/server/controllers/user.controller/authManagement.ts index 92c1f5247c..138407f081 100644 --- a/server/controllers/user.controller/authManagement.ts +++ b/server/controllers/user.controller/authManagement.ts @@ -129,8 +129,7 @@ export const updateSettings: RequestHandler< res.status(401).json({ error: 'Current password is not provided.' }); return; } - } - if (req.body.currentPassword) { + const isMatch = await user.comparePassword(req.body.currentPassword); if (!isMatch) { res.status(401).json({ error: 'Current password is invalid.' }); @@ -138,7 +137,17 @@ export const updateSettings: RequestHandler< } user.password = req.body.newPassword; await saveUser(res, user); - } else if (user.email !== req.body.email) { + } + // if (req.body.currentPassword) { + // const isMatch = await user.comparePassword(req.body.currentPassword); + // if (!isMatch) { + // res.status(401).json({ error: 'Current password is invalid.' }); + // return; + // } + // user.password = req.body.newPassword!; + // await saveUser(res, user); + // } + else if (user.email !== req.body.email) { const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours user.verified = User.EmailConfirmation().Sent; From ea21fc1670015eb67310ad4e20abe3cefbc438b7 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 17:49:57 +0100 Subject: [PATCH 44/67] add test util to create mockUser for typesafety --- .../user.controller/__testUtils__.ts | 66 +++++++++++++++++ .../user.controller/__tests__/apiKey.test.ts | 60 +++++----------- .../__tests__/authManagement.test.ts | 55 +++++++------- .../user.controller/__tests__/helpers.test.ts | 44 +++--------- .../__tests__/userPreferences.test.ts | 72 +++++++------------ 5 files changed, 143 insertions(+), 154 deletions(-) create mode 100644 server/controllers/user.controller/__testUtils__.ts diff --git a/server/controllers/user.controller/__testUtils__.ts b/server/controllers/user.controller/__testUtils__.ts new file mode 100644 index 0000000000..9d851c49f8 --- /dev/null +++ b/server/controllers/user.controller/__testUtils__.ts @@ -0,0 +1,66 @@ +import { Types } from 'mongoose'; +import { PublicUser, User, UserDocument, UserPreferences } from '../../types'; +import { + CookieConsentOptions, + ApiKeyDocument, + AppThemeOptions +} from '../../types'; + +/** Mock user preferences for testing. Matches mongoose defaults in User model */ +export const mockUserPreferences: UserPreferences = { + fontSize: 18, + lineNumbers: true, + indentationAmount: 2, + isTabIndent: false, + autosave: true, + linewrap: true, + lintWarning: false, + textOutput: false, + gridOutput: false, + theme: AppThemeOptions.LIGHT, + autorefresh: false, + language: 'en-GB', + autocloseBracketsQuotes: true, + autocompleteHinter: false +}; + +/** Mock sanitised user for testing */ +export const mockBaseUserSanitised: PublicUser = { + email: 'test@example.com', + username: 'tester', + preferences: mockUserPreferences, + apiKeys: ([] as unknown) as Types.DocumentArray, + verified: 'verified', + id: 'abc123', + totalSize: 42, + cookieConsent: CookieConsentOptions.NONE, + google: 'user@gmail.com', + github: 'user123' +}; + +/** Mock full user for testing. createdAt is omitted to simplify jest timers where possible */ +export const mockBaseUserFull: Omit = { + ...mockBaseUserSanitised, + name: 'test user', + tokens: [], + password: 'abweorij', + resetPasswordToken: '1i14ij23', + banned: false +}; + +/** + * Helper function to make mock user document / object for tests + * - Does not attach any document methods + * @param unSanitised - use the entire user type, including sensitive fields + * @param overrides - any overrides on the default mocks --> for clearest tests, always define the properties expected to change + * @returns + */ +export function createMockUser( + overrides: Partial = {}, + unSanitised: boolean = false +): PublicUser & Record { + return { + ...(unSanitised ? mockBaseUserFull : mockBaseUserSanitised), + ...overrides + }; +} diff --git a/server/controllers/user.controller/__tests__/apiKey.test.ts b/server/controllers/user.controller/__tests__/apiKey.test.ts index c136b8d35a..d3e4452696 100644 --- a/server/controllers/user.controller/__tests__/apiKey.test.ts +++ b/server/controllers/user.controller/__tests__/apiKey.test.ts @@ -7,13 +7,14 @@ import { Types } from 'mongoose'; import { User } from '../../../models/user'; import { createApiKey, removeApiKey } from '../apiKey'; -import type { ApiKeyDocument, RemoveApiKeyRequestParams } from '../../../types'; +import type { ApiKeyDocument } from '../../../types'; +import { createMockUser } from '../__testUtils__'; jest.mock('../../../models/user'); describe('user.controller > api key', () => { - let request: MockRequest & { user?: { id: string } }; - let response: MockResponse; + let request: any; + let response: any; let next: MockNext; beforeEach(() => { @@ -30,15 +31,11 @@ describe('user.controller > api key', () => { describe('createApiKey', () => { it("returns an error if user doesn't exist", async () => { - request.user = { id: '1234' }; + request.user = createMockUser({ id: '1234' }); User.findById = jest.fn().mockResolvedValue(null); - await createApiKey( - (request as unknown) as Request, - (response as unknown) as Response, - (next as unknown) as NextFunction - ); + await createApiKey(request, response, next); expect(response.status).toHaveBeenCalledWith(404); expect(response.json).toHaveBeenCalledWith({ @@ -47,17 +44,13 @@ describe('user.controller > api key', () => { }); it('returns an error if label not provided', async () => { - request.user = { id: '1234' }; + request.user = createMockUser({ id: '1234' }); request.body = {}; const user = new User(); User.findById = jest.fn().mockResolvedValue(user); - await createApiKey( - (request as unknown) as Request, - (response as unknown) as Response, - (next as unknown) as NextFunction - ); + await createApiKey(request, response, next); expect(response.status).toHaveBeenCalledWith(400); expect(response.json).toHaveBeenCalledWith({ @@ -66,8 +59,8 @@ describe('user.controller > api key', () => { }); it('returns generated API key to the user', async () => { + request.user = createMockUser({ id: '1234' }); request.setBody({ label: 'my key' }); - request.user = { id: '1234' }; const user = new User(); user.apiKeys = ([] as unknown) as Types.DocumentArray; @@ -75,11 +68,7 @@ describe('user.controller > api key', () => { User.findById = jest.fn().mockResolvedValue(user); user.save = jest.fn(); - await createApiKey( - (request as unknown) as Request, - (response as unknown) as Response, - (next as unknown) as NextFunction - ); + await createApiKey(request, response, next); const lastKey = last(user.apiKeys); @@ -97,15 +86,11 @@ describe('user.controller > api key', () => { describe('removeApiKey', () => { it("returns an error if user doesn't exist", async () => { - request.user = { id: '1234' }; + request.user = createMockUser({ id: '1234' }); User.findById = jest.fn().mockResolvedValue(null); - await removeApiKey( - (request as unknown) as Request, - (response as unknown) as Response, - (next as unknown) as NextFunction - ); + await removeApiKey(request, response, next); expect(response.status).toHaveBeenCalledWith(404); expect(response.json).toHaveBeenCalledWith({ @@ -114,18 +99,14 @@ describe('user.controller > api key', () => { }); it("returns an error if specified key doesn't exist", async () => { - request.user = { id: '1234' }; + request.user = createMockUser({ id: '1234' }); request.params = { keyId: 'not-a-real-key' }; const user = new User(); user.apiKeys = ([] as unknown) as Types.DocumentArray; User.findById = jest.fn().mockResolvedValue(user); - await removeApiKey( - (request as unknown) as Request, - (response as unknown) as Response, - (next as unknown) as NextFunction - ); + await removeApiKey(request, response, next); expect(response.status).toHaveBeenCalledWith(404); expect(response.json).toHaveBeenCalledWith({ @@ -144,21 +125,18 @@ describe('user.controller > api key', () => { apiKeys.find = Array.prototype.find; apiKeys.pull = jest.fn(); - const user = { + const user = createMockUser({ + id: '1234', apiKeys, save: jest.fn() - }; + }); - request.user = { id: '1234' }; + request.user = user; request.params = { keyId: 'id1' }; User.findById = jest.fn().mockResolvedValue(user); - await removeApiKey( - (request as unknown) as Request, - (response as unknown) as Response, - (next as unknown) as NextFunction - ); + await removeApiKey(request, response, next); expect(user.apiKeys.pull).toHaveBeenCalledWith({ _id: 'id1' }); expect(user.save).toHaveBeenCalled(); diff --git a/server/controllers/user.controller/__tests__/authManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement.test.ts index 27f210f0b2..9c5c558aa7 100644 --- a/server/controllers/user.controller/__tests__/authManagement.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement.test.ts @@ -11,6 +11,7 @@ import { unlinkGoogle } from '../authManagement'; import { saveUser, generateToken, userResponse } from '../helpers'; +import { createMockUser } from '../__testUtils__'; import { mailerService } from '../../../utils/mail'; import { UserDocument } from '../../../types'; @@ -68,11 +69,11 @@ describe('user.controller > auth management', () => { describe('if the user is found', () => { beforeEach(() => { mockToken = 'mock-token'; - saveMock = jest.fn().mockResolvedValue({}); - mockUser = { + saveMock = jest.fn().mockResolvedValue(null); + mockUser = createMockUser({ email: 'test@example.com', save: saveMock - }; + }); (generateToken as jest.Mock).mockResolvedValue(mockToken); User.findByEmail = jest.fn().mockResolvedValue(mockUser); @@ -113,10 +114,10 @@ describe('user.controller > auth management', () => { beforeEach(() => { mockToken = 'mock-token'; saveMock = jest.fn().mockResolvedValue({}); - mockUser = { + mockUser = createMockUser({ email: 'test@example.com', save: saveMock - }; + }); (generateToken as jest.Mock).mockResolvedValue(mockToken); User.findByEmail = jest.fn().mockResolvedValue(null); @@ -142,10 +143,10 @@ describe('user.controller > auth management', () => { it('returns unsuccessful for all other errors', async () => { mockToken = 'mock-token'; saveMock = jest.fn().mockResolvedValue({}); - mockUser = { + mockUser = createMockUser({ email: 'test@example.com', save: saveMock - }; + }); (generateToken as jest.Mock).mockRejectedValue( new Error('network error') @@ -202,11 +203,12 @@ describe('user.controller > auth management', () => { describe('and when there is a user with valid token', () => { beforeEach(async () => { - const fakeUser = { + const fakeUser = createMockUser({ email: 'test@example.com', resetPasswordToken: 'valid-token', resetPasswordExpires: fixedTime + 10000 // still valid - }; + }); + User.findOne = jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue(fakeUser) }); @@ -257,13 +259,14 @@ describe('user.controller > auth management', () => { }); describe('and when there is a user with valid token', () => { - const fakeUser = { + const fakeUser = createMockUser({ email: 'test@example.com', password: 'oldpassword', resetPasswordToken: 'valid-token', resetPasswordExpires: fixedTime + 10000, // still valid save: jest.fn() - }; + }); + beforeEach(async () => { User.findOne = jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue(fakeUser) @@ -320,11 +323,11 @@ describe('user.controller > auth management', () => { // the below tests match the current logic, but logic can be improved describe('if the user is found', () => { - const startingUser = { + const startingUser = createMockUser({ username: 'oldusername', email: 'old@email.com', id: 'valid-id' - }; + }); beforeEach(() => { User.findById = jest.fn().mockResolvedValue(startingUser); @@ -399,13 +402,10 @@ describe('user.controller > auth management', () => { }); }); describe('and when there is a user in the request', () => { - const user = { - github: { id: '123', username: 'testuser' }, - tokens: [ - { kind: 'github', accessToken: 'abc' }, - { kind: 'google', accessToken: 'xyz' } - ] - }; + const user = createMockUser({ + github: 'testuser', + tokens: [{ kind: 'github' }, { kind: 'google' }] + }); beforeEach(async () => { request.user = user; @@ -415,7 +415,7 @@ describe('user.controller > auth management', () => { expect(user.github).toBeUndefined(); }); it('filters out the github token', () => { - expect(user.tokens).toEqual([{ kind: 'google', accessToken: 'xyz' }]); + expect(user.tokens).toEqual([{ kind: 'google' }]); }); it('does calls saveUser', () => { expect(saveUser).toHaveBeenCalledWith(response, user); @@ -440,13 +440,10 @@ describe('user.controller > auth management', () => { }); }); describe('and when there is a user in the request', () => { - const user = { - google: { id: '123', username: 'testuser' }, - tokens: [ - { kind: 'github', accessToken: 'abc' }, - { kind: 'google', accessToken: 'xyz' } - ] - }; + const user = createMockUser({ + google: 'testuser', + tokens: [{ kind: 'github' }, { kind: 'google' }] + }); beforeEach(async () => { request.user = user; @@ -456,7 +453,7 @@ describe('user.controller > auth management', () => { expect(user.google).toBeUndefined(); }); it('filters out the google token', () => { - expect(user.tokens).toEqual([{ kind: 'github', accessToken: 'abc' }]); + expect(user.tokens).toEqual([{ kind: 'github' }]); }); it('does calls saveUser', () => { expect(saveUser).toHaveBeenCalledWith(response, user); diff --git a/server/controllers/user.controller/__tests__/helpers.test.ts b/server/controllers/user.controller/__tests__/helpers.test.ts index 4de5e7f24a..8780dd6528 100644 --- a/server/controllers/user.controller/__tests__/helpers.test.ts +++ b/server/controllers/user.controller/__tests__/helpers.test.ts @@ -1,47 +1,19 @@ /* eslint-disable no-unused-vars */ import crypto from 'crypto'; -import { Types } from 'mongoose'; import { userResponse, generateToken } from '../helpers'; -import { CookieConsentOptions, AppThemeOptions } from '../../../types'; -import { ApiKeyDocument } from '../../../types'; +import { createMockUser } from '../__testUtils__'; 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: ([] as unknown) as Types.DocumentArray, - verified: 'verified', - id: 'abc123', - totalSize: 42, - cookieConsent: CookieConsentOptions.NONE, - google: 'user@gmail.com', - github: 'user123', - - // to be removed: - name: 'test user', +const mockFullUser = createMockUser({ + // sensitive fields to be removed: + name: 'bob dylan', tokens: [], - password: 'abweorij', - resetPasswordToken: '1i14ij23', - banned: false -}; + password: 'password12314', + resetPasswordToken: 'wijroaijwoer', + banned: true +}); describe('user.controller > helpers', () => { describe('userResponse', () => { diff --git a/server/controllers/user.controller/__tests__/userPreferences.test.ts b/server/controllers/user.controller/__tests__/userPreferences.test.ts index de9192b798..6aa9074deb 100644 --- a/server/controllers/user.controller/__tests__/userPreferences.test.ts +++ b/server/controllers/user.controller/__tests__/userPreferences.test.ts @@ -1,36 +1,14 @@ import { Request as MockRequest } from 'jest-express/lib/request'; import { Response as MockResponse } from 'jest-express/lib/response'; import { NextFunction as MockNext } from 'jest-express/lib/next'; -import { Types } from 'mongoose'; import { User } from '../../../models/user'; import { updatePreferences, updateCookieConsent } from '../userPreferences'; -import { ApiKeyDocument } from '../../../types'; -import { CookieConsentOptions } from '../../../types'; +import { createMockUser, mockUserPreferences } from '../__testUtils__'; +import { AppThemeOptions, CookieConsentOptions } from '../../../types'; jest.mock('../../../models/user'); -jest.mock('../../../utils/mail', () => ({ - mailerService: { - send: jest.fn() - } -})); -jest.mock('../../../views/mail', () => ({ - renderEmailConfirmation: jest - .fn() - .mockReturnValue({ to: 'test@example.com', subject: 'Confirm' }) -})); - -const mockBaseUser = { - email: 'test@example.com', - username: 'tester', - preferences: {}, - apiKeys: ([] as unknown) as Types.DocumentArray, - verified: 'verified', - id: 'abc123', - totalSize: 42, - cookieConsent: CookieConsentOptions.NONE, - google: 'user@gmail.com', - github: 'user123' -}; + +const mockBaseUser = createMockUser(); describe('user.controller > user preferences', () => { let request: any; @@ -52,23 +30,25 @@ describe('user.controller > user preferences', () => { describe('updatePreferences', () => { it('saves user preferences when user exists', async () => { const saveMock = jest.fn().mockResolvedValue({}); - const mockUser = { - ...mockBaseUser, - preferences: { theme: 'light' }, + const mockUser = createMockUser({ + preferences: { ...mockUserPreferences, theme: AppThemeOptions.LIGHT }, save: saveMock - }; + }); User.findById = jest .fn() .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); request.user = { id: 'user1' }; - request.body = { preferences: { theme: 'dark', notifications: true } }; + request.body = { + preferences: { theme: AppThemeOptions.DARK, notifications: true } + }; await updatePreferences(request, response, next); // Check that preferences were merged correctly expect(mockUser.preferences).toEqual({ - theme: 'dark', + ...mockUserPreferences, + theme: AppThemeOptions.DARK, notifications: true }); expect(saveMock).toHaveBeenCalled(); @@ -89,11 +69,10 @@ describe('user.controller > user preferences', () => { }); it('returns 500 if saving preferences fails', async () => { const saveMock = jest.fn().mockRejectedValue(new Error('DB error')); - const mockUser = { - ...mockBaseUser, - preferences: { theme: 'light' }, + const mockUser = createMockUser({ + preferences: { ...mockUserPreferences, theme: AppThemeOptions.LIGHT }, save: saveMock - }; + }); User.findById = jest .fn() .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); @@ -111,27 +90,25 @@ describe('user.controller > user preferences', () => { describe('updateCookieConsent', () => { it('updates cookieConsent when user exists', async () => { const saveMock = jest.fn().mockResolvedValue({}); - const mockUser = { - ...mockBaseUser, - cookieConsent: false, + const mockUser = createMockUser({ + cookieConsent: CookieConsentOptions.ALL, save: saveMock - }; - + }); User.findById = jest .fn() .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); request.user = { id: 'user1' }; - request.body = { cookieConsent: true }; + request.body = { cookieConsent: CookieConsentOptions.ESSENTIAL }; await updateCookieConsent(request, response, next); expect(User.findById).toHaveBeenCalledWith('user1'); - expect(mockUser.cookieConsent).toBe(true); + expect(mockUser.cookieConsent).toBe(CookieConsentOptions.ESSENTIAL); expect(saveMock).toHaveBeenCalled(); expect(response.json).toHaveBeenCalledWith({ ...mockBaseUser, - cookieConsent: true + cookieConsent: CookieConsentOptions.ESSENTIAL }); }); @@ -152,11 +129,10 @@ describe('user.controller > user preferences', () => { it('returns 500 if saving cookieConsent fails', async () => { const saveMock = jest.fn().mockRejectedValue(new Error('DB error')); - const mockUser = { - ...mockBaseUser, - cookieConsent: true, + const mockUser = createMockUser({ + cookieConsent: CookieConsentOptions.ALL, save: saveMock - }; + }); User.findById = jest .fn() From 33aa06b4d94db123f933ff468578d9476e944d89 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 18:07:11 +0100 Subject: [PATCH 45/67] add jsdocs to userController.signup routes --- server/controllers/user.controller/signup.ts | 82 ++++++++++++++------ server/routes/user.routes.ts | 4 +- 2 files changed, 61 insertions(+), 25 deletions(-) diff --git a/server/controllers/user.controller/signup.ts b/server/controllers/user.controller/signup.ts index 3261ace7e2..4aabd9df0e 100644 --- a/server/controllers/user.controller/signup.ts +++ b/server/controllers/user.controller/signup.ts @@ -10,6 +10,15 @@ import { VerifyEmailQuery } from '../../types'; +/** + * - Method: `POST` + * - Endpoint: `/signup` + * - Authenticated: `false` + * - Id: `UserController.createUser` + * + * Description: + * - Create a new user + */ export const createUser: RequestHandler< {}, PublicUserOrError, @@ -71,6 +80,15 @@ export const createUser: RequestHandler< } }; +/** + * - Method: `GET` + * - Endpoint: `/signup/duplicate_check` + * - Authenticated: `false` + * - Id: `UserController.duplicateUserCheck` + * + * Description: + * - Check if a user with the same email or username already exists + */ export const duplicateUserCheck: RequestHandler< {}, {}, @@ -94,29 +112,15 @@ export const duplicateUserCheck: RequestHandler< }); }; -export const verifyEmail: RequestHandler<{}, {}, {}, VerifyEmailQuery> = async ( - req, - res -) => { - const token = req.query.t; - const user = await User.findOne({ - verifiedToken: token, - verifiedTokenExpires: { $gt: new Date() } - }).exec(); - if (!user) { - res.status(401).json({ - success: false, - message: 'Token is invalid or has expired.' - }); - return; - } - user.verified = User.EmailConfirmation().Verified; - user.verifiedToken = null; - user.verifiedTokenExpires = null; - await user.save(); - res.json({ success: true }); -}; - +/** + * - Method: `POST` + * - Endpoint: `/verify/send` + * - Authenticated: `false` + * - Id: `UserController.emailVerificationInitiate` + * + * Description: + * - Send a Confirm Email email to verify that the user owns the specified email account + */ export const emailVerificationInitiate: RequestHandler< {}, PublicUserOrError @@ -157,3 +161,35 @@ export const emailVerificationInitiate: RequestHandler< res.status(500).json({ error: err }); } }; + +/** + * - Method: `GET` + * - Endpoint: `/verify` + * - Authenticated: `false` + * - Id: `UserController.verifyEmail` + * + * Description: + * - Used in the Confirm Email's link to verify a user's email is attached to their account + */ +export const verifyEmail: RequestHandler<{}, {}, {}, VerifyEmailQuery> = async ( + req, + res +) => { + const token = req.query.t; + const user = await User.findOne({ + verifiedToken: token, + verifiedTokenExpires: { $gt: new Date() } + }).exec(); + if (!user) { + res.status(401).json({ + success: false, + message: 'Token is invalid or has expired.' + }); + return; + } + user.verified = User.EmailConfirmation().Verified; + user.verifiedToken = null; + user.verifiedTokenExpires = null; + await user.save(); + res.json({ success: true }); +}; diff --git a/server/routes/user.routes.ts b/server/routes/user.routes.ts index 26fdbb2589..a61a42e11b 100644 --- a/server/routes/user.routes.ts +++ b/server/routes/user.routes.ts @@ -13,10 +13,10 @@ const router = Router(); 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); +// GET /verify +router.get('/verify', UserController.verifyEmail); /** * =============== From 57d38f763c8a4b711992c569922ff1cc316b2c85 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 18:08:24 +0100 Subject: [PATCH 46/67] add jsdocs to userController.userPreferences routes --- .../user.controller/userPreferences.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/server/controllers/user.controller/userPreferences.ts b/server/controllers/user.controller/userPreferences.ts index a60fe4c64f..4f83b01f64 100644 --- a/server/controllers/user.controller/userPreferences.ts +++ b/server/controllers/user.controller/userPreferences.ts @@ -8,6 +8,15 @@ import { } from '../../types'; import { saveUser } from './helpers'; +/** + * - Method: `PUT` + * - Endpoint: `/preferences` + * - Authenticated: `true` + * - Controller: `UserController.updatePreferences` + * + * Description: + * - Update user preferences, such as AppTheme + */ export const updatePreferences: RequestHandler< {}, UpdatePreferencesResponseBody, @@ -28,6 +37,15 @@ export const updatePreferences: RequestHandler< } }; +/** + * - Method: `PUT` + * - Endpoint: `/cookie-consent` + * - Authenticated: `true` + * - Id: `UserController.updatePreferences` + * + * Description: + * - Update user cookie consent + */ export const updateCookieConsent: RequestHandler< {}, PublicUserOrError, From 6332a45137ab4a5f841db0b448a8166c0e88d8b5 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 18:29:47 +0100 Subject: [PATCH 47/67] update user.password to be optional per mongoose schema --- .../user.controller/authManagement.ts | 17 ++++------------- server/models/__test__/user.test.ts | 2 +- server/types/user.ts | 9 ++++++++- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/server/controllers/user.controller/authManagement.ts b/server/controllers/user.controller/authManagement.ts index 138407f081..f7253a465b 100644 --- a/server/controllers/user.controller/authManagement.ts +++ b/server/controllers/user.controller/authManagement.ts @@ -129,25 +129,16 @@ export const updateSettings: RequestHandler< res.status(401).json({ error: 'Current password is not provided.' }); return; } - + } + if (req.body.currentPassword) { const isMatch = await user.comparePassword(req.body.currentPassword); if (!isMatch) { res.status(401).json({ error: 'Current password is invalid.' }); return; } - user.password = req.body.newPassword; + user.password = req.body.newPassword!; await saveUser(res, user); - } - // if (req.body.currentPassword) { - // const isMatch = await user.comparePassword(req.body.currentPassword); - // if (!isMatch) { - // res.status(401).json({ error: 'Current password is invalid.' }); - // return; - // } - // user.password = req.body.newPassword!; - // await saveUser(res, user); - // } - else if (user.email !== req.body.email) { + } else if (user.email !== req.body.email) { const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours user.verified = User.EmailConfirmation().Sent; diff --git a/server/models/__test__/user.test.ts b/server/models/__test__/user.test.ts index 6e448a971d..2abc77873c 100644 --- a/server/models/__test__/user.test.ts +++ b/server/models/__test__/user.test.ts @@ -35,7 +35,7 @@ describe('User model', () => { await user.save(); expect(user.password).not.toBe('mypassword'); - const match = await bcrypt.compare('mypassword', user.password); + const match = await bcrypt.compare('mypassword', user.password!); expect(match).toBe(true); }); diff --git a/server/types/user.ts b/server/types/user.ts index a5878875f5..f16147c437 100644 --- a/server/types/user.ts +++ b/server/types/user.ts @@ -9,7 +9,7 @@ import { Error, GenericResponseBody, RouteParam } from './express'; export interface IUser extends VirtualId, MongooseTimestamps { name: string; username: string; - password: string; + password?: string; resetPasswordToken?: string; resetPasswordExpires?: number; verified?: string; @@ -93,6 +93,13 @@ export type PublicUserOrErrorOrGeneric = | PublicUserOrError | GenericResponseBody; +export interface UpdateSettingsRequestBody { + username: string; + email: string; + newPassword?: string; + currentPassword?: string; +} + /** * Response body used for unlinkGithub and unlinkGoogle * - If user is not logged in, a GenericResponseBody with 404 is returned From e8dcb595663f487a0f993bf7bbdd31825a822668 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 18:31:15 +0100 Subject: [PATCH 48/67] add jsdocs to userController.authManagement routes --- .../user.controller/authManagement.ts | 69 +++++++++++++++++-- 1 file changed, 62 insertions(+), 7 deletions(-) diff --git a/server/controllers/user.controller/authManagement.ts b/server/controllers/user.controller/authManagement.ts index f7253a465b..61781346ea 100644 --- a/server/controllers/user.controller/authManagement.ts +++ b/server/controllers/user.controller/authManagement.ts @@ -9,11 +9,21 @@ import { PublicUserOrError, ResetPasswordInitiateRequestBody, ResetOrUpdatePasswordRequestParams, - UpdatePasswordRequestBody + UpdatePasswordRequestBody, + UpdateSettingsRequestBody } from '../../types'; import { mailerService } from '../../utils/mail'; import { renderResetPassword, renderEmailConfirmation } from '../../views/mail'; +/** + * - Method: `POST` + * - Endpoint: `/reset-password` + * - Authenticated: `false` + * - Id: `UserController.resetPasswordInitiate` + * + * Description: + * - Send an Reset Email email to the registered email account + */ export const resetPasswordInitiate: RequestHandler< {}, GenericResponseBody, @@ -58,6 +68,17 @@ export const resetPasswordInitiate: RequestHandler< } }; +/** + * - Method: `GET` + * - Endpoint: `/reset-password/:token` + * - Authenticated: `false` + * - Id: `UserController.validateResetPasswordToken` + * + * Description: + * - The link in the Reset Password email, which contains a reset token that is valid for 1h + * - If valid, the user will see a form to reset their password + * - Else they will see a message that their token has expired + */ export const validateResetPasswordToken: RequestHandler< ResetOrUpdatePasswordRequestParams, GenericResponseBody @@ -76,6 +97,18 @@ export const validateResetPasswordToken: RequestHandler< res.json({ success: true }); }; +/** + * - Method: `POST` + * - Endpoint: `/reset-password/:token` + * - Authenticated: `false` + * - Id: `UserController.updatePassword` + * + * Description: + * - Used by the new password form to update a user's password with the valid token + * - Returns a Generic 401 - 'Password reset token is invalid or has expired.' if the token timed out + * - Returns a PublicUser if successfully saved + * - Returns an Error if network error on save attempt + */ export const updatePassword: RequestHandler< ResetOrUpdatePasswordRequestParams, PublicUserOrErrorOrGeneric, @@ -102,15 +135,19 @@ export const updatePassword: RequestHandler< // eventually send email that the password has been reset }; +/** + * - Method: `PUT` + * - Endpoint: `/account` + * - Authenticated: `true` + * - Id: `UserController.updateSettings` + * + * Description: + * - Used to update the user's username, email, or password while authenticated + */ export const updateSettings: RequestHandler< {}, PublicUserOrError, - { - username: string; - email: string; - newPassword?: string; - currentPassword?: string; - } + UpdateSettingsRequestBody > = async (req, res) => { try { const user = await User.findById(req.user!.id); @@ -168,6 +205,15 @@ export const updateSettings: RequestHandler< } }; +/** + * - Method: `DELETE` + * - Endpoint: `/auth/github` + * - Authenticated: `false` -- TODO: update to true? + * - Id: `UserController.unlinkGithub` + * + * Description: + * - Unlink github account + */ export const unlinkGithub: RequestHandler< {}, UnlinkThirdPartyResponseBody @@ -186,6 +232,15 @@ export const unlinkGithub: RequestHandler< }); }; +/** + * - Method: `DELETE` + * - Endpoint: `/auth/google` + * - Authenticated: `false` -- TODO: update to true? + * - Id: `UserController.unlinkGoogle` + * + * Description: + * - Unlink google account + */ export const unlinkGoogle: RequestHandler< {}, UnlinkThirdPartyResponseBody From 3d78cdc32d9de469e0c585c789a118e2931d36d7 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 18:36:22 +0100 Subject: [PATCH 49/67] add jsdocs to userController.apiKey routes --- server/controllers/user.controller/apiKey.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/server/controllers/user.controller/apiKey.ts b/server/controllers/user.controller/apiKey.ts index a17544621f..e87747a75c 100644 --- a/server/controllers/user.controller/apiKey.ts +++ b/server/controllers/user.controller/apiKey.ts @@ -23,7 +23,15 @@ function generateApiKey(): Promise { }); } -/** POST /account/api-keys, UserController.createApiKey */ +/** + * - Method: `POST` + * - Endpoint: `/account/api-keys` + * - Authenticated: `true` + * - Id: `UserController.createApiKey` + * + * Description: + * - Create API key + */ export const createApiKey: RequestHandler< {}, ApiKeyResponseOrError, @@ -75,7 +83,15 @@ export const createApiKey: RequestHandler< } }; -/** DELETE /account/api-keys/:keyId, UserController.removeApiKey */ +/** + * - Method: `DELETE` + * - Endpoint: `/account/api-keys/:keyId` + * - Authenticated: `true` + * - Id: `UserController.removeApiKey` + * + * Description: + * - Remove API key + */ export const removeApiKey: RequestHandler< RemoveApiKeyRequestParams, ApiKeyResponseOrError From eeaa29aa27a18795fc227d6c11a43219648ecd05 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 19:17:23 +0100 Subject: [PATCH 50/67] add remaining tests for helpers --- .../user.controller/__tests__/helpers.test.ts | 70 ++++++++++++++++--- .../user.controller/authManagement.ts | 1 - server/controllers/user.controller/helpers.ts | 4 +- 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/server/controllers/user.controller/__tests__/helpers.test.ts b/server/controllers/user.controller/__tests__/helpers.test.ts index 8780dd6528..5121abcb4c 100644 --- a/server/controllers/user.controller/__tests__/helpers.test.ts +++ b/server/controllers/user.controller/__tests__/helpers.test.ts @@ -1,8 +1,12 @@ /* eslint-disable no-unused-vars */ import crypto from 'crypto'; -import { userResponse, generateToken } from '../helpers'; +import { Response as MockResponse } from 'jest-express/lib/response'; +import { Response } from 'express'; +import { userResponse, generateToken, userExists, saveUser } from '../helpers'; import { createMockUser } from '../__testUtils__'; +import { User } from '../../../models/user'; +import { UserDocument } from '../../../types'; jest.mock('../../../models/user'); @@ -15,20 +19,19 @@ const mockFullUser = createMockUser({ banned: true }); +const { + name, + tokens, + password, + resetPasswordToken, + banned, + ...sanitised +} = mockFullUser; + describe('user.controller > helpers', () => { describe('userResponse', () => { it('returns a sanitized PublicUser object', () => { const result = userResponse(mockFullUser); - - const { - name, - tokens, - password, - resetPasswordToken, - banned, - ...sanitised - } = mockFullUser; - expect(result).toMatchObject(sanitised); }); }); @@ -54,4 +57,49 @@ describe('user.controller > helpers', () => { spy.mockRestore(); }); }); + + describe('saveUser', () => { + it('returns a response with a sanitised user if user.save succeeds', async () => { + const userWithSuccessfulSave = { + ...mockFullUser, + save: jest.fn().mockResolvedValue(null) + }; + const response = new MockResponse(); + await saveUser( + (response as unknown) as Response, + (userWithSuccessfulSave as unknown) as UserDocument + ); + expect(response.json).toHaveBeenCalledWith(sanitised); + }); + it('returns a 500 Error if user.save fails', async () => { + const userWithUnsuccessfulSave = { + ...mockFullUser, + save: jest.fn().mockRejectedValue('async error') + }; + const response = new MockResponse(); + await saveUser( + (response as unknown) as Response, + (userWithUnsuccessfulSave as unknown) as UserDocument + ); + expect(response.status).toHaveBeenCalledWith(500); + expect(response.json).toHaveBeenCalledWith({ + error: 'async error' + }); + }); + }); + + describe('userExists', () => { + it('returns true when User.findByUsername returns non-nullish', async () => { + User.findByEmailOrUsername = jest + .fn() + .mockResolvedValue({ id: 'something' }); + const exists = await userExists('someusername'); + expect(exists).toBe(true); + }); + it('returns false when User.findByUsername returns nullish', async () => { + User.findByEmailOrUsername = jest.fn().mockResolvedValue(null); + const exists = await userExists('someusername'); + expect(exists).toBe(false); + }); + }); }); diff --git a/server/controllers/user.controller/authManagement.ts b/server/controllers/user.controller/authManagement.ts index 61781346ea..71c5213cf9 100644 --- a/server/controllers/user.controller/authManagement.ts +++ b/server/controllers/user.controller/authManagement.ts @@ -1,5 +1,4 @@ import { RequestHandler } from 'express'; -import * as core from 'express-serve-static-core'; import { User } from '../../models/user'; import { saveUser, generateToken, userResponse } from './helpers'; import { diff --git a/server/controllers/user.controller/helpers.ts b/server/controllers/user.controller/helpers.ts index e921a97d6e..e24df5516a 100644 --- a/server/controllers/user.controller/helpers.ts +++ b/server/controllers/user.controller/helpers.ts @@ -61,10 +61,8 @@ export async function saveUser(res: Response, user: UserDocument) { /** * Helper used in other controllers to check if user by username exists. - * @param {string} username - * @return {Promise} */ -export async function userExists(username: string) { +export async function userExists(username: string): Promise { const user = await User.findByUsername(username); return user != null; } From 0138eb2ef26d0cd9fa8cb4951c9211d502d7b352 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 19:19:12 +0100 Subject: [PATCH 51/67] uncomment failing tests for authManagement --- .../__tests__/authManagement.test.ts | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/server/controllers/user.controller/__tests__/authManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement.test.ts index 9c5c558aa7..6b6a21e99d 100644 --- a/server/controllers/user.controller/__tests__/authManagement.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement.test.ts @@ -284,9 +284,9 @@ describe('user.controller > auth management', () => { expect(fakeUser.resetPasswordExpires).toBeUndefined(); expect(fakeUser.save).toHaveBeenCalled(); }); - // it('returns a success response with the sanitised user', () => { - // expect(response.json).toHaveBeenCalledWith({ success: true }); - // }); + it('returns a success response with the sanitised user', () => { + expect(response.json).toHaveBeenCalledWith({ success: true }); + }); }); }); @@ -349,22 +349,22 @@ describe('user.controller > auth management', () => { }); }); - // describe('and when there is an email in the request', () => { - // beforeEach(async () => { - // request.setBody({ - // username: 'oldusername', - // email: 'new@email.com' - // }); - // await updateSettings(request, response); - // }); - // it('calls saveUser with the new email', () => { - // expect(saveUser).toHaveBeenCalledWith(response, { - // ...startingUser, - // email: 'new@email.com' - // }); - // }); - // it('sends an email to confirm the email update', () => {}); - // }); + describe('and when there is an email in the request', () => { + beforeEach(async () => { + request.setBody({ + username: 'oldusername', + email: 'new@email.com' + }); + await updateSettings(request, response, next); + }); + it('calls saveUser with the new email', () => { + expect(saveUser).toHaveBeenCalledWith(response, { + ...startingUser, + email: 'new@email.com' + }); + }); + it('sends an email to confirm the email update', () => {}); + }); // currently frontend doesn't seem to call the below describe('and when there is a newPassword in the request', () => { From 84396edc6ebfcadb1627228a872be3daeb20ead6 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 21:44:13 +0100 Subject: [PATCH 52/67] user.controller/userPreferences: update request body type to Partial --- server/types/userPreferences.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/types/userPreferences.ts b/server/types/userPreferences.ts index eca5f4ca41..470dfc2a91 100644 --- a/server/types/userPreferences.ts +++ b/server/types/userPreferences.ts @@ -36,7 +36,7 @@ export type UpdatePreferencesResponseBody = UserPreferences | Error; * user.controller.updatePreferences */ export interface UpdatePreferencesRequestBody { - preferences: UserPreferences; + preferences: Partial; } /** From eb2fc4fbeb78499ba110c94d17d220bc4ba644b0 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Fri, 10 Oct 2025 00:14:04 +0100 Subject: [PATCH 53/67] fix helpers tests --- .../controllers/user.controller/__tests__/helpers.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/server/controllers/user.controller/__tests__/helpers.test.ts b/server/controllers/user.controller/__tests__/helpers.test.ts index 5121abcb4c..a6add4fda9 100644 --- a/server/controllers/user.controller/__tests__/helpers.test.ts +++ b/server/controllers/user.controller/__tests__/helpers.test.ts @@ -71,6 +71,7 @@ describe('user.controller > helpers', () => { ); expect(response.json).toHaveBeenCalledWith(sanitised); }); + it('returns a 500 Error if user.save fails', async () => { const userWithUnsuccessfulSave = { ...mockFullUser, @@ -90,14 +91,12 @@ describe('user.controller > helpers', () => { describe('userExists', () => { it('returns true when User.findByUsername returns non-nullish', async () => { - User.findByEmailOrUsername = jest - .fn() - .mockResolvedValue({ id: 'something' }); + User.findByUsername = jest.fn().mockResolvedValue({ id: 'something' }); const exists = await userExists('someusername'); expect(exists).toBe(true); }); it('returns false when User.findByUsername returns nullish', async () => { - User.findByEmailOrUsername = jest.fn().mockResolvedValue(null); + User.findByUsername = jest.fn().mockResolvedValue(null); const exists = await userExists('someusername'); expect(exists).toBe(false); }); From bf9626ee9384cf81fbd6ea3aac4d429b2d8a2be5 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Fri, 10 Oct 2025 00:35:29 +0100 Subject: [PATCH 54/67] fix updatePassword test --- .../__tests__/authManagement.test.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/server/controllers/user.controller/__tests__/authManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement.test.ts index 6b6a21e99d..83e19dfa52 100644 --- a/server/controllers/user.controller/__tests__/authManagement.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement.test.ts @@ -23,6 +23,8 @@ jest.mock('../../../utils/mail', () => ({ } })); jest.mock('../helpers', () => ({ + // userResponse: jest.fn(), + ...jest.requireActual('../helpers'), saveUser: jest.fn(), generateToken: jest.fn() })); @@ -259,13 +261,14 @@ describe('user.controller > auth management', () => { }); describe('and when there is a user with valid token', () => { - const fakeUser = createMockUser({ - email: 'test@example.com', + const fakeSanitisedUser = createMockUser({ email: 'test@example.com' }); + const fakeUser = { + ...fakeSanitisedUser, password: 'oldpassword', resetPasswordToken: 'valid-token', resetPasswordExpires: fixedTime + 10000, // still valid save: jest.fn() - }); + }; beforeEach(async () => { User.findOne = jest.fn().mockReturnValue({ @@ -275,7 +278,11 @@ describe('user.controller > auth management', () => { request.setBody({ password: 'newpassword' }); - request.logIn = jest.fn(); + // simulate logging in after resetting the password works + request.logIn = jest.fn((user, cb) => { + request.user = user; + cb(null); + }); await updatePassword(request, response, next); }); it('calls user.save with the updated password and removes the reset password token', () => { @@ -285,7 +292,7 @@ describe('user.controller > auth management', () => { expect(fakeUser.save).toHaveBeenCalled(); }); it('returns a success response with the sanitised user', () => { - expect(response.json).toHaveBeenCalledWith({ success: true }); + expect(response.json).toHaveBeenCalledWith(fakeSanitisedUser); }); }); }); From df6390772e380b780dab5a1df7d8c3b82d99d835 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Fri, 10 Oct 2025 01:12:07 +0100 Subject: [PATCH 55/67] clean up userPreferences test --- .../__tests__/userPreferences.test.ts | 33 ++++++++++--------- .../user.controller/userPreferences.ts | 2 +- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/server/controllers/user.controller/__tests__/userPreferences.test.ts b/server/controllers/user.controller/__tests__/userPreferences.test.ts index 6aa9074deb..d0f653ad96 100644 --- a/server/controllers/user.controller/__tests__/userPreferences.test.ts +++ b/server/controllers/user.controller/__tests__/userPreferences.test.ts @@ -4,7 +4,11 @@ import { NextFunction as MockNext } from 'jest-express/lib/next'; import { User } from '../../../models/user'; import { updatePreferences, updateCookieConsent } from '../userPreferences'; import { createMockUser, mockUserPreferences } from '../__testUtils__'; -import { AppThemeOptions, CookieConsentOptions } from '../../../types'; +import { + AppThemeOptions, + CookieConsentOptions, + PublicUser +} from '../../../types'; jest.mock('../../../models/user'); @@ -14,6 +18,7 @@ describe('user.controller > user preferences', () => { let request: any; let response: any; let next: MockNext; + let mockUser: PublicUser & Record; beforeEach(() => { request = new MockRequest(); @@ -29,11 +34,11 @@ describe('user.controller > user preferences', () => { describe('updatePreferences', () => { it('saves user preferences when user exists', async () => { - const saveMock = jest.fn().mockResolvedValue({}); - const mockUser = createMockUser({ + mockUser = createMockUser({ preferences: { ...mockUserPreferences, theme: AppThemeOptions.LIGHT }, - save: saveMock + save: jest.fn().mockResolvedValue(null) }); + User.findById = jest .fn() .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); @@ -51,7 +56,7 @@ describe('user.controller > user preferences', () => { theme: AppThemeOptions.DARK, notifications: true }); - expect(saveMock).toHaveBeenCalled(); + expect(mockUser.save).toHaveBeenCalled(); expect(response.json).toHaveBeenCalledWith(mockUser.preferences); }); it('returns 404 when no user is found', async () => { @@ -68,11 +73,11 @@ describe('user.controller > user preferences', () => { expect(response.json).toHaveBeenCalledWith({ error: 'User not found' }); }); it('returns 500 if saving preferences fails', async () => { - const saveMock = jest.fn().mockRejectedValue(new Error('DB error')); - const mockUser = createMockUser({ + mockUser = createMockUser({ preferences: { ...mockUserPreferences, theme: AppThemeOptions.LIGHT }, - save: saveMock + save: jest.fn().mockRejectedValue(new Error('DB error')) }); + User.findById = jest .fn() .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); @@ -89,10 +94,9 @@ describe('user.controller > user preferences', () => { describe('updateCookieConsent', () => { it('updates cookieConsent when user exists', async () => { - const saveMock = jest.fn().mockResolvedValue({}); - const mockUser = createMockUser({ + mockUser = createMockUser({ cookieConsent: CookieConsentOptions.ALL, - save: saveMock + save: jest.fn().mockResolvedValue(null) }); User.findById = jest .fn() @@ -105,7 +109,7 @@ describe('user.controller > user preferences', () => { expect(User.findById).toHaveBeenCalledWith('user1'); expect(mockUser.cookieConsent).toBe(CookieConsentOptions.ESSENTIAL); - expect(saveMock).toHaveBeenCalled(); + expect(mockUser.save).toHaveBeenCalled(); expect(response.json).toHaveBeenCalledWith({ ...mockBaseUser, cookieConsent: CookieConsentOptions.ESSENTIAL @@ -128,10 +132,9 @@ describe('user.controller > user preferences', () => { }); it('returns 500 if saving cookieConsent fails', async () => { - const saveMock = jest.fn().mockRejectedValue(new Error('DB error')); - const mockUser = createMockUser({ + mockUser = createMockUser({ cookieConsent: CookieConsentOptions.ALL, - save: saveMock + save: jest.fn().mockRejectedValue(new Error('DB error')) }); User.findById = jest diff --git a/server/controllers/user.controller/userPreferences.ts b/server/controllers/user.controller/userPreferences.ts index 4f83b01f64..259a140839 100644 --- a/server/controllers/user.controller/userPreferences.ts +++ b/server/controllers/user.controller/userPreferences.ts @@ -12,7 +12,7 @@ import { saveUser } from './helpers'; * - Method: `PUT` * - Endpoint: `/preferences` * - Authenticated: `true` - * - Controller: `UserController.updatePreferences` + * - Id: `UserController.updatePreferences` * * Description: * - Update user preferences, such as AppTheme From f78a692956a14a837b658e271b0dd084cb35049c Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Fri, 10 Oct 2025 01:13:20 +0100 Subject: [PATCH 56/67] clean up apiKey test --- server/controllers/user.controller/__tests__/apiKey.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/controllers/user.controller/__tests__/apiKey.test.ts b/server/controllers/user.controller/__tests__/apiKey.test.ts index d3e4452696..db85962033 100644 --- a/server/controllers/user.controller/__tests__/apiKey.test.ts +++ b/server/controllers/user.controller/__tests__/apiKey.test.ts @@ -2,7 +2,6 @@ import { last } from 'lodash'; import { Request as MockRequest } from 'jest-express/lib/request'; import { Response as MockResponse } from 'jest-express/lib/response'; import { NextFunction as MockNext } from 'jest-express/lib/next'; -import { Request, Response, NextFunction } from 'express'; import { Types } from 'mongoose'; import { User } from '../../../models/user'; From 47015cdfc9e477b9cff8ab8e910032f0717a16d8 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Thu, 9 Oct 2025 21:02:14 -0400 Subject: [PATCH 57/67] clean up authManagement tests --- .../__tests__/authManagement.test.ts | 107 ++++++++---------- 1 file changed, 47 insertions(+), 60 deletions(-) diff --git a/server/controllers/user.controller/__tests__/authManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement.test.ts index 83e19dfa52..4f93e6744e 100644 --- a/server/controllers/user.controller/__tests__/authManagement.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement.test.ts @@ -23,7 +23,6 @@ jest.mock('../../../utils/mail', () => ({ } })); jest.mock('../helpers', () => ({ - // userResponse: jest.fn(), ...jest.requireActual('../helpers'), saveUser: jest.fn(), generateToken: jest.fn() @@ -33,6 +32,9 @@ describe('user.controller > auth management', () => { let request: any; let response: any; let next: MockNext; + let mockToken: string; + let mockUser: Partial; + const fixedTime = 100000000; beforeEach(() => { request = new MockRequest(); @@ -47,11 +49,6 @@ describe('user.controller > auth management', () => { }); describe('resetPasswordInitiate', () => { - const fixedTime = 100000000; - let mockToken: string; - let saveMock: jest.Mock; - let mockUser: Partial; - beforeAll(() => { jest.useFakeTimers().setSystemTime(fixedTime); }); @@ -69,12 +66,11 @@ describe('user.controller > auth management', () => { }); describe('if the user is found', () => { - beforeEach(() => { + beforeEach(async () => { mockToken = 'mock-token'; - saveMock = jest.fn().mockResolvedValue(null); mockUser = createMockUser({ email: 'test@example.com', - save: saveMock + save: jest.fn().mockResolvedValue(null) }); (generateToken as jest.Mock).mockResolvedValue(mockToken); @@ -82,17 +78,15 @@ describe('user.controller > auth management', () => { request.body = { email: 'test@example.com' }; request.headers.host = 'localhost:3000'; - }); - it('sets a resetPasswordToken with an expiry of 1h to the user', async () => { - await resetPasswordInitiate(request, response, next); + await resetPasswordInitiate(request, response, next); + }); + it('sets a resetPasswordToken with an expiry of 1h to the user', () => { expect(mockUser.resetPasswordToken).toBe(mockToken); expect(mockUser.resetPasswordExpires).toBe(fixedTime + 3600000); - expect(saveMock).toHaveBeenCalled(); + expect(mockUser.save).toHaveBeenCalled(); }); - it('sends the reset password email', async () => { - await resetPasswordInitiate(request, response, next); - + it('sends the reset password email', () => { expect(mailerService.send).toHaveBeenCalledWith( expect.objectContaining({ to: 'test@example.com', @@ -102,9 +96,7 @@ describe('user.controller > auth management', () => { }) ); }); - it('returns a success message that does not indicate if the user exists, for security purposes', async () => { - await resetPasswordInitiate(request, response, next); - + it('returns a success message that does not indicate if the user exists, for security purposes', () => { expect(response.json).toHaveBeenCalledWith({ success: true, message: @@ -115,11 +107,6 @@ describe('user.controller > auth management', () => { describe('if the user is not found', () => { beforeEach(() => { mockToken = 'mock-token'; - saveMock = jest.fn().mockResolvedValue({}); - mockUser = createMockUser({ - email: 'test@example.com', - save: saveMock - }); (generateToken as jest.Mock).mockResolvedValue(mockToken); User.findByEmail = jest.fn().mockResolvedValue(null); @@ -144,10 +131,9 @@ describe('user.controller > auth management', () => { }); it('returns unsuccessful for all other errors', async () => { mockToken = 'mock-token'; - saveMock = jest.fn().mockResolvedValue({}); mockUser = createMockUser({ email: 'test@example.com', - save: saveMock + save: jest.fn().mockResolvedValue(null) }); (generateToken as jest.Mock).mockRejectedValue( @@ -167,7 +153,6 @@ describe('user.controller > auth management', () => { }); describe('validateResetPasswordToken', () => { - const fixedTime = 100000000; beforeAll(() => jest.useFakeTimers().setSystemTime(fixedTime)); afterAll(() => jest.useRealTimers()); @@ -175,7 +160,9 @@ describe('user.controller > auth management', () => { User.findOne = jest.fn().mockReturnValue({ exec: jest.fn() }); + request.params = { token: 'some-token' }; + await validateResetPasswordToken(request, response, next); expect(User.findOne).toHaveBeenCalledWith({ @@ -189,7 +176,9 @@ describe('user.controller > auth management', () => { User.findOne = jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); + request.params = { token: 'invalid-token' }; + await validateResetPasswordToken(request, response, next); }); it('returns a 401', () => { @@ -214,7 +203,9 @@ describe('user.controller > auth management', () => { User.findOne = jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue(fakeUser) }); + request.params = { token: 'valid-token' }; + await validateResetPasswordToken(request, response, next); }); it('returns a success response', () => { @@ -224,7 +215,6 @@ describe('user.controller > auth management', () => { }); describe('updatePassword', () => { - const fixedTime = 100000000; beforeAll(() => jest.useFakeTimers().setSystemTime(fixedTime)); afterAll(() => jest.useRealTimers()); @@ -232,7 +222,9 @@ describe('user.controller > auth management', () => { User.findOne = jest.fn().mockReturnValue({ exec: jest.fn() }); + request.params = { token: 'some-token' }; + await updatePassword(request, response, next); expect(User.findOne).toHaveBeenCalledWith({ @@ -246,7 +238,9 @@ describe('user.controller > auth management', () => { User.findOne = jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); + request.params = { token: 'invalid-token' }; + await updatePassword(request, response, next); }); it('returns a 401', () => { @@ -261,9 +255,9 @@ describe('user.controller > auth management', () => { }); describe('and when there is a user with valid token', () => { - const fakeSanitisedUser = createMockUser({ email: 'test@example.com' }); - const fakeUser = { - ...fakeSanitisedUser, + const sanitisedMockUser = createMockUser({ email: 'test@example.com' }); + mockUser = { + ...sanitisedMockUser, password: 'oldpassword', resetPasswordToken: 'valid-token', resetPasswordExpires: fixedTime + 10000, // still valid @@ -272,36 +266,35 @@ describe('user.controller > auth management', () => { beforeEach(async () => { User.findOne = jest.fn().mockReturnValue({ - exec: jest.fn().mockResolvedValue(fakeUser) + exec: jest.fn().mockResolvedValue(mockUser) }); + request.params = { token: 'valid-token' }; request.setBody({ password: 'newpassword' }); + // simulate logging in after resetting the password works request.logIn = jest.fn((user, cb) => { request.user = user; cb(null); }); + await updatePassword(request, response, next); }); it('calls user.save with the updated password and removes the reset password token', () => { - expect(fakeUser.password).toBe('newpassword'); - expect(fakeUser.resetPasswordToken).toBeUndefined(); - expect(fakeUser.resetPasswordExpires).toBeUndefined(); - expect(fakeUser.save).toHaveBeenCalled(); + expect(mockUser.password).toBe('newpassword'); + expect(mockUser.resetPasswordToken).toBeUndefined(); + expect(mockUser.resetPasswordExpires).toBeUndefined(); + expect(mockUser.save).toHaveBeenCalled(); }); it('returns a success response with the sanitised user', () => { - expect(response.json).toHaveBeenCalledWith(fakeSanitisedUser); + expect(response.json).toHaveBeenCalledWith(sanitisedMockUser); }); }); }); describe('updateSettings', () => { - const fixedTime = 100000000; // arbitrary fixed timestamp - let saveMock: jest.Mock; - let mockUser: Partial; - beforeAll(() => { jest.useFakeTimers().setSystemTime(fixedTime); }); @@ -313,7 +306,12 @@ describe('user.controller > auth management', () => { describe('if the user is not found', () => { beforeEach(async () => { User.findById = jest.fn().mockResolvedValue(null); + request.user = { id: 'nonexistent-id' }; + + (saveUser as jest.Mock).mockResolvedValue(null); + (generateToken as jest.Mock).mockResolvedValue('token12343'); + await updateSettings(request, response, next); }); @@ -333,12 +331,17 @@ describe('user.controller > auth management', () => { const startingUser = createMockUser({ username: 'oldusername', email: 'old@email.com', - id: 'valid-id' + id: 'valid-id', + comparePassword: jest.fn().mockResolvedValue(true) }); beforeEach(() => { User.findById = jest.fn().mockResolvedValue(startingUser); + request.user = { id: 'valid-id' }; + + (saveUser as jest.Mock).mockResolvedValue(null); + (generateToken as jest.Mock).mockResolvedValue('token12343'); }); describe('and when there is a username in the request', () => { @@ -348,7 +351,7 @@ describe('user.controller > auth management', () => { }); await updateSettings(request, response, next); }); - it('calls saveUser with the new username', () => { + it('calls saveUser', () => { expect(saveUser).toHaveBeenCalledWith(response, { ...startingUser, username: 'newusername' @@ -356,25 +359,9 @@ describe('user.controller > auth management', () => { }); }); - describe('and when there is an email in the request', () => { - beforeEach(async () => { - request.setBody({ - username: 'oldusername', - email: 'new@email.com' - }); - await updateSettings(request, response, next); - }); - it('calls saveUser with the new email', () => { - expect(saveUser).toHaveBeenCalledWith(response, { - ...startingUser, - email: 'new@email.com' - }); - }); - it('sends an email to confirm the email update', () => {}); - }); - // currently frontend doesn't seem to call the below describe('and when there is a newPassword in the request', () => { + beforeEach(async () => {}); describe('and the current password is not provided', () => { it('returns 401 with a "current password not provided" message', () => {}); it('does not save the user with the new password', () => {}); From b0548ea1bb6c95db73e3c90d40ce328d2453488e Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Fri, 10 Oct 2025 05:45:07 -0400 Subject: [PATCH 58/67] split up authManagement tests into sub files for easier reading --- .../authManagement/3rdPartyManagement.test.ts | 110 ++++++++++ .../passwordManagement.test.ts} | 193 ++---------------- .../authManagement/updateSettings.test.ts | 126 ++++++++++++ 3 files changed, 250 insertions(+), 179 deletions(-) create mode 100644 server/controllers/user.controller/__tests__/authManagement/3rdPartyManagement.test.ts rename server/controllers/user.controller/__tests__/{authManagement.test.ts => authManagement/passwordManagement.test.ts} (59%) create mode 100644 server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts diff --git a/server/controllers/user.controller/__tests__/authManagement/3rdPartyManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement/3rdPartyManagement.test.ts new file mode 100644 index 0000000000..13699deddd --- /dev/null +++ b/server/controllers/user.controller/__tests__/authManagement/3rdPartyManagement.test.ts @@ -0,0 +1,110 @@ +import { Request as MockRequest } from 'jest-express/lib/request'; +import { Response as MockResponse } from 'jest-express/lib/response'; +import { NextFunction as MockNext } from 'jest-express/lib/next'; +import { unlinkGithub, unlinkGoogle } from '../../authManagement'; +import { saveUser } from '../../helpers'; +import { createMockUser } from '../../__testUtils__'; + +jest.mock('../../helpers', () => ({ + ...jest.requireActual('../../helpers'), + saveUser: jest.fn() +})); +jest.mock('../../../../utils/mail', () => ({ + mailerService: { + send: jest.fn() + } +})); + +describe('user.controller > auth management > 3rd party auth', () => { + let request: any; + let response: any; + let next: MockNext; + + beforeEach(() => { + request = new MockRequest(); + response = new MockResponse(); + next = jest.fn(); + }); + + afterEach(() => { + request.resetMocked(); + response.resetMocked(); + jest.clearAllMocks(); + }); + + describe('unlinkGithub', () => { + describe('and when there is no user in the request', () => { + beforeEach(async () => { + await unlinkGithub(request, response, next); + }); + it('does not call saveUser', () => { + expect(saveUser).not.toHaveBeenCalled(); + }); + it('returns a 404 with the correct status and message', () => { + expect(response.status).toHaveBeenCalledWith(404); + expect(response.json).toHaveBeenCalledWith({ + success: false, + message: 'You must be logged in to complete this action.' + }); + }); + }); + describe('and when there is a user in the request', () => { + const user = createMockUser({ + github: 'testuser', + tokens: [{ kind: 'github' }, { kind: 'google' }] + }); + + beforeEach(async () => { + request.user = user; + await unlinkGithub(request, response, next); + }); + it('removes the users github property', () => { + expect(user.github).toBeUndefined(); + }); + it('filters out the github token', () => { + expect(user.tokens).toEqual([{ kind: 'google' }]); + }); + it('does calls saveUser', () => { + expect(saveUser).toHaveBeenCalledWith(response, user); + }); + }); + }); + + describe('unlinkGoogle', () => { + describe('and when there is no user in the request', () => { + beforeEach(async () => { + await unlinkGoogle(request, response, next); + }); + it('does not call saveUser', () => { + expect(saveUser).not.toHaveBeenCalled(); + }); + it('returns a 404 with the correct status and message', () => { + expect(response.status).toHaveBeenCalledWith(404); + expect(response.json).toHaveBeenCalledWith({ + success: false, + message: 'You must be logged in to complete this action.' + }); + }); + }); + describe('and when there is a user in the request', () => { + const user = createMockUser({ + google: 'testuser', + tokens: [{ kind: 'github' }, { kind: 'google' }] + }); + + beforeEach(async () => { + request.user = user; + await unlinkGoogle(request, response, next); + }); + it('removes the users google property', () => { + expect(user.google).toBeUndefined(); + }); + it('filters out the google token', () => { + expect(user.tokens).toEqual([{ kind: 'github' }]); + }); + it('does calls saveUser', () => { + expect(saveUser).toHaveBeenCalledWith(response, user); + }); + }); + }); +}); diff --git a/server/controllers/user.controller/__tests__/authManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement/passwordManagement.test.ts similarity index 59% rename from server/controllers/user.controller/__tests__/authManagement.test.ts rename to server/controllers/user.controller/__tests__/authManagement/passwordManagement.test.ts index 4f93e6744e..aa713cbbae 100644 --- a/server/controllers/user.controller/__tests__/authManagement.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement/passwordManagement.test.ts @@ -1,34 +1,30 @@ import { Request as MockRequest } from 'jest-express/lib/request'; import { Response as MockResponse } from 'jest-express/lib/response'; import { NextFunction as MockNext } from 'jest-express/lib/next'; -import { User } from '../../../models/user'; +import { User } from '../../../../models/user'; import { resetPasswordInitiate, validateResetPasswordToken, - updatePassword, - updateSettings, - unlinkGithub, - unlinkGoogle -} from '../authManagement'; -import { saveUser, generateToken, userResponse } from '../helpers'; -import { createMockUser } from '../__testUtils__'; - -import { mailerService } from '../../../utils/mail'; -import { UserDocument } from '../../../types'; - -jest.mock('../../../models/user'); -jest.mock('../../../utils/mail', () => ({ + updatePassword +} from '../../authManagement'; +import { generateToken } from '../../helpers'; +import { createMockUser } from '../../__testUtils__'; + +import { mailerService } from '../../../../utils/mail'; +import { UserDocument } from '../../../../types'; + +jest.mock('../../../../models/user'); +jest.mock('../../../../utils/mail', () => ({ mailerService: { send: jest.fn() } })); -jest.mock('../helpers', () => ({ - ...jest.requireActual('../helpers'), - saveUser: jest.fn(), +jest.mock('../../helpers', () => ({ + ...jest.requireActual('../../helpers'), generateToken: jest.fn() })); -describe('user.controller > auth management', () => { +describe('user.controller > auth management > password management', () => { let request: any; let response: any; let next: MockNext; @@ -293,165 +289,4 @@ describe('user.controller > auth management', () => { }); }); }); - - describe('updateSettings', () => { - beforeAll(() => { - jest.useFakeTimers().setSystemTime(fixedTime); - }); - - afterAll(() => { - jest.useRealTimers(); - }); - - describe('if the user is not found', () => { - beforeEach(async () => { - User.findById = jest.fn().mockResolvedValue(null); - - request.user = { id: 'nonexistent-id' }; - - (saveUser as jest.Mock).mockResolvedValue(null); - (generateToken as jest.Mock).mockResolvedValue('token12343'); - - await updateSettings(request, response, next); - }); - - it('returns 404 and a user-not-found error', async () => { - expect(response.status).toHaveBeenCalledWith(404); - expect(response.json).toHaveBeenCalledWith({ - error: 'User not found' - }); - }); - it('does not save the user', () => { - expect(saveUser).not.toHaveBeenCalled(); - }); - }); - - // the below tests match the current logic, but logic can be improved - describe('if the user is found', () => { - const startingUser = createMockUser({ - username: 'oldusername', - email: 'old@email.com', - id: 'valid-id', - comparePassword: jest.fn().mockResolvedValue(true) - }); - - beforeEach(() => { - User.findById = jest.fn().mockResolvedValue(startingUser); - - request.user = { id: 'valid-id' }; - - (saveUser as jest.Mock).mockResolvedValue(null); - (generateToken as jest.Mock).mockResolvedValue('token12343'); - }); - - describe('and when there is a username in the request', () => { - beforeEach(async () => { - request.setBody({ - username: 'newusername' - }); - await updateSettings(request, response, next); - }); - it('calls saveUser', () => { - expect(saveUser).toHaveBeenCalledWith(response, { - ...startingUser, - username: 'newusername' - }); - }); - }); - - // currently frontend doesn't seem to call the below - describe('and when there is a newPassword in the request', () => { - beforeEach(async () => {}); - describe('and the current password is not provided', () => { - it('returns 401 with a "current password not provided" message', () => {}); - it('does not save the user with the new password', () => {}); - }); - }); - describe('and when there is a currentPassword in the request', () => { - describe('and the current password does not match', () => { - it('returns 401 with a "current password invalid" message', () => {}); - it('does not save the user with the new password', () => {}); - }); - describe('and when the current password does match', () => { - it('calls saveUser with the new password', () => {}); - }); - }); - }); - }); - - describe('unlinkGithub', () => { - describe('and when there is no user in the request', () => { - beforeEach(async () => { - await unlinkGithub(request, response, next); - }); - it('does not call saveUser', () => { - expect(saveUser).not.toHaveBeenCalled(); - }); - it('returns a 404 with the correct status and message', () => { - expect(response.status).toHaveBeenCalledWith(404); - expect(response.json).toHaveBeenCalledWith({ - success: false, - message: 'You must be logged in to complete this action.' - }); - }); - }); - describe('and when there is a user in the request', () => { - const user = createMockUser({ - github: 'testuser', - tokens: [{ kind: 'github' }, { kind: 'google' }] - }); - - beforeEach(async () => { - request.user = user; - await unlinkGithub(request, response, next); - }); - it('removes the users github property', () => { - expect(user.github).toBeUndefined(); - }); - it('filters out the github token', () => { - expect(user.tokens).toEqual([{ kind: 'google' }]); - }); - it('does calls saveUser', () => { - expect(saveUser).toHaveBeenCalledWith(response, user); - }); - }); - }); - - describe('unlinkGoogle', () => { - describe('and when there is no user in the request', () => { - beforeEach(async () => { - await unlinkGoogle(request, response, next); - }); - it('does not call saveUser', () => { - expect(saveUser).not.toHaveBeenCalled(); - }); - it('returns a 404 with the correct status and message', () => { - expect(response.status).toHaveBeenCalledWith(404); - expect(response.json).toHaveBeenCalledWith({ - success: false, - message: 'You must be logged in to complete this action.' - }); - }); - }); - describe('and when there is a user in the request', () => { - const user = createMockUser({ - google: 'testuser', - tokens: [{ kind: 'github' }, { kind: 'google' }] - }); - - beforeEach(async () => { - request.user = user; - await unlinkGoogle(request, response, next); - }); - it('removes the users google property', () => { - expect(user.google).toBeUndefined(); - }); - it('filters out the google token', () => { - expect(user.tokens).toEqual([{ kind: 'github' }]); - }); - it('does calls saveUser', () => { - expect(saveUser).toHaveBeenCalledWith(response, user); - }); - }); - }); }); diff --git a/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts b/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts new file mode 100644 index 0000000000..10b9fb24e4 --- /dev/null +++ b/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts @@ -0,0 +1,126 @@ +import { Request as MockRequest } from 'jest-express/lib/request'; +import { Response as MockResponse } from 'jest-express/lib/response'; +import { NextFunction as MockNext } from 'jest-express/lib/next'; +import { User } from '../../../../models/user'; +import { updateSettings } from '../../authManagement'; +import { saveUser, generateToken, userResponse } from '../../helpers'; +import { createMockUser } from '../../__testUtils__'; + +import { mailerService } from '../../../../utils/mail'; +import { UserDocument } from '../../../../types'; + +jest.mock('../../../../models/user'); +jest.mock('../../../../utils/mail', () => ({ + mailerService: { + send: jest.fn() + } +})); +jest.mock('../../helpers', () => ({ + ...jest.requireActual('../../helpers'), + saveUser: jest.fn(), + generateToken: jest.fn() +})); + +describe('user.controller > auth management', () => { + let request: any; + let response: any; + let next: MockNext; + const fixedTime = 100000000; + + beforeEach(() => { + request = new MockRequest(); + response = new MockResponse(); + next = jest.fn(); + }); + + afterEach(() => { + request.resetMocked(); + response.resetMocked(); + jest.clearAllMocks(); + }); + + describe('updateSettings', () => { + beforeAll(() => { + jest.useFakeTimers().setSystemTime(fixedTime); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + describe('if the user is not found', () => { + beforeEach(async () => { + User.findById = jest.fn().mockResolvedValue(null); + + request.user = { id: 'nonexistent-id' }; + + (saveUser as jest.Mock).mockResolvedValue(null); + (generateToken as jest.Mock).mockResolvedValue('token12343'); + + await updateSettings(request, response, next); + }); + + it('returns 404 and a user-not-found error', async () => { + expect(response.status).toHaveBeenCalledWith(404); + expect(response.json).toHaveBeenCalledWith({ + error: 'User not found' + }); + }); + it('does not save the user', () => { + expect(saveUser).not.toHaveBeenCalled(); + }); + }); + + // the below tests match the current logic, but logic can be improved + describe('if the user is found', () => { + const startingUser = createMockUser({ + username: 'oldusername', + email: 'old@email.com', + id: 'valid-id', + comparePassword: jest.fn().mockResolvedValue(true) + }); + + beforeEach(() => { + User.findById = jest.fn().mockResolvedValue(startingUser); + + request.user = { id: 'valid-id' }; + + (saveUser as jest.Mock).mockResolvedValue(null); + (generateToken as jest.Mock).mockResolvedValue('token12343'); + }); + + describe('and when there is a username in the request', () => { + beforeEach(async () => { + request.setBody({ + username: 'newusername' + }); + await updateSettings(request, response, next); + }); + it('calls saveUser', () => { + expect(saveUser).toHaveBeenCalledWith(response, { + ...startingUser, + username: 'newusername' + }); + }); + }); + + // currently frontend doesn't seem to call the below + describe('and when there is a newPassword in the request', () => { + beforeEach(async () => {}); + describe('and the current password is not provided', () => { + it('returns 401 with a "current password not provided" message', () => {}); + it('does not save the user with the new password', () => {}); + }); + }); + describe('and when there is a currentPassword in the request', () => { + describe('and the current password does not match', () => { + it('returns 401 with a "current password invalid" message', () => {}); + it('does not save the user with the new password', () => {}); + }); + describe('and when the current password does match', () => { + it('calls saveUser with the new password', () => {}); + }); + }); + }); + }); +}); From fd98175e66f0b2345f11f4b1359995ab0ea34745 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Fri, 10 Oct 2025 06:16:08 -0400 Subject: [PATCH 59/67] clean up tests by migrating frequent mocks to mock files --- .../authManagement/3rdPartyManagement.test.ts | 6 +--- .../authManagement/passwordManagement.test.ts | 6 +--- .../authManagement/updateSettings.test.ts | 8 ++--- .../user.controller/__tests__/signup.test.ts | 36 ++++++++----------- server/utils/__mocks__/mail.ts | 4 +++ server/views/__mocks__/mail.ts | 12 +++++++ 6 files changed, 36 insertions(+), 36 deletions(-) create mode 100644 server/utils/__mocks__/mail.ts create mode 100644 server/views/__mocks__/mail.ts diff --git a/server/controllers/user.controller/__tests__/authManagement/3rdPartyManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement/3rdPartyManagement.test.ts index 13699deddd..d94745d606 100644 --- a/server/controllers/user.controller/__tests__/authManagement/3rdPartyManagement.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement/3rdPartyManagement.test.ts @@ -9,11 +9,7 @@ jest.mock('../../helpers', () => ({ ...jest.requireActual('../../helpers'), saveUser: jest.fn() })); -jest.mock('../../../../utils/mail', () => ({ - mailerService: { - send: jest.fn() - } -})); +jest.mock('../../../../utils/mail'); describe('user.controller > auth management > 3rd party auth', () => { let request: any; diff --git a/server/controllers/user.controller/__tests__/authManagement/passwordManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement/passwordManagement.test.ts index aa713cbbae..f8b90d2ff8 100644 --- a/server/controllers/user.controller/__tests__/authManagement/passwordManagement.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement/passwordManagement.test.ts @@ -14,11 +14,7 @@ import { mailerService } from '../../../../utils/mail'; import { UserDocument } from '../../../../types'; jest.mock('../../../../models/user'); -jest.mock('../../../../utils/mail', () => ({ - mailerService: { - send: jest.fn() - } -})); +jest.mock('../../../../utils/mail'); jest.mock('../../helpers', () => ({ ...jest.requireActual('../../helpers'), generateToken: jest.fn() diff --git a/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts b/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts index 10b9fb24e4..8958bf7314 100644 --- a/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts @@ -10,11 +10,7 @@ import { mailerService } from '../../../../utils/mail'; import { UserDocument } from '../../../../types'; jest.mock('../../../../models/user'); -jest.mock('../../../../utils/mail', () => ({ - mailerService: { - send: jest.fn() - } -})); +jest.mock('../../../../utils/mail'); jest.mock('../../helpers', () => ({ ...jest.requireActual('../../helpers'), saveUser: jest.fn(), @@ -114,10 +110,12 @@ describe('user.controller > auth management', () => { }); describe('and when there is a currentPassword in the request', () => { describe('and the current password does not match', () => { + beforeEach(async () => {}); it('returns 401 with a "current password invalid" message', () => {}); it('does not save the user with the new password', () => {}); }); describe('and when the current password does match', () => { + beforeEach(async () => {}); it('calls saveUser with the new password', () => {}); }); }); diff --git a/server/controllers/user.controller/__tests__/signup.test.ts b/server/controllers/user.controller/__tests__/signup.test.ts index 1c91506d9c..2dce0e61f5 100644 --- a/server/controllers/user.controller/__tests__/signup.test.ts +++ b/server/controllers/user.controller/__tests__/signup.test.ts @@ -12,16 +12,8 @@ import { import { mailerService } from '../../../utils/mail'; jest.mock('../../../models/user'); -jest.mock('../../../utils/mail', () => ({ - mailerService: { - send: jest.fn() - } -})); -jest.mock('../../../views/mail', () => ({ - renderEmailConfirmation: jest - .fn() - .mockReturnValue({ to: 'test@example.com', subject: 'Confirm' }) -})); +jest.mock('../../../utils/mail'); +jest.mock('../../../views/mail'); describe('user.controller > signup', () => { let request: any; @@ -89,17 +81,19 @@ describe('user.controller > signup', () => { describe('duplicateUserCheck', () => { it('calls findByEmailOrUsername with the correct params', async () => { - const mockFind = jest.fn().mockResolvedValue(null); - User.findByEmailOrUsername = mockFind; + User.findByEmailOrUsername = jest.fn().mockResolvedValue(null); request.query = { check_type: 'email', email: 'test@example.com' }; await duplicateUserCheck(request, response, next); - expect(mockFind).toHaveBeenCalledWith('test@example.com', { - caseInsensitive: true, - valueType: 'email' - }); + expect(User.findByEmailOrUsername).toHaveBeenCalledWith( + 'test@example.com', + { + caseInsensitive: true, + valueType: 'email' + } + ); }); it('returns the correct response body when no matching user is found', async () => { @@ -254,7 +248,7 @@ describe('user.controller > signup', () => { User.findById = jest .fn() .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); - (mailerService.send as jest.Mock).mockResolvedValue(true); + mailerService.send = jest.fn().mockResolvedValue(true); request.user = { id: 'user1' }; request.headers.host = 'localhost:3000'; @@ -263,7 +257,7 @@ describe('user.controller > signup', () => { expect(User.findById).toHaveBeenCalledWith('user1'); expect(mailerService.send).toHaveBeenCalledWith( - expect.objectContaining({ to: 'test@example.com' }) + expect.objectContaining({ subject: 'Mock confirm your email' }) // see views/__mocks__/mail.ts ); expect(mockUser.verified).toBe('resent'); expect(mockUser.verifiedToken).toBeDefined(); @@ -290,9 +284,9 @@ describe('user.controller > signup', () => { User.findById = jest .fn() .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); - (mailerService.send as jest.Mock).mockRejectedValue( - new Error('Mailer fail') - ); + mailerService.send = jest + .fn() + .mockRejectedValue(new Error('Mailer fail')); request.user = { id: 'user1' }; request.headers.host = 'localhost:3000'; diff --git a/server/utils/__mocks__/mail.ts b/server/utils/__mocks__/mail.ts new file mode 100644 index 0000000000..fdde3d2484 --- /dev/null +++ b/server/utils/__mocks__/mail.ts @@ -0,0 +1,4 @@ +export const mailerService = { + send: jest.fn().mockResolvedValue({ success: true }), + sendMail: jest.fn().mockResolvedValue({ success: true }) +}; diff --git a/server/views/__mocks__/mail.ts b/server/views/__mocks__/mail.ts new file mode 100644 index 0000000000..cdf84b1303 --- /dev/null +++ b/server/views/__mocks__/mail.ts @@ -0,0 +1,12 @@ +export const renderAccountConsolidation = jest.fn().mockReturnValue({ + to: 'test@example.com', + subject: 'Mock consolidate your email' +}); +export const renderResetPassword = jest.fn().mockReturnValue({ + to: 'test@example.com', + subject: 'Mock reset your password' +}); +export const renderEmailConfirmation = jest.fn().mockReturnValue({ + to: 'test@example.com', + subject: 'Mock confirm your email' +}); From e46db9bd2d8b1b2179453f4e35e0b473b357fd9e Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Fri, 10 Oct 2025 07:35:49 -0400 Subject: [PATCH 60/67] resolve updateSetting tests with current logic --- .../authManagement/updateSettings.test.ts | 258 ++++++++++++++---- .../user.controller/__tests__/signup.test.ts | 1 - 2 files changed, 201 insertions(+), 58 deletions(-) diff --git a/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts b/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts index 8958bf7314..565a32cd80 100644 --- a/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts @@ -3,121 +3,265 @@ import { Response as MockResponse } from 'jest-express/lib/response'; import { NextFunction as MockNext } from 'jest-express/lib/next'; import { User } from '../../../../models/user'; import { updateSettings } from '../../authManagement'; -import { saveUser, generateToken, userResponse } from '../../helpers'; +import { saveUser, generateToken } from '../../helpers'; import { createMockUser } from '../../__testUtils__'; import { mailerService } from '../../../../utils/mail'; -import { UserDocument } from '../../../../types'; +import { UpdateSettingsRequestBody, UserDocument } from '../../../../types'; jest.mock('../../../../models/user'); jest.mock('../../../../utils/mail'); +jest.mock('../../../../views/mail'); jest.mock('../../helpers', () => ({ - ...jest.requireActual('../../helpers'), + ...jest.requireActual('../../helpers'), // use actual userResponse saveUser: jest.fn(), generateToken: jest.fn() })); -describe('user.controller > auth management', () => { +describe('user.controller > auth management > updateSettings (email, username, password)', () => { let request: any; let response: any; let next: MockNext; + let requestBody: UpdateSettingsRequestBody; + let startingUser: Partial; + const fixedTime = 100000000; + const GENERATED_TOKEN = 'new-token-1io23jijo'; + + const OLD_USERNAME = 'oldusername'; + const NEW_USERNAME = 'newusername'; + + const OLD_EMAIL = 'old@email.com'; + const NEW_EMAIL = 'new@email.com'; + + const OLD_PASSWORD = 'oldpassword'; + const NEW_PASSWORD = 'newpassword'; + + // minimum valid request body to manipulate per test + // from manual testing on the account form: + // both username and email are required & there is client-side validation for valid email & username-taken prior to submit + const minimumValidRequest: UpdateSettingsRequestBody = { + username: OLD_USERNAME, + email: OLD_EMAIL + }; + + beforeAll(() => { + jest.useFakeTimers().setSystemTime(fixedTime); + }); + + afterAll(() => { + jest.useRealTimers(); + }); beforeEach(() => { request = new MockRequest(); response = new MockResponse(); next = jest.fn(); + + startingUser = createMockUser({ + username: OLD_USERNAME, + email: OLD_EMAIL, + password: OLD_PASSWORD, + id: '123459', + comparePassword: jest.fn().mockResolvedValue(true) + }); + + User.findById = jest.fn().mockResolvedValue(startingUser); + User.EmailConfirmation = jest.fn().mockReturnValue({ Sent: 'sent' }); + (saveUser as jest.Mock).mockResolvedValue(null); + (generateToken as jest.Mock).mockResolvedValue(GENERATED_TOKEN); + (mailerService.send as jest.Mock).mockResolvedValue(true); + + request.user = { id: 'valid-id' }; + request.headers.host = 'localhost:3000'; }); afterEach(() => { request.resetMocked(); response.resetMocked(); jest.clearAllMocks(); + jest.restoreAllMocks(); }); - describe('updateSettings', () => { - beforeAll(() => { - jest.useFakeTimers().setSystemTime(fixedTime); + describe('if the user is not found', () => { + beforeEach(async () => { + (User.findById as jest.Mock).mockResolvedValue(null); + request.user = { id: 'nonexistent-id' }; + + await updateSettings(request, response, next); }); - afterAll(() => { - jest.useRealTimers(); + it('returns 404 and a user-not-found error', async () => { + expect(response.status).toHaveBeenCalledWith(404); + expect(response.json).toHaveBeenCalledWith({ + error: 'User not found' + }); }); - describe('if the user is not found', () => { - beforeEach(async () => { - User.findById = jest.fn().mockResolvedValue(null); + it('does not save the user', () => { + expect(saveUser).not.toHaveBeenCalled(); + }); + }); - request.user = { id: 'nonexistent-id' }; + // the below tests match the current logic, but logic can be improved + describe('if the user is found', () => { + // Q: should we add check & logic that if no username or email are on the request, + // we fallback to the username and/or email on the found user for safety? + // not sure if anyone is hitting this api directly, so the client-side checks may not be enough - (saveUser as jest.Mock).mockResolvedValue(null); - (generateToken as jest.Mock).mockResolvedValue('token12343'); + // duplicate username check happens client-side before this request is made + it('saves the user with any username in the request', async () => { + // saves with old username + requestBody = { ...minimumValidRequest, username: OLD_USERNAME }; + request.setBody(requestBody); + await updateSettings(request, response, next); + expect(saveUser).toHaveBeenCalledWith(response, { ...startingUser }); - await updateSettings(request, response, next); + // saves with new username + requestBody = { ...minimumValidRequest, username: NEW_USERNAME }; + request.setBody(requestBody); + await updateSettings(request, response, next); + expect(saveUser).toHaveBeenCalledWith(response, { + ...startingUser, + username: NEW_USERNAME }); + }); - it('returns 404 and a user-not-found error', async () => { - expect(response.status).toHaveBeenCalledWith(404); - expect(response.json).toHaveBeenCalledWith({ - error: 'User not found' + // currently frontend doesn't seem to call password-change related things the below + // not sure if we should update the logic to be cleaner? + describe('when there is a new password in the request', () => { + describe('and the current password is not provided', () => { + beforeEach(async () => { + requestBody = { ...minimumValidRequest, newPassword: NEW_PASSWORD }; + request.setBody(requestBody); + await updateSettings(request, response, next); + }); + + it('returns 401 with a "current password not provided" message', () => { + expect(response.status).toHaveBeenCalledWith(401); + expect(response.json).toHaveBeenCalledWith({ + error: 'Current password is not provided.' + }); + }); + + it('does not save the user with the new password', () => { + expect(saveUser).not.toHaveBeenCalled(); }); - }); - it('does not save the user', () => { - expect(saveUser).not.toHaveBeenCalled(); }); }); - // the below tests match the current logic, but logic can be improved - describe('if the user is found', () => { - const startingUser = createMockUser({ - username: 'oldusername', - email: 'old@email.com', - id: 'valid-id', - comparePassword: jest.fn().mockResolvedValue(true) - }); + // this should be nested in the previous block but currently here to match existing logic as-is + // NOTE: will make a PR into this branch to propose the change + describe('and when there is a currentPassword in the request', () => { + describe('and the current password does not match', () => { + beforeEach(async () => { + startingUser.comparePassword = jest.fn().mockResolvedValue(false); - beforeEach(() => { - User.findById = jest.fn().mockResolvedValue(startingUser); + requestBody = { + ...minimumValidRequest, + newPassword: NEW_PASSWORD, + currentPassword: 'WRONG_PASSWORD' + }; - request.user = { id: 'valid-id' }; + request.setBody(requestBody); + await updateSettings(request, response, next); + }); - (saveUser as jest.Mock).mockResolvedValue(null); - (generateToken as jest.Mock).mockResolvedValue('token12343'); + it('returns 401 with a "current password invalid" message', () => { + expect(response.status).toHaveBeenCalledWith(401); + expect(response.json).toHaveBeenCalledWith({ + error: 'Current password is invalid.' + }); + }); + it('does not save the user with the new password', () => { + expect(saveUser).not.toHaveBeenCalled(); + }); }); - describe('and when there is a username in the request', () => { + describe('and when the current password does match', () => { beforeEach(async () => { - request.setBody({ - username: 'newusername' - }); + startingUser.comparePassword = jest.fn().mockResolvedValue(true); + + requestBody = { + ...minimumValidRequest, + newPassword: NEW_PASSWORD, + currentPassword: OLD_PASSWORD + }; + request.setBody(requestBody); + await updateSettings(request, response, next); }); - it('calls saveUser', () => { + it('calls saveUser with the new password', () => { expect(saveUser).toHaveBeenCalledWith(response, { ...startingUser, - username: 'newusername' + password: NEW_PASSWORD }); }); }); - // currently frontend doesn't seem to call the below - describe('and when there is a newPassword in the request', () => { - beforeEach(async () => {}); - describe('and the current password is not provided', () => { - it('returns 401 with a "current password not provided" message', () => {}); - it('does not save the user with the new password', () => {}); + // NOTE: This should not pass, but it currently does!! + describe('and when there is no new password on the request', () => { + beforeEach(async () => { + startingUser.comparePassword = jest.fn().mockResolvedValue(true); + + requestBody = { + ...minimumValidRequest, + newPassword: undefined, + currentPassword: OLD_PASSWORD + }; + request.setBody(requestBody); + + await updateSettings(request, response, next); }); - }); - describe('and when there is a currentPassword in the request', () => { - describe('and the current password does not match', () => { - beforeEach(async () => {}); - it('returns 401 with a "current password invalid" message', () => {}); - it('does not save the user with the new password', () => {}); + it('calls saveUser with the new empty password', () => { + expect(saveUser).toHaveBeenCalledWith(response, { + ...startingUser, + password: undefined + }); }); - describe('and when the current password does match', () => { - beforeEach(async () => {}); - it('calls saveUser with the new password', () => {}); + }); + }); + + describe('and when there is an email in the request', () => { + it('does not send a verification email if email is unchanged', async () => { + requestBody = minimumValidRequest; + request.setBody(requestBody); + await updateSettings(request, response, next); + + expect(saveUser).toHaveBeenCalledWith(response, startingUser); + expect(mailerService.send).not.toHaveBeenCalled(); + }); + + it('updates email and sends verification email if email is changed', async () => { + requestBody = { ...minimumValidRequest, email: NEW_EMAIL }; + request.setBody(requestBody); + await updateSettings(request, response, next); + + expect(saveUser).toHaveBeenCalledWith(response, { + ...startingUser, + email: NEW_EMAIL, + verified: 'sent', + verifiedToken: GENERATED_TOKEN }); + + expect(mailerService.send).toHaveBeenCalledWith( + expect.objectContaining({ + subject: 'Mock confirm your email' + }) + ); + }); + }); + + describe('and when there is any other error', () => { + beforeEach(async () => { + User.findById = jest.fn().mockRejectedValue('db error'); + requestBody = minimumValidRequest; + request.setBody(requestBody); + await updateSettings(request, response, next); + }); + it('returns a 500 error', () => { + expect(response.status).toHaveBeenCalledWith(500); + expect(response.json).toHaveBeenCalledWith({ error: 'db error' }); }); }); }); diff --git a/server/controllers/user.controller/__tests__/signup.test.ts b/server/controllers/user.controller/__tests__/signup.test.ts index 2dce0e61f5..df823a9639 100644 --- a/server/controllers/user.controller/__tests__/signup.test.ts +++ b/server/controllers/user.controller/__tests__/signup.test.ts @@ -248,7 +248,6 @@ describe('user.controller > signup', () => { User.findById = jest .fn() .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); - mailerService.send = jest.fn().mockResolvedValue(true); request.user = { id: 'user1' }; request.headers.host = 'localhost:3000'; From 14261423c1bb9548423947a5da33537febd2ce45 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Fri, 10 Oct 2025 08:41:21 -0400 Subject: [PATCH 61/67] cleanup jsdocs --- server/types/apiKey.ts | 7 ++++++- server/types/user.ts | 18 ++++++++++-------- server/types/userPreferences.ts | 15 ++++++--------- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/server/types/apiKey.ts b/server/types/apiKey.ts index 9876d2c986..21ba15aa04 100644 --- a/server/types/apiKey.ts +++ b/server/types/apiKey.ts @@ -2,6 +2,7 @@ import { Model, Document, Types } from 'mongoose'; import { VirtualId, MongooseTimestamps } from './mongoose'; import { Error, RouteParam } from './express'; +// -------- MONGOOSE -------- /** Full Api Key interface */ export interface IApiKey extends VirtualId, MongooseTimestamps { label: string; @@ -27,20 +28,24 @@ export interface SanitisedApiKey /** Mongoose model for API Key */ export interface ApiKeyModel extends Model {} -// HTTP +// -------- API -------- /** * Response body for userController.createApiKey & userController.removeApiKey * - Either an ApiKeyResponse or Error */ export type ApiKeyResponseOrError = ApiKeyResponse | Error; +/** Response for api-key related endpoints, containing list of keys */ export interface ApiKeyResponse { apiKeys: ApiKeyDocument[]; } +/** userController.createApiKey - Request */ export interface CreateApiKeyRequestBody { label: string; } + +/** userController.removeApiKey - Request */ export interface RemoveApiKeyRequestParams extends RouteParam { keyId: string; } diff --git a/server/types/user.ts b/server/types/user.ts index f16147c437..6d6d53fa1f 100644 --- a/server/types/user.ts +++ b/server/types/user.ts @@ -5,6 +5,7 @@ import { EmailConfirmationStates } from './email'; import { ApiKeyDocument } from './apiKey'; import { Error, GenericResponseBody, RouteParam } from './express'; +// -------- MONGOOSE -------- /** Full User interface */ export interface IUser extends VirtualId, MongooseTimestamps { name: string; @@ -76,14 +77,13 @@ export interface UserModel extends Model { EmailConfirmation(): typeof EmailConfirmationStates; } -// HTTP: +// -------- API -------- /** * Response body used for User related routes * Contains either the Public (sanitised) User or an Error */ export type PublicUserOrError = PublicUser | Error; -// authManagement: /** * Note: This type should probably be updated to be removed in the future and use just PublicUserOrError * - Contains either a GenericResponseBody for when there is no user found or attached to a request @@ -93,6 +93,7 @@ export type PublicUserOrErrorOrGeneric = | PublicUserOrError | GenericResponseBody; +/** userController.updateSettings - Request */ export interface UpdateSettingsRequestBody { username: string; email: string; @@ -101,38 +102,39 @@ export interface UpdateSettingsRequestBody { } /** - * Response body used for unlinkGithub and unlinkGoogle + * userContoller.unlinkGithub & userContoller.unlinkGoogle - Response * - If user is not logged in, a GenericResponseBody with 404 is returned * - If user is logged in, PublicUserOrError is returned */ export type UnlinkThirdPartyResponseBody = PublicUserOrErrorOrGeneric; +/** userController.resetPasswordInitiate - Request */ export interface ResetPasswordInitiateRequestBody { email: string; } -/** - * Request params used for validateResetPasswordToken & updatePassword - */ +/** userContoller.validateResetPasswordToken & userController.updatePassword - Request */ export interface ResetOrUpdatePasswordRequestParams extends RouteParam { token: string; } +/** userController.updatePassword - Request */ export interface UpdatePasswordRequestBody { password: string; } - -// signup: +/** userController.createUser - Request */ export interface CreateUserRequestBody { username: string; email: string; password: string; } +/** userController.duplicateUserCheck - Query */ export interface DuplicateUserCheckQuery { // eslint-disable-next-line camelcase check_type: 'email' | 'username'; email?: string; username?: string; } +/** userController.verifyEmail - Query */ export interface VerifyEmailQuery { t: string; } diff --git a/server/types/userPreferences.ts b/server/types/userPreferences.ts index 470dfc2a91..6a467999cc 100644 --- a/server/types/userPreferences.ts +++ b/server/types/userPreferences.ts @@ -29,19 +29,16 @@ export enum CookieConsentOptions { ALL = 'all' } -// HTTP: - -export type UpdatePreferencesResponseBody = UserPreferences | Error; -/** - * user.controller.updatePreferences - */ +// -------- API -------- +/** user.controller.updatePreferences - Request */ export interface UpdatePreferencesRequestBody { preferences: Partial; } -/** - * user.controller.updateCookieConsent - */ +/** userController.updatePreferences - Response */ +export type UpdatePreferencesResponseBody = UserPreferences | Error; + +/** user.controller.updateCookieConsent - Request */ export interface UpdateCookieConsentRequestBody { cookieConsent: CookieConsentOptions; } From b492d3eeea0e7abd5252e0f39ec04c29bfb7b6a2 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 11 Oct 2025 10:33:50 -0400 Subject: [PATCH 62/67] user.controller/authManagement: update tests for updateSettings to be BDD, simplified after manual testingon client --- .../authManagement/updateSettings.test.ts | 375 +++++++++++++----- .../user.controller/authManagement.ts | 3 +- 2 files changed, 276 insertions(+), 102 deletions(-) diff --git a/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts b/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts index 565a32cd80..73b8e51d5b 100644 --- a/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts @@ -23,10 +23,13 @@ describe('user.controller > auth management > updateSettings (email, username, p let response: any; let next: MockNext; let requestBody: UpdateSettingsRequestBody; - let startingUser: Partial; + let startingUser: Partial; // copy of found user that won't be mutated in test + let testUser: Partial; // found and mutated user const fixedTime = 100000000; const GENERATED_TOKEN = 'new-token-1io23jijo'; + const STATUSES = { Sent: 'sent' }; + const TOKEN_EXPIRY_TIME = 186400000; const OLD_USERNAME = 'oldusername'; const NEW_USERNAME = 'newusername'; @@ -37,9 +40,7 @@ describe('user.controller > auth management > updateSettings (email, username, p const OLD_PASSWORD = 'oldpassword'; const NEW_PASSWORD = 'newpassword'; - // minimum valid request body to manipulate per test - // from manual testing on the account form: - // both username and email are required & there is client-side validation for valid email & username-taken prior to submit + // minimum valid request body const minimumValidRequest: UpdateSettingsRequestBody = { username: OLD_USERNAME, email: OLD_EMAIL @@ -66,8 +67,10 @@ describe('user.controller > auth management > updateSettings (email, username, p comparePassword: jest.fn().mockResolvedValue(true) }); - User.findById = jest.fn().mockResolvedValue(startingUser); - User.EmailConfirmation = jest.fn().mockReturnValue({ Sent: 'sent' }); + testUser = { ...startingUser }; // copy to avoid mutation causing false-positive tests results + + User.findById = jest.fn().mockResolvedValue(testUser); + User.EmailConfirmation = jest.fn().mockReturnValue(STATUSES); (saveUser as jest.Mock).mockResolvedValue(null); (generateToken as jest.Mock).mockResolvedValue(GENERATED_TOKEN); (mailerService.send as jest.Mock).mockResolvedValue(true); @@ -103,166 +106,336 @@ describe('user.controller > auth management > updateSettings (email, username, p }); }); - // the below tests match the current logic, but logic can be improved describe('if the user is found', () => { - // Q: should we add check & logic that if no username or email are on the request, - // we fallback to the username and/or email on the found user for safety? - // not sure if anyone is hitting this api directly, so the client-side checks may not be enough - - // duplicate username check happens client-side before this request is made - it('saves the user with any username in the request', async () => { - // saves with old username - requestBody = { ...minimumValidRequest, username: OLD_USERNAME }; - request.setBody(requestBody); - await updateSettings(request, response, next); - expect(saveUser).toHaveBeenCalledWith(response, { ...startingUser }); - - // saves with new username - requestBody = { ...minimumValidRequest, username: NEW_USERNAME }; - request.setBody(requestBody); - await updateSettings(request, response, next); - expect(saveUser).toHaveBeenCalledWith(response, { - ...startingUser, - username: NEW_USERNAME + describe('happy paths:', () => { + describe('when given old username and old email', () => { + beforeEach(async () => { + requestBody = { ...minimumValidRequest }; + request.setBody(requestBody); + await updateSettings(request, response, next); + }); + it('saves the user with the correct details exactly once', () => { + expect(saveUser).toHaveBeenCalledWith(response, { ...startingUser }); + expect(saveUser).toHaveBeenCalledTimes(1); + }); + it('does not send a confirmation email to the user', () => { + expect(mailerService.send).not.toHaveBeenCalled(); + }); }); - }); - // currently frontend doesn't seem to call password-change related things the below - // not sure if we should update the logic to be cleaner? - describe('when there is a new password in the request', () => { - describe('and the current password is not provided', () => { + // duplicate username check happens client-side before this request is made + describe('when given new username and old email', () => { beforeEach(async () => { - requestBody = { ...minimumValidRequest, newPassword: NEW_PASSWORD }; + requestBody = { ...minimumValidRequest, username: NEW_USERNAME }; request.setBody(requestBody); await updateSettings(request, response, next); }); - - it('returns 401 with a "current password not provided" message', () => { - expect(response.status).toHaveBeenCalledWith(401); - expect(response.json).toHaveBeenCalledWith({ - error: 'Current password is not provided.' + it('saves the user with the correct details exactly once', () => { + expect(saveUser).toHaveBeenCalledWith(response, { + ...startingUser, + username: NEW_USERNAME }); + expect(saveUser).toHaveBeenCalledTimes(1); + }); + it('does not send a confirmation email to the user', () => { + expect(mailerService.send).not.toHaveBeenCalled(); }); + }); - it('does not save the user with the new password', () => { - expect(saveUser).not.toHaveBeenCalled(); + describe('when given old username and new email', () => { + beforeEach(async () => { + requestBody = { ...minimumValidRequest, email: NEW_EMAIL }; + request.setBody(requestBody); + await updateSettings(request, response, next); + }); + it('saves the user with the correct details & verification token once', () => { + expect(saveUser).toHaveBeenCalledWith(response, { + ...startingUser, + email: NEW_EMAIL, + verified: STATUSES.Sent, + verifiedToken: GENERATED_TOKEN, + verifiedTokenExpires: TOKEN_EXPIRY_TIME + }); + expect(saveUser).toHaveBeenCalledTimes(1); + }); + it('sends a confirmation email to the user', () => { + expect(mailerService.send).toHaveBeenCalledWith( + expect.objectContaining({ + subject: 'Mock confirm your email' + }) + ); }); }); - }); - // this should be nested in the previous block but currently here to match existing logic as-is - // NOTE: will make a PR into this branch to propose the change - describe('and when there is a currentPassword in the request', () => { - describe('and the current password does not match', () => { + describe('when given new username and new email', () => { beforeEach(async () => { - startingUser.comparePassword = jest.fn().mockResolvedValue(false); + requestBody = { username: NEW_USERNAME, email: NEW_EMAIL }; + request.setBody(requestBody); + await updateSettings(request, response, next); + }); + it('saves the user with the correct details once', () => { + expect(saveUser).toHaveBeenCalledWith(response, { + ...startingUser, + username: NEW_USERNAME, + email: NEW_EMAIL, + verified: STATUSES.Sent, + verifiedToken: GENERATED_TOKEN, + verifiedTokenExpires: TOKEN_EXPIRY_TIME + }); + expect(saveUser).toHaveBeenCalledTimes(1); + }); + it('sends a confirmation email to the user', () => { + expect(mailerService.send).toHaveBeenCalledWith( + expect.objectContaining({ + subject: 'Mock confirm your email' + }) + ); + }); + }); + describe('when given old username, old email, and matching current password and new password', () => { + beforeEach(async () => { requestBody = { ...minimumValidRequest, - newPassword: NEW_PASSWORD, - currentPassword: 'WRONG_PASSWORD' + currentPassword: OLD_PASSWORD, + newPassword: NEW_PASSWORD }; - request.setBody(requestBody); await updateSettings(request, response, next); }); - - it('returns 401 with a "current password invalid" message', () => { - expect(response.status).toHaveBeenCalledWith(401); - expect(response.json).toHaveBeenCalledWith({ - error: 'Current password is invalid.' + it('saves the user with the correct details once', () => { + expect(saveUser).toHaveBeenCalledWith(response, { + ...startingUser, + password: NEW_PASSWORD }); + expect(saveUser).toHaveBeenCalledTimes(1); }); - it('does not save the user with the new password', () => { - expect(saveUser).not.toHaveBeenCalled(); + it('does not send a confirmation email to the user', () => { + expect(mailerService.send).not.toHaveBeenCalled(); }); }); - describe('and when the current password does match', () => { + describe('when given new username, old email, and new password with valid current password', () => { beforeEach(async () => { - startingUser.comparePassword = jest.fn().mockResolvedValue(true); - requestBody = { ...minimumValidRequest, - newPassword: NEW_PASSWORD, - currentPassword: OLD_PASSWORD + username: NEW_USERNAME, + currentPassword: OLD_PASSWORD, + newPassword: NEW_PASSWORD }; request.setBody(requestBody); - await updateSettings(request, response, next); }); - it('calls saveUser with the new password', () => { + it('saves the user with the correct details once', () => { expect(saveUser).toHaveBeenCalledWith(response, { ...startingUser, + username: NEW_USERNAME, password: NEW_PASSWORD }); + expect(saveUser).toHaveBeenCalledTimes(1); + }); + it('does not send a confirmation email to the user', () => { + expect(mailerService.send).not.toHaveBeenCalled(); }); }); - // NOTE: This should not pass, but it currently does!! - describe('and when there is no new password on the request', () => { + describe.skip('when given old username, new email, and new password with valid current password', () => { beforeEach(async () => { - startingUser.comparePassword = jest.fn().mockResolvedValue(true); - requestBody = { ...minimumValidRequest, - newPassword: undefined, - currentPassword: OLD_PASSWORD + email: NEW_EMAIL, + currentPassword: OLD_PASSWORD, + newPassword: NEW_PASSWORD }; request.setBody(requestBody); + await updateSettings(request, response, next); + }); + it('saves the user with the correct details once', () => { + expect(saveUser).toHaveBeenCalledWith(response, { + ...startingUser, + email: NEW_EMAIL, + verified: STATUSES.Sent, + verifiedToken: GENERATED_TOKEN, + verifiedTokenExpires: TOKEN_EXPIRY_TIME, + password: NEW_PASSWORD + }); + expect(saveUser).toHaveBeenCalledTimes(1); + }); + it('does not send a confirmation email to the user', () => { + expect(mailerService.send).toHaveBeenCalledWith( + expect.objectContaining({ + subject: 'Mock confirm your email' + }) + ); + }); + }); + describe.skip('when given new username, new email, and new password with valid current password', () => { + beforeEach(async () => { + requestBody = { + username: NEW_USERNAME, + email: NEW_EMAIL, + currentPassword: OLD_PASSWORD, + newPassword: NEW_PASSWORD + }; + request.setBody(requestBody); await updateSettings(request, response, next); }); - it('calls saveUser with the new empty password', () => { + it('saves the user with the correct details once', () => { expect(saveUser).toHaveBeenCalledWith(response, { ...startingUser, - password: undefined + username: NEW_USERNAME, + email: NEW_EMAIL, + verified: STATUSES.Sent, + verifiedToken: GENERATED_TOKEN, + verifiedTokenExpires: TOKEN_EXPIRY_TIME, + password: NEW_PASSWORD }); + expect(saveUser).toHaveBeenCalledTimes(1); + }); + it('does not send a confirmation email to the user', () => { + expect(mailerService.send).toHaveBeenCalledWith( + expect.objectContaining({ + subject: 'Mock confirm your email' + }) + ); }); }); }); - describe('and when there is an email in the request', () => { - it('does not send a verification email if email is unchanged', async () => { - requestBody = minimumValidRequest; - request.setBody(requestBody); - await updateSettings(request, response, next); + describe('unhappy paths', () => { + describe.skip('when missing username', () => { + beforeEach(async () => { + request.setBody({ email: OLD_EMAIL }); + await updateSettings(request, response, next); + }); + + it('returns 401 with an "Missing username" message', () => { + expect(response.status).toHaveBeenCalledWith(400); + expect(response.json).toHaveBeenCalledWith({ + error: 'Username is required.' + }); + }); + + it('does not save the user with the new password', () => { + expect(saveUser).not.toHaveBeenCalled(); + }); + it('does not send a confirmation email to the user', () => { + expect(mailerService.send).not.toHaveBeenCalled(); + }); + }); + + describe.skip('when missing email', () => { + beforeEach(async () => { + request.setBody({ username: OLD_USERNAME }); + await updateSettings(request, response, next); + }); + + it('returns 401 with an "Missing email" message', () => { + expect(response.status).toHaveBeenCalledWith(400); + expect(response.json).toHaveBeenCalledWith({ + error: 'Email is required.' + }); + }); - expect(saveUser).toHaveBeenCalledWith(response, startingUser); - expect(mailerService.send).not.toHaveBeenCalled(); + it('does not save the user with the new password', () => { + expect(saveUser).not.toHaveBeenCalled(); + }); + it('does not send a confirmation email to the user', () => { + expect(mailerService.send).not.toHaveBeenCalled(); + }); }); - it('updates email and sends verification email if email is changed', async () => { - requestBody = { ...minimumValidRequest, email: NEW_EMAIL }; - request.setBody(requestBody); - await updateSettings(request, response, next); + describe.skip('when given old username, old email, and matching current password and no new password', () => { + beforeEach(async () => { + requestBody = { + ...minimumValidRequest, + currentPassword: OLD_PASSWORD + }; + request.setBody(requestBody); + await updateSettings(request, response, next); + }); - expect(saveUser).toHaveBeenCalledWith(response, { - ...startingUser, - email: NEW_EMAIL, - verified: 'sent', - verifiedToken: GENERATED_TOKEN + it('returns 401 with an "New password is required" message', () => { + expect(response.status).toHaveBeenCalledWith(400); + expect(response.json).toHaveBeenCalledWith({ + error: 'New password is required.' + }); }); - expect(mailerService.send).toHaveBeenCalledWith( - expect.objectContaining({ - subject: 'Mock confirm your email' - }) - ); + it('does not save the user with the new password', () => { + expect(saveUser).not.toHaveBeenCalled(); + }); + it('does not send a confirmation email to the user', () => { + expect(mailerService.send).not.toHaveBeenCalled(); + }); }); - }); - describe('and when there is any other error', () => { - beforeEach(async () => { - User.findById = jest.fn().mockRejectedValue('db error'); - requestBody = minimumValidRequest; - request.setBody(requestBody); - await updateSettings(request, response, next); + describe('when given old username, old email, and non-matching current password and a new password', () => { + beforeEach(async () => { + testUser.comparePassword = jest.fn().mockResolvedValue(false); + + requestBody = { + ...minimumValidRequest, + currentPassword: 'not the same password', + newPassword: NEW_PASSWORD + }; + request.setBody(requestBody); + await updateSettings(request, response, next); + }); + + it('returns 401 with an error message', () => { + expect(response.status).toHaveBeenCalledWith(401); + expect(response.json).toHaveBeenCalledWith({ + error: 'Current password is invalid.' + }); + }); + + it('does not save the user with the new password', () => { + expect(saveUser).not.toHaveBeenCalled(); + }); + it('does not send a confirmation email to the user', () => { + expect(mailerService.send).not.toHaveBeenCalled(); + }); }); - it('returns a 500 error', () => { - expect(response.status).toHaveBeenCalledWith(500); - expect(response.json).toHaveBeenCalledWith({ error: 'db error' }); + + describe('when given old username, old email, and no current password and a new password', () => { + beforeEach(async () => { + requestBody = { + ...minimumValidRequest, + newPassword: NEW_PASSWORD + }; + request.setBody(requestBody); + await updateSettings(request, response, next); + }); + + it('returns 401 with an error message', () => { + expect(response.status).toHaveBeenCalledWith(401); + expect(response.json).toHaveBeenCalledWith({ + error: 'Current password is not provided.' + }); + }); + + it('does not save the user with the new password', () => { + expect(saveUser).not.toHaveBeenCalled(); + }); + it('does not send a confirmation email to the user', () => { + expect(mailerService.send).not.toHaveBeenCalled(); + }); }); }); }); + + describe('and when there is any other error', () => { + beforeEach(async () => { + User.findById = jest.fn().mockRejectedValue('db error'); + requestBody = minimumValidRequest; + request.setBody(requestBody); + await updateSettings(request, response, next); + }); + it('returns a 500 error', () => { + expect(response.status).toHaveBeenCalledWith(500); + expect(response.json).toHaveBeenCalledWith({ error: 'db error' }); + }); + }); }); diff --git a/server/controllers/user.controller/authManagement.ts b/server/controllers/user.controller/authManagement.ts index 71c5213cf9..fbb853ac0f 100644 --- a/server/controllers/user.controller/authManagement.ts +++ b/server/controllers/user.controller/authManagement.ts @@ -141,7 +141,8 @@ export const updatePassword: RequestHandler< * - Id: `UserController.updateSettings` * * Description: - * - Used to update the user's username, email, or password while authenticated + * - Used to update the user's username, email, or password on the `/account` page while authenticated + * - Currently the client only shows the `currentPassword` and `newPassword` fields if no social logins (github & google) are enabled */ export const updateSettings: RequestHandler< {}, From 60bb02afb3beed6f884709bbec9678211451d2aa Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sat, 11 Oct 2025 13:07:12 -0400 Subject: [PATCH 63/67] update tests to correct typos --- .../authManagement/updateSettings.test.ts | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts b/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts index 73b8e51d5b..b14140418a 100644 --- a/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts @@ -261,7 +261,7 @@ describe('user.controller > auth management > updateSettings (email, username, p }); expect(saveUser).toHaveBeenCalledTimes(1); }); - it('does not send a confirmation email to the user', () => { + it('sends a confirmation email to the user', () => { expect(mailerService.send).toHaveBeenCalledWith( expect.objectContaining({ subject: 'Mock confirm your email' @@ -293,7 +293,7 @@ describe('user.controller > auth management > updateSettings (email, username, p }); expect(saveUser).toHaveBeenCalledTimes(1); }); - it('does not send a confirmation email to the user', () => { + it('sends a confirmation email to the user', () => { expect(mailerService.send).toHaveBeenCalledWith( expect.objectContaining({ subject: 'Mock confirm your email' @@ -304,6 +304,7 @@ describe('user.controller > auth management > updateSettings (email, username, p }); describe('unhappy paths', () => { + // Client-side checks to require username describe.skip('when missing username', () => { beforeEach(async () => { request.setBody({ email: OLD_EMAIL }); @@ -325,6 +326,7 @@ describe('user.controller > auth management > updateSettings (email, username, p }); }); + // Client-side checks to require email describe.skip('when missing email', () => { beforeEach(async () => { request.setBody({ username: OLD_USERNAME }); @@ -332,7 +334,7 @@ describe('user.controller > auth management > updateSettings (email, username, p }); it('returns 401 with an "Missing email" message', () => { - expect(response.status).toHaveBeenCalledWith(400); + expect(response.status).toHaveBeenCalledWith(401); expect(response.json).toHaveBeenCalledWith({ error: 'Email is required.' }); @@ -346,6 +348,7 @@ describe('user.controller > auth management > updateSettings (email, username, p }); }); + // Client-side checks to require new password if current password is provided describe.skip('when given old username, old email, and matching current password and no new password', () => { beforeEach(async () => { requestBody = { @@ -357,7 +360,7 @@ describe('user.controller > auth management > updateSettings (email, username, p }); it('returns 401 with an "New password is required" message', () => { - expect(response.status).toHaveBeenCalledWith(400); + expect(response.status).toHaveBeenCalledWith(401); expect(response.json).toHaveBeenCalledWith({ error: 'New password is required.' }); @@ -371,6 +374,34 @@ describe('user.controller > auth management > updateSettings (email, username, p }); }); + describe('when given old username, old email, and non-matching current password and no new password', () => { + beforeEach(async () => { + testUser.comparePassword = jest.fn().mockResolvedValue(false); + + requestBody = { + ...minimumValidRequest, + currentPassword: 'not the same password', + newPassword: NEW_PASSWORD + }; + request.setBody(requestBody); + await updateSettings(request, response, next); + }); + + it('returns 401 with an "Current password is invalid" message', () => { + expect(response.status).toHaveBeenCalledWith(401); + expect(response.json).toHaveBeenCalledWith({ + error: 'Current password is invalid.' + }); + }); + + it('does not save the user with the new password', () => { + expect(saveUser).not.toHaveBeenCalled(); + }); + it('does not send a confirmation email to the user', () => { + expect(mailerService.send).not.toHaveBeenCalled(); + }); + }); + describe('when given old username, old email, and non-matching current password and a new password', () => { beforeEach(async () => { testUser.comparePassword = jest.fn().mockResolvedValue(false); From f8fe2de44269e8d689c6ad82432bf84a217b0fbb Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 12 Oct 2025 14:32:17 -0400 Subject: [PATCH 64/67] fix typo --- server/controllers/user.controller/authManagement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/controllers/user.controller/authManagement.ts b/server/controllers/user.controller/authManagement.ts index fbb853ac0f..19f3cf870c 100644 --- a/server/controllers/user.controller/authManagement.ts +++ b/server/controllers/user.controller/authManagement.ts @@ -21,7 +21,7 @@ import { renderResetPassword, renderEmailConfirmation } from '../../views/mail'; * - Id: `UserController.resetPasswordInitiate` * * Description: - * - Send an Reset Email email to the registered email account + * - Send an Reset-Password email to the registered email account */ export const resetPasswordInitiate: RequestHandler< {}, From e7730d6b11d7ad03952eb36ae0d184154cb9408b Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 12 Oct 2025 14:32:55 -0400 Subject: [PATCH 65/67] stronger typing on userResponse --- server/controllers/user.controller/helpers.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/server/controllers/user.controller/helpers.ts b/server/controllers/user.controller/helpers.ts index e24df5516a..36bcf86649 100644 --- a/server/controllers/user.controller/helpers.ts +++ b/server/controllers/user.controller/helpers.ts @@ -8,9 +8,7 @@ import { PublicUser, UserDocument } from '../../types'; * @param user * @returns Sanitised user */ -export function userResponse( - user: PublicUser & Record -): PublicUser { +export function userResponse(user: PublicUser | UserDocument): PublicUser { return { email: user.email, username: user.username, From 7eeac888ee339629735dc48570c7f5ac3d7f63f9 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 12 Oct 2025 14:34:15 -0400 Subject: [PATCH 66/67] extend Request type for jest-express --- server/types/jest-express/index.d.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 server/types/jest-express/index.d.ts diff --git a/server/types/jest-express/index.d.ts b/server/types/jest-express/index.d.ts new file mode 100644 index 0000000000..893a9f6a00 --- /dev/null +++ b/server/types/jest-express/index.d.ts @@ -0,0 +1,9 @@ +import type { Mock } from 'jest-express/lib/next'; +import type { PublicUser, UserDocument } from '../user'; + +declare module 'jest-express/lib/request' { + interface Request { + user?: PublicUser | UserDocument; + logIn: Mock; + } +} From 509e00aee80161a5875d1035b3d062f8da9cf608 Mon Sep 17 00:00:00 2001 From: Claire Peng Date: Sun, 12 Oct 2025 14:35:20 -0400 Subject: [PATCH 67/67] server/controller/user: update tests to have stronger typing for request and response --- .../user.controller/__tests__/apiKey.test.ts | 43 +++++-- .../authManagement/3rdPartyManagement.test.ts | 29 ++++- .../authManagement/passwordManagement.test.ts | 76 ++++++++++--- .../authManagement/updateSettings.test.ts | 105 ++++++++++++++---- .../user.controller/__tests__/signup.test.ts | 87 ++++++++++++--- .../__tests__/userPreferences.test.ts | 53 ++++++--- 6 files changed, 312 insertions(+), 81 deletions(-) diff --git a/server/controllers/user.controller/__tests__/apiKey.test.ts b/server/controllers/user.controller/__tests__/apiKey.test.ts index db85962033..875b15b2b6 100644 --- a/server/controllers/user.controller/__tests__/apiKey.test.ts +++ b/server/controllers/user.controller/__tests__/apiKey.test.ts @@ -2,18 +2,19 @@ import { last } from 'lodash'; import { Request as MockRequest } from 'jest-express/lib/request'; import { Response as MockResponse } from 'jest-express/lib/response'; import { NextFunction as MockNext } from 'jest-express/lib/next'; +import { Request, Response } from 'express'; import { Types } from 'mongoose'; import { User } from '../../../models/user'; import { createApiKey, removeApiKey } from '../apiKey'; -import type { ApiKeyDocument } from '../../../types'; +import type { ApiKeyDocument, RemoveApiKeyRequestParams } from '../../../types'; import { createMockUser } from '../__testUtils__'; jest.mock('../../../models/user'); describe('user.controller > api key', () => { - let request: any; - let response: any; + let request: MockRequest; + let response: MockResponse; let next: MockNext; beforeEach(() => { @@ -34,7 +35,11 @@ describe('user.controller > api key', () => { User.findById = jest.fn().mockResolvedValue(null); - await createApiKey(request, response, next); + await createApiKey( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); expect(response.status).toHaveBeenCalledWith(404); expect(response.json).toHaveBeenCalledWith({ @@ -49,7 +54,11 @@ describe('user.controller > api key', () => { const user = new User(); User.findById = jest.fn().mockResolvedValue(user); - await createApiKey(request, response, next); + await createApiKey( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); expect(response.status).toHaveBeenCalledWith(400); expect(response.json).toHaveBeenCalledWith({ @@ -67,7 +76,11 @@ describe('user.controller > api key', () => { User.findById = jest.fn().mockResolvedValue(user); user.save = jest.fn(); - await createApiKey(request, response, next); + await createApiKey( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); const lastKey = last(user.apiKeys); @@ -89,7 +102,11 @@ describe('user.controller > api key', () => { User.findById = jest.fn().mockResolvedValue(null); - await removeApiKey(request, response, next); + await removeApiKey( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); expect(response.status).toHaveBeenCalledWith(404); expect(response.json).toHaveBeenCalledWith({ @@ -105,7 +122,11 @@ describe('user.controller > api key', () => { User.findById = jest.fn().mockResolvedValue(user); - await removeApiKey(request, response, next); + await removeApiKey( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); expect(response.status).toHaveBeenCalledWith(404); expect(response.json).toHaveBeenCalledWith({ @@ -135,7 +156,11 @@ describe('user.controller > api key', () => { User.findById = jest.fn().mockResolvedValue(user); - await removeApiKey(request, response, next); + await removeApiKey( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); expect(user.apiKeys.pull).toHaveBeenCalledWith({ _id: 'id1' }); expect(user.save).toHaveBeenCalled(); diff --git a/server/controllers/user.controller/__tests__/authManagement/3rdPartyManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement/3rdPartyManagement.test.ts index d94745d606..d2e68ee61a 100644 --- a/server/controllers/user.controller/__tests__/authManagement/3rdPartyManagement.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement/3rdPartyManagement.test.ts @@ -1,6 +1,7 @@ import { Request as MockRequest } from 'jest-express/lib/request'; import { Response as MockResponse } from 'jest-express/lib/response'; import { NextFunction as MockNext } from 'jest-express/lib/next'; +import { Request, Response } from 'express'; import { unlinkGithub, unlinkGoogle } from '../../authManagement'; import { saveUser } from '../../helpers'; import { createMockUser } from '../../__testUtils__'; @@ -12,8 +13,8 @@ jest.mock('../../helpers', () => ({ jest.mock('../../../../utils/mail'); describe('user.controller > auth management > 3rd party auth', () => { - let request: any; - let response: any; + let request: MockRequest; + let response: MockResponse; let next: MockNext; beforeEach(() => { @@ -31,7 +32,11 @@ describe('user.controller > auth management > 3rd party auth', () => { describe('unlinkGithub', () => { describe('and when there is no user in the request', () => { beforeEach(async () => { - await unlinkGithub(request, response, next); + await unlinkGithub( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); }); it('does not call saveUser', () => { expect(saveUser).not.toHaveBeenCalled(); @@ -52,7 +57,11 @@ describe('user.controller > auth management > 3rd party auth', () => { beforeEach(async () => { request.user = user; - await unlinkGithub(request, response, next); + await unlinkGithub( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); }); it('removes the users github property', () => { expect(user.github).toBeUndefined(); @@ -69,7 +78,11 @@ describe('user.controller > auth management > 3rd party auth', () => { describe('unlinkGoogle', () => { describe('and when there is no user in the request', () => { beforeEach(async () => { - await unlinkGoogle(request, response, next); + await unlinkGoogle( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); }); it('does not call saveUser', () => { expect(saveUser).not.toHaveBeenCalled(); @@ -90,7 +103,11 @@ describe('user.controller > auth management > 3rd party auth', () => { beforeEach(async () => { request.user = user; - await unlinkGoogle(request, response, next); + await unlinkGoogle( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); }); it('removes the users google property', () => { expect(user.google).toBeUndefined(); diff --git a/server/controllers/user.controller/__tests__/authManagement/passwordManagement.test.ts b/server/controllers/user.controller/__tests__/authManagement/passwordManagement.test.ts index f8b90d2ff8..248eeac4e0 100644 --- a/server/controllers/user.controller/__tests__/authManagement/passwordManagement.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement/passwordManagement.test.ts @@ -1,6 +1,7 @@ import { Request as MockRequest } from 'jest-express/lib/request'; import { Response as MockResponse } from 'jest-express/lib/response'; import { NextFunction as MockNext } from 'jest-express/lib/next'; +import { Request, Response } from 'express'; import { User } from '../../../../models/user'; import { resetPasswordInitiate, @@ -11,7 +12,10 @@ import { generateToken } from '../../helpers'; import { createMockUser } from '../../__testUtils__'; import { mailerService } from '../../../../utils/mail'; -import { UserDocument } from '../../../../types'; +import { + ResetOrUpdatePasswordRequestParams, + UserDocument +} from '../../../../types'; jest.mock('../../../../models/user'); jest.mock('../../../../utils/mail'); @@ -21,8 +25,8 @@ jest.mock('../../helpers', () => ({ })); describe('user.controller > auth management > password management', () => { - let request: any; - let response: any; + let request: MockRequest; + let response: MockResponse; let next: MockNext; let mockToken: string; let mockUser: Partial; @@ -52,7 +56,11 @@ describe('user.controller > auth management > password management', () => { it('calls User.findByEmail with the correct email', async () => { User.findByEmail = jest.fn().mockResolvedValue({}); request.body = { email: 'email@gmail.com' }; - await resetPasswordInitiate(request, response, next); + await resetPasswordInitiate( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); expect(User.findByEmail).toHaveBeenCalledWith('email@gmail.com'); }); @@ -71,7 +79,11 @@ describe('user.controller > auth management > password management', () => { request.body = { email: 'test@example.com' }; request.headers.host = 'localhost:3000'; - await resetPasswordInitiate(request, response, next); + await resetPasswordInitiate( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); }); it('sets a resetPasswordToken with an expiry of 1h to the user', () => { expect(mockUser.resetPasswordToken).toBe(mockToken); @@ -107,12 +119,20 @@ describe('user.controller > auth management > password management', () => { request.headers.host = 'localhost:3000'; }); it('does not send the reset password email', async () => { - await resetPasswordInitiate(request, response, next); + await resetPasswordInitiate( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); expect(mailerService.send).not.toHaveBeenCalledWith(); }); it('returns a success message that does not indicate if the user exists, for security purposes', async () => { - await resetPasswordInitiate(request, response, next); + await resetPasswordInitiate( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); expect(response.json).toHaveBeenCalledWith({ success: true, @@ -136,7 +156,11 @@ describe('user.controller > auth management > password management', () => { request.body = { email: 'test@example.com' }; request.headers.host = 'localhost:3000'; - await resetPasswordInitiate(request, response, next); + await resetPasswordInitiate( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); expect(response.json).toHaveBeenCalledWith({ success: false @@ -155,7 +179,11 @@ describe('user.controller > auth management > password management', () => { request.params = { token: 'some-token' }; - await validateResetPasswordToken(request, response, next); + await validateResetPasswordToken( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); expect(User.findOne).toHaveBeenCalledWith({ resetPasswordToken: 'some-token', @@ -171,7 +199,11 @@ describe('user.controller > auth management > password management', () => { request.params = { token: 'invalid-token' }; - await validateResetPasswordToken(request, response, next); + await validateResetPasswordToken( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); }); it('returns a 401', () => { expect(response.status).toHaveBeenCalledWith(401); @@ -198,7 +230,11 @@ describe('user.controller > auth management > password management', () => { request.params = { token: 'valid-token' }; - await validateResetPasswordToken(request, response, next); + await validateResetPasswordToken( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); }); it('returns a success response', () => { expect(response.json).toHaveBeenCalledWith({ success: true }); @@ -217,7 +253,11 @@ describe('user.controller > auth management > password management', () => { request.params = { token: 'some-token' }; - await updatePassword(request, response, next); + await updatePassword( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); expect(User.findOne).toHaveBeenCalledWith({ resetPasswordToken: 'some-token', @@ -233,7 +273,11 @@ describe('user.controller > auth management > password management', () => { request.params = { token: 'invalid-token' }; - await updatePassword(request, response, next); + await updatePassword( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); }); it('returns a 401', () => { expect(response.status).toHaveBeenCalledWith(401); @@ -272,7 +316,11 @@ describe('user.controller > auth management > password management', () => { cb(null); }); - await updatePassword(request, response, next); + await updatePassword( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); }); it('calls user.save with the updated password and removes the reset password token', () => { expect(mockUser.password).toBe('newpassword'); diff --git a/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts b/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts index b14140418a..698dfa1aea 100644 --- a/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts +++ b/server/controllers/user.controller/__tests__/authManagement/updateSettings.test.ts @@ -1,6 +1,7 @@ import { Request as MockRequest } from 'jest-express/lib/request'; import { Response as MockResponse } from 'jest-express/lib/response'; import { NextFunction as MockNext } from 'jest-express/lib/next'; +import { Request, Response } from 'express'; import { User } from '../../../../models/user'; import { updateSettings } from '../../authManagement'; import { saveUser, generateToken } from '../../helpers'; @@ -19,8 +20,8 @@ jest.mock('../../helpers', () => ({ })); describe('user.controller > auth management > updateSettings (email, username, password)', () => { - let request: any; - let response: any; + let request: MockRequest; + let response: MockResponse; let next: MockNext; let requestBody: UpdateSettingsRequestBody; let startingUser: Partial; // copy of found user that won't be mutated in test @@ -75,7 +76,7 @@ describe('user.controller > auth management > updateSettings (email, username, p (generateToken as jest.Mock).mockResolvedValue(GENERATED_TOKEN); (mailerService.send as jest.Mock).mockResolvedValue(true); - request.user = { id: 'valid-id' }; + request.user = createMockUser({ id: 'valid-id' }); request.headers.host = 'localhost:3000'; }); @@ -89,9 +90,13 @@ describe('user.controller > auth management > updateSettings (email, username, p describe('if the user is not found', () => { beforeEach(async () => { (User.findById as jest.Mock).mockResolvedValue(null); - request.user = { id: 'nonexistent-id' }; + request.user = createMockUser({ id: 'nonexistent-id' }); - await updateSettings(request, response, next); + await updateSettings( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); }); it('returns 404 and a user-not-found error', async () => { @@ -112,7 +117,11 @@ describe('user.controller > auth management > updateSettings (email, username, p beforeEach(async () => { requestBody = { ...minimumValidRequest }; request.setBody(requestBody); - await updateSettings(request, response, next); + await updateSettings( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); }); it('saves the user with the correct details exactly once', () => { expect(saveUser).toHaveBeenCalledWith(response, { ...startingUser }); @@ -128,7 +137,11 @@ describe('user.controller > auth management > updateSettings (email, username, p beforeEach(async () => { requestBody = { ...minimumValidRequest, username: NEW_USERNAME }; request.setBody(requestBody); - await updateSettings(request, response, next); + await updateSettings( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); }); it('saves the user with the correct details exactly once', () => { expect(saveUser).toHaveBeenCalledWith(response, { @@ -146,7 +159,11 @@ describe('user.controller > auth management > updateSettings (email, username, p beforeEach(async () => { requestBody = { ...minimumValidRequest, email: NEW_EMAIL }; request.setBody(requestBody); - await updateSettings(request, response, next); + await updateSettings( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); }); it('saves the user with the correct details & verification token once', () => { expect(saveUser).toHaveBeenCalledWith(response, { @@ -171,7 +188,11 @@ describe('user.controller > auth management > updateSettings (email, username, p beforeEach(async () => { requestBody = { username: NEW_USERNAME, email: NEW_EMAIL }; request.setBody(requestBody); - await updateSettings(request, response, next); + await updateSettings( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); }); it('saves the user with the correct details once', () => { expect(saveUser).toHaveBeenCalledWith(response, { @@ -201,7 +222,11 @@ describe('user.controller > auth management > updateSettings (email, username, p newPassword: NEW_PASSWORD }; request.setBody(requestBody); - await updateSettings(request, response, next); + await updateSettings( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); }); it('saves the user with the correct details once', () => { expect(saveUser).toHaveBeenCalledWith(response, { @@ -224,7 +249,11 @@ describe('user.controller > auth management > updateSettings (email, username, p newPassword: NEW_PASSWORD }; request.setBody(requestBody); - await updateSettings(request, response, next); + await updateSettings( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); }); it('saves the user with the correct details once', () => { expect(saveUser).toHaveBeenCalledWith(response, { @@ -248,7 +277,11 @@ describe('user.controller > auth management > updateSettings (email, username, p newPassword: NEW_PASSWORD }; request.setBody(requestBody); - await updateSettings(request, response, next); + await updateSettings( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); }); it('saves the user with the correct details once', () => { expect(saveUser).toHaveBeenCalledWith(response, { @@ -279,7 +312,11 @@ describe('user.controller > auth management > updateSettings (email, username, p newPassword: NEW_PASSWORD }; request.setBody(requestBody); - await updateSettings(request, response, next); + await updateSettings( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); }); it('saves the user with the correct details once', () => { expect(saveUser).toHaveBeenCalledWith(response, { @@ -308,7 +345,11 @@ describe('user.controller > auth management > updateSettings (email, username, p describe.skip('when missing username', () => { beforeEach(async () => { request.setBody({ email: OLD_EMAIL }); - await updateSettings(request, response, next); + await updateSettings( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); }); it('returns 401 with an "Missing username" message', () => { @@ -330,7 +371,11 @@ describe('user.controller > auth management > updateSettings (email, username, p describe.skip('when missing email', () => { beforeEach(async () => { request.setBody({ username: OLD_USERNAME }); - await updateSettings(request, response, next); + await updateSettings( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); }); it('returns 401 with an "Missing email" message', () => { @@ -356,7 +401,11 @@ describe('user.controller > auth management > updateSettings (email, username, p currentPassword: OLD_PASSWORD }; request.setBody(requestBody); - await updateSettings(request, response, next); + await updateSettings( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); }); it('returns 401 with an "New password is required" message', () => { @@ -384,7 +433,11 @@ describe('user.controller > auth management > updateSettings (email, username, p newPassword: NEW_PASSWORD }; request.setBody(requestBody); - await updateSettings(request, response, next); + await updateSettings( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); }); it('returns 401 with an "Current password is invalid" message', () => { @@ -412,7 +465,11 @@ describe('user.controller > auth management > updateSettings (email, username, p newPassword: NEW_PASSWORD }; request.setBody(requestBody); - await updateSettings(request, response, next); + await updateSettings( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); }); it('returns 401 with an error message', () => { @@ -437,7 +494,11 @@ describe('user.controller > auth management > updateSettings (email, username, p newPassword: NEW_PASSWORD }; request.setBody(requestBody); - await updateSettings(request, response, next); + await updateSettings( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); }); it('returns 401 with an error message', () => { @@ -462,7 +523,11 @@ describe('user.controller > auth management > updateSettings (email, username, p User.findById = jest.fn().mockRejectedValue('db error'); requestBody = minimumValidRequest; request.setBody(requestBody); - await updateSettings(request, response, next); + await updateSettings( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); }); it('returns a 500 error', () => { expect(response.status).toHaveBeenCalledWith(500); diff --git a/server/controllers/user.controller/__tests__/signup.test.ts b/server/controllers/user.controller/__tests__/signup.test.ts index df823a9639..51795cda9b 100644 --- a/server/controllers/user.controller/__tests__/signup.test.ts +++ b/server/controllers/user.controller/__tests__/signup.test.ts @@ -1,6 +1,7 @@ import { Request as MockRequest } from 'jest-express/lib/request'; import { Response as MockResponse } from 'jest-express/lib/response'; import { NextFunction as MockNext } from 'jest-express/lib/next'; +import { Request, Response } from 'express'; import { User } from '../../../models/user'; import { createUser, @@ -10,14 +11,16 @@ import { } from '../signup'; import { mailerService } from '../../../utils/mail'; +import { DuplicateUserCheckQuery, VerifyEmailQuery } from '../../../types'; +import { createMockUser } from '../__testUtils__'; jest.mock('../../../models/user'); jest.mock('../../../utils/mail'); jest.mock('../../../views/mail'); describe('user.controller > signup', () => { - let request: any; - let response: any; + let request: MockRequest; + let response: MockResponse; let next: MockNext; beforeEach(() => { @@ -45,7 +48,11 @@ describe('user.controller > signup', () => { password: 'password' }); - await createUser(request, response, next); + await createUser( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); expect(User.findByEmailAndUsername).toHaveBeenCalledWith( 'existing@example.com', @@ -66,7 +73,11 @@ describe('user.controller > signup', () => { password: 'password' }); - await createUser(request, response, next); + await createUser( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); expect(User.findByEmailAndUsername).toHaveBeenCalledWith( 'existing@example.com', @@ -85,7 +96,11 @@ describe('user.controller > signup', () => { request.query = { check_type: 'email', email: 'test@example.com' }; - await duplicateUserCheck(request, response, next); + await duplicateUserCheck( + (request as unknown) as Request<{}, {}, {}, DuplicateUserCheckQuery>, + (response as unknown) as Response, + next + ); expect(User.findByEmailOrUsername).toHaveBeenCalledWith( 'test@example.com', @@ -101,7 +116,11 @@ describe('user.controller > signup', () => { request.query = { check_type: 'username', username: 'newuser' }; - await duplicateUserCheck(request, response, next); + await duplicateUserCheck( + (request as unknown) as Request<{}, {}, {}, DuplicateUserCheckQuery>, + (response as unknown) as Response, + next + ); expect(response.json).toHaveBeenCalledWith({ exists: false, @@ -116,7 +135,11 @@ describe('user.controller > signup', () => { request.query = { check_type: 'username', username: 'existinguser' }; - await duplicateUserCheck(request, response, next); + await duplicateUserCheck( + (request as unknown) as Request<{}, {}, {}, DuplicateUserCheckQuery>, + (response as unknown) as Response, + next + ); expect(response.json).toHaveBeenCalledWith({ exists: true, @@ -132,7 +155,11 @@ describe('user.controller > signup', () => { request.query = { check_type: 'email', email: 'existing@example.com' }; - await duplicateUserCheck(request, response, next); + await duplicateUserCheck( + (request as unknown) as Request<{}, {}, {}, DuplicateUserCheckQuery>, + (response as unknown) as Response, + next + ); expect(response.json).toHaveBeenCalledWith({ exists: true, @@ -150,7 +177,11 @@ describe('user.controller > signup', () => { request.query = { t: 'invalidtoken' }; - await verifyEmail(request, response, next); + await verifyEmail( + (request as unknown) as Request<{}, {}, {}, VerifyEmailQuery>, + (response as unknown) as Response, + next + ); expect(User.findOne).toHaveBeenCalledWith({ verifiedToken: 'invalidtoken', @@ -182,7 +213,11 @@ describe('user.controller > signup', () => { request.query = { t: 'validtoken' }; - await verifyEmail(request, response, next); + await verifyEmail( + (request as unknown) as Request<{}, {}, {}, VerifyEmailQuery>, + (response as unknown) as Response, + next + ); expect(mockUser.verified).toBe('verified'); expect(mockUser.verifiedToken).toBeNull(); @@ -198,10 +233,14 @@ describe('user.controller > signup', () => { .fn() .mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); - request.user = { id: 'nonexistentid' }; + request.user = createMockUser({ id: 'nonexistentid' }); request.headers.host = 'localhost:3000'; - await emailVerificationInitiate(request, response, next); + await emailVerificationInitiate( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); expect(User.findById).toHaveBeenCalledWith('nonexistentid'); expect(response.status).toHaveBeenCalledWith(404); @@ -220,10 +259,14 @@ describe('user.controller > signup', () => { .fn() .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); - request.user = { id: 'user1' }; + request.user = createMockUser({ id: 'user1' }); request.headers.host = 'localhost:3000'; - await emailVerificationInitiate(request, response, next); + await emailVerificationInitiate( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); expect(response.status).toHaveBeenCalledWith(409); expect(response.json).toHaveBeenCalledWith({ @@ -249,10 +292,14 @@ describe('user.controller > signup', () => { .fn() .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); - request.user = { id: 'user1' }; + request.user = createMockUser({ id: 'user1' }); request.headers.host = 'localhost:3000'; - await emailVerificationInitiate(request, response, next); + await emailVerificationInitiate( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); expect(User.findById).toHaveBeenCalledWith('user1'); expect(mailerService.send).toHaveBeenCalledWith( @@ -287,10 +334,14 @@ describe('user.controller > signup', () => { .fn() .mockRejectedValue(new Error('Mailer fail')); - request.user = { id: 'user1' }; + request.user = createMockUser({ id: 'user1' }); request.headers.host = 'localhost:3000'; - await emailVerificationInitiate(request, response, next); + await emailVerificationInitiate( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); expect(response.status).toHaveBeenCalledWith(500); expect(response.send).toHaveBeenCalledWith({ diff --git a/server/controllers/user.controller/__tests__/userPreferences.test.ts b/server/controllers/user.controller/__tests__/userPreferences.test.ts index d0f653ad96..9fc03189a1 100644 --- a/server/controllers/user.controller/__tests__/userPreferences.test.ts +++ b/server/controllers/user.controller/__tests__/userPreferences.test.ts @@ -1,6 +1,7 @@ import { Request as MockRequest } from 'jest-express/lib/request'; import { Response as MockResponse } from 'jest-express/lib/response'; import { NextFunction as MockNext } from 'jest-express/lib/next'; +import { Request, Response } from 'express'; import { User } from '../../../models/user'; import { updatePreferences, updateCookieConsent } from '../userPreferences'; import { createMockUser, mockUserPreferences } from '../__testUtils__'; @@ -15,8 +16,8 @@ jest.mock('../../../models/user'); const mockBaseUser = createMockUser(); describe('user.controller > user preferences', () => { - let request: any; - let response: any; + let request: MockRequest; + let response: MockResponse; let next: MockNext; let mockUser: PublicUser & Record; @@ -43,12 +44,16 @@ describe('user.controller > user preferences', () => { .fn() .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); - request.user = { id: 'user1' }; + request.user = createMockUser({ id: 'user1' }); request.body = { preferences: { theme: AppThemeOptions.DARK, notifications: true } }; - await updatePreferences(request, response, next); + await updatePreferences( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); // Check that preferences were merged correctly expect(mockUser.preferences).toEqual({ @@ -64,9 +69,13 @@ describe('user.controller > user preferences', () => { .fn() .mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); - request.user = { id: 'nonexistentid' }; + request.user = createMockUser({ id: 'nonexistentid' }); - await updatePreferences(request, response, next); + await updatePreferences( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); expect(User.findById).toHaveBeenCalledWith('nonexistentid'); expect(response.status).toHaveBeenCalledWith(404); @@ -82,10 +91,14 @@ describe('user.controller > user preferences', () => { .fn() .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); - request.user = { id: 'user1' }; + request.user = createMockUser({ id: 'user1' }); request.body = { preferences: { theme: 'dark' } }; - await updatePreferences(request, response, next); + await updatePreferences( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); expect(response.status).toHaveBeenCalledWith(500); expect(response.json).toHaveBeenCalledWith({ error: expect.any(Error) }); @@ -102,10 +115,14 @@ describe('user.controller > user preferences', () => { .fn() .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); - request.user = { id: 'user1' }; + request.user = createMockUser({ id: 'user1' }); request.body = { cookieConsent: CookieConsentOptions.ESSENTIAL }; - await updateCookieConsent(request, response, next); + await updateCookieConsent( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); expect(User.findById).toHaveBeenCalledWith('user1'); expect(mockUser.cookieConsent).toBe(CookieConsentOptions.ESSENTIAL); @@ -121,10 +138,14 @@ describe('user.controller > user preferences', () => { .fn() .mockReturnValue({ exec: jest.fn().mockResolvedValue(null) }); - request.user = { id: 'nonexistentid' }; + request.user = createMockUser({ id: 'nonexistentid' }); request.body = { cookieConsent: true }; - await updateCookieConsent(request, response, next); + await updateCookieConsent( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); expect(User.findById).toHaveBeenCalledWith('nonexistentid'); expect(response.status).toHaveBeenCalledWith(404); @@ -141,10 +162,14 @@ describe('user.controller > user preferences', () => { .fn() .mockReturnValue({ exec: jest.fn().mockResolvedValue(mockUser) }); - request.user = { id: 'user1' }; + request.user = createMockUser({ id: 'user1' }); request.body = { cookieConsent: true }; - await updateCookieConsent(request, response, next); + await updateCookieConsent( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); expect(response.status).toHaveBeenCalledWith(500); expect(response.json).toHaveBeenCalledWith({ error: expect.any(Error) });