From f46c2edd1b8c9e1d4841c0cc812b74741f6015dc Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 15 May 2026 10:58:38 -0700 Subject: [PATCH 1/2] add password and newPassword to editUserValidation --- server/src/validation/userValidation.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server/src/validation/userValidation.ts b/server/src/validation/userValidation.ts index e613b86e6..c26c10116 100644 --- a/server/src/validation/userValidation.ts +++ b/server/src/validation/userValidation.ts @@ -49,6 +49,16 @@ export const editUserBodyValidation = z.object({ lastName: nameValidation.optional(), email: lowercaseEmailValidation.optional(), profilePicture: z.string().optional(), + password: z + .string() + .min(8, "Password must be at least 8 characters") + .regex(passwordPattern, "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character") + .optional(), + newPassword: z + .string() + .min(8, "Password must be at least 8 characters") + .regex(passwordPattern, "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character") + .optional(), }); // Canonical user shape returned by auth/user endpoints. Keep aligned with what From 704f25b987814c57d38d1c67cf6d2cc13f8d7610 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 15 May 2026 11:14:39 -0700 Subject: [PATCH 2/2] extract password validation, add regression test --- server/src/validation/shared.ts | 5 ++ server/src/validation/userValidation.ts | 44 ++++++++---------- .../unit/validation/userValidation.test.ts | 46 +++++++++++++++++++ 3 files changed, 70 insertions(+), 25 deletions(-) create mode 100644 server/test/unit/validation/userValidation.test.ts diff --git a/server/src/validation/shared.ts b/server/src/validation/shared.ts index 99f92f250..a3e17866b 100644 --- a/server/src/validation/shared.ts +++ b/server/src/validation/shared.ts @@ -4,6 +4,11 @@ import { type UserRole } from "@/types/user.js"; export const passwordPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\-_=+[\]{};:'",.~`|\\/])[A-Za-z0-9!?@#$%^&*()\-_=+[\]{};:'",.~`|\\/]+$/; +export const passwordValidation = z + .string() + .min(8, "Password must be at least 8 characters") + .regex(passwordPattern, "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character"); + export const nameValidation = z .string() .trim() diff --git a/server/src/validation/userValidation.ts b/server/src/validation/userValidation.ts index c26c10116..40e7d8b54 100644 --- a/server/src/validation/userValidation.ts +++ b/server/src/validation/userValidation.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { UserRoles } from "@/types/user.js"; -import { nameValidation, lowercaseEmailValidation, passwordPattern } from "./shared.js"; +import { nameValidation, lowercaseEmailValidation, passwordValidation } from "./shared.js"; //**************************************** // User Validations @@ -27,39 +27,33 @@ export const editSuperadminUserByIdBodyValidation = z.object({ }); export const editUserPasswordByIdBodyValidation = z.object({ - password: z - .string() - .min(8, "Password must be at least 8 characters") - .regex(passwordPattern, "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character"), + password: passwordValidation, }); export const createUserBodyValidation = z.object({ firstName: nameValidation, lastName: nameValidation, email: lowercaseEmailValidation, - password: z - .string() - .min(8, "Password must be at least 8 characters") - .regex(passwordPattern, "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character"), + password: passwordValidation, role: z.array(z.enum(UserRoles)).min(1, "At least one role is required"), }); -export const editUserBodyValidation = z.object({ - firstName: nameValidation.optional(), - lastName: nameValidation.optional(), - email: lowercaseEmailValidation.optional(), - profilePicture: z.string().optional(), - password: z - .string() - .min(8, "Password must be at least 8 characters") - .regex(passwordPattern, "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character") - .optional(), - newPassword: z - .string() - .min(8, "Password must be at least 8 characters") - .regex(passwordPattern, "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character") - .optional(), -}); +export const editUserBodyValidation = z + .object({ + firstName: nameValidation.optional(), + lastName: nameValidation.optional(), + email: lowercaseEmailValidation.optional(), + profilePicture: z.string().optional(), + password: passwordValidation.optional(), + newPassword: passwordValidation.optional(), + }) + // Changing a password requires both the current password and the new one. + // The service only runs the change-password flow when both are present, so + // reject a half-filled request + .refine((data) => (data.password === undefined) === (data.newPassword === undefined), { + message: "Both current password and new password are required to change your password", + path: ["newPassword"], + }); // Canonical user shape returned by auth/user endpoints. Keep aligned with what // the controllers actually serialize (password is intentionally omitted). diff --git a/server/test/unit/validation/userValidation.test.ts b/server/test/unit/validation/userValidation.test.ts new file mode 100644 index 000000000..2e263d1a9 --- /dev/null +++ b/server/test/unit/validation/userValidation.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "@jest/globals"; +import { editUserBodyValidation } from "../../../src/validation/userValidation.ts"; + +const validPassword = "OldPass1!"; +const validNewPassword = "NewPass1!"; + +describe("editUserBodyValidation", () => { + // Regression: the Joi->Zod validation refactor (commit c3a41272a3) dropped + // `password`/`newPassword` from this schema. Because z.object() strips + // unknown keys, the change-password flow in userService.editUser silently + // became a no-op. Both fields must survive parsing. + it("retains password and newPassword on the parsed body", () => { + const result = editUserBodyValidation.safeParse({ + password: validPassword, + newPassword: validNewPassword, + }); + expect(result.success).toBe(true); + expect(result.data).toEqual({ + password: validPassword, + newPassword: validNewPassword, + }); + }); + + it("accepts a profile-only edit with neither password field", () => { + const result = editUserBodyValidation.safeParse({ firstName: "Ada", lastName: "Lovelace" }); + expect(result.success).toBe(true); + }); + + it("rejects password without newPassword", () => { + const result = editUserBodyValidation.safeParse({ password: validPassword }); + expect(result.success).toBe(false); + }); + + it("rejects newPassword without password", () => { + const result = editUserBodyValidation.safeParse({ newPassword: validNewPassword }); + expect(result.success).toBe(false); + }); + + it("rejects a newPassword that does not meet the strength policy", () => { + const result = editUserBodyValidation.safeParse({ + password: validPassword, + newPassword: "weak", + }); + expect(result.success).toBe(false); + }); +});