From dc1b3c46e7051520baf2539f1862de3ea6a2c970 Mon Sep 17 00:00:00 2001 From: Tanuj Date: Fri, 31 Jul 2026 15:24:44 +1000 Subject: [PATCH 01/13] Add PII context param to checkPasswordStrength --- application/common/src/PasswordStrength.ts | 46 +++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/application/common/src/PasswordStrength.ts b/application/common/src/PasswordStrength.ts index a105e29d..57a5e956 100644 --- a/application/common/src/PasswordStrength.ts +++ b/application/common/src/PasswordStrength.ts @@ -8,12 +8,46 @@ export interface FieldErrors { } } +export interface PasswordContext { + email?: string + firstName?: string + middleName?: string + lastName?: string + dob?: string +} + interface PasswordStrengthResult { isValid: boolean fields: FieldErrors } -export function checkPasswordStrength(password: string): PasswordStrengthResult { +function extractPiiTokens(context: PasswordContext): string[] { + const tokens = new Set() + const addTokens = (value: string | undefined) => { + if (!value) return + value + .split(/\s+/) + .map((token) => token.trim().toLowerCase()) + .filter((token) => token.length >= 3) + .forEach((token) => tokens.add(token)) + } + addTokens(context.firstName) + addTokens(context.middleName) + addTokens(context.lastName) + if (context.email) { + addTokens(context.email.split('@')[0]) + } + if (context.dob) { + const year = context.dob.match(/\d{4}/)?.[0] + if (year) tokens.add(year) + } + return Array.from(tokens) +} + +export function checkPasswordStrength( + password: string, + context?: PasswordContext, +): PasswordStrengthResult { const fields: FieldErrors = {} const baseWordRegex = new RegExp(commonPasswordBaseWords.join('|'), 'i') @@ -25,6 +59,16 @@ export function checkPasswordStrength(password: string): PasswordStrengthResult message: `Password must not contain easily guessable words (i.e. ${commonPasswordBaseWords.join(', ')})`, } } + if (context) { + const tokens = extractPiiTokens(context) + const lowerPassword = password.toLowerCase() + const matchedToken = tokens.find((t) => lowerPassword.includes(t)) + if (matchedToken) { + fields.PersonalInfo = { + message: `Password contains personal information: "${matchedToken}"`, + } + } + } if (!/[A-Z]/.test(password)) { fields.Uppercase = { message: 'Password must contain at least one uppercase letter', From 9bbe4789d5937ae6c85c742bc5901674e5f511dd Mon Sep 17 00:00:00 2001 From: Tanuj Date: Fri, 31 Jul 2026 15:31:59 +1000 Subject: [PATCH 02/13] Add unit tests for checkPasswordStrength PII rejection --- .../common/src/PasswordStrength.test.ts | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 application/common/src/PasswordStrength.test.ts diff --git a/application/common/src/PasswordStrength.test.ts b/application/common/src/PasswordStrength.test.ts new file mode 100644 index 00000000..db2f78c4 --- /dev/null +++ b/application/common/src/PasswordStrength.test.ts @@ -0,0 +1,164 @@ +import { checkPasswordStrength } from './PasswordStrength' + +describe('checkPasswordStrength', () => { + const strongPassword = 'Constellation2026' + + describe('without context (backwards compatibility)', () => { + it('accepts a strong password with no context', () => { + const { isValid, fields } = checkPasswordStrength(strongPassword) + expect(isValid).toBe(true) + expect(fields).toEqual({}) + }) + + it('rejects a password shorter than 14 characters', () => { + const { isValid, fields } = checkPasswordStrength('Short2026Aok') + expect(isValid).toBe(false) + expect(fields.Length).toBeDefined() + }) + + it('rejects a password without an uppercase letter', () => { + const { isValid, fields } = checkPasswordStrength('constellation2026') + expect(isValid).toBe(false) + expect(fields.Uppercase).toBeDefined() + }) + + it('rejects a password without a lowercase letter', () => { + const { isValid, fields } = checkPasswordStrength('CONSTELLATION2026') + expect(isValid).toBe(false) + expect(fields.Lowercase).toBeDefined() + }) + + it('rejects a password without a digit', () => { + const { isValid, fields } = checkPasswordStrength('ConstellationOnly') + expect(isValid).toBe(false) + expect(fields.Number).toBeDefined() + }) + + it('rejects a password containing a common base word', () => { + const { isValid, fields } = checkPasswordStrength('MyPassword2026Ok') + expect(isValid).toBe(false) + expect(fields.CommonBase).toBeDefined() + }) + }) + + describe('with context — PII rejection', () => { + it('rejects a password containing the first name', () => { + const { isValid, fields } = checkPasswordStrength('Tanuj2026StrongOne', { + firstName: 'Tanuj', + }) + expect(isValid).toBe(false) + expect(fields.PersonalInfo).toBeDefined() + expect(fields.PersonalInfo.message).toContain('tanuj') + }) + + it('rejects a password containing the last name', () => { + const { isValid, fields } = checkPasswordStrength('AndersonMyPas2026', { + lastName: 'Anderson', + }) + expect(isValid).toBe(false) + expect(fields.PersonalInfo).toBeDefined() + }) + + it('rejects a password containing the middle name', () => { + const { isValid, fields } = checkPasswordStrength('RobertMyStrong26', { + middleName: 'Robert', + }) + expect(isValid).toBe(false) + expect(fields.PersonalInfo).toBeDefined() + }) + + it('rejects a password containing the email local part', () => { + const { isValid, fields } = checkPasswordStrength('Tanuj2026MyStrong', { + email: 'tanuj@garvan.org.au', + }) + expect(isValid).toBe(false) + expect(fields.PersonalInfo).toBeDefined() + expect(fields.PersonalInfo.message).toContain('tanuj') + }) + + it('does not reject a password containing only the email domain', () => { + const { fields } = checkPasswordStrength('GarvanStaff2026Big', { + email: 'someone@garvan.org.au', + }) + expect(fields.PersonalInfo).toBeUndefined() + }) + + it('rejects a password containing the DOB year', () => { + const { isValid, fields } = checkPasswordStrength('MyStrongOne1990Ok', { + dob: '1990-05-15', + }) + expect(isValid).toBe(false) + expect(fields.PersonalInfo).toBeDefined() + expect(fields.PersonalInfo.message).toContain('1990') + }) + + it('matches case-insensitively', () => { + const { isValid, fields } = checkPasswordStrength('TANUJBrightStar26', { + firstName: 'Tanuj', + }) + expect(isValid).toBe(false) + expect(fields.PersonalInfo).toBeDefined() + }) + + it('accepts a strong password with valid context', () => { + const { isValid, fields } = checkPasswordStrength(strongPassword, { + firstName: 'Elizabeth', + lastName: 'Windsor', + email: 'elizabeth@example.com', + dob: '1926-04-21', + }) + expect(isValid).toBe(true) + expect(fields).toEqual({}) + }) + + it('ignores tokens shorter than 3 characters', () => { + const { fields } = checkPasswordStrength(strongPassword, { + firstName: 'Li', + }) + expect(fields.PersonalInfo).toBeUndefined() + }) + + it('splits multi-word names on whitespace', () => { + const { isValid, fields } = checkPasswordStrength('BergerKing2026Big', { + lastName: 'Van Der Berg', + }) + expect(isValid).toBe(false) + expect(fields.PersonalInfo).toBeDefined() + }) + + it('handles an empty context object', () => { + const { isValid, fields } = checkPasswordStrength(strongPassword, {}) + expect(isValid).toBe(true) + expect(fields.PersonalInfo).toBeUndefined() + }) + + it('handles context with all undefined fields', () => { + const { isValid, fields } = checkPasswordStrength(strongPassword, { + firstName: undefined, + lastName: undefined, + email: undefined, + dob: undefined, + }) + expect(isValid).toBe(true) + expect(fields.PersonalInfo).toBeUndefined() + }) + + it('returns multiple errors when password violates multiple checks', () => { + const { isValid, fields } = checkPasswordStrength('tanuj', { + firstName: 'Tanuj', + }) + expect(isValid).toBe(false) + expect(fields.Length).toBeDefined() + expect(fields.Uppercase).toBeDefined() + expect(fields.Number).toBeDefined() + expect(fields.PersonalInfo).toBeDefined() + }) + + it('names the specific matched token in the error message', () => { + const { fields } = checkPasswordStrength('Elizabeth2026Extra', { + firstName: 'Elizabeth', + }) + expect(fields.PersonalInfo.message).toContain('elizabeth') + }) + }) +}) From 02d062b68e46fad63a1ed2e0ba9cec5e2c70b308 Mon Sep 17 00:00:00 2001 From: Tanuj Date: Fri, 31 Jul 2026 15:54:44 +1000 Subject: [PATCH 03/13] Wire PII context through auth register endpoints --- .../backend/src/controllers/AuthController.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/application/backend/src/controllers/AuthController.ts b/application/backend/src/controllers/AuthController.ts index 92c6a699..94f60f11 100644 --- a/application/backend/src/controllers/AuthController.ts +++ b/application/backend/src/controllers/AuthController.ts @@ -83,7 +83,12 @@ export class AuthController extends Controller { public async registerUser(@Body() bodyRequest: RegisterRequest): Promise { const { password, ...userDetails } = bodyRequest - const { isValid, fields } = await checkPasswordStrength(password) + const { isValid, fields } = await checkPasswordStrength(password, { + firstName: bodyRequest.firstName, + middleName: bodyRequest.middleName, + lastName: bodyRequest.lastName, + email: bodyRequest.email, + }) if (!isValid) { throw new ValidateError(fields, 'Password does not meet strength requirements') @@ -145,7 +150,7 @@ export class AuthController extends Controller { ): Promise { const { password, email } = bodyRequest - const { isValid, fields } = await checkPasswordStrength(password) + const { isValid, fields } = await checkPasswordStrength(password, { email }) if (!isValid) { throw new ValidateError(fields, 'Password does not meet strength requirements') @@ -214,7 +219,13 @@ export class AuthController extends Controller { } // Check and hash Password - const { isValid, fields } = await checkPasswordStrength(password) + const { isValid, fields } = await checkPasswordStrength(password, { + firstName, + middleName, + lastName, + email, + dob: bodyRequest.dob, + }) if (!isValid) { throw new ValidateError(fields, 'Password does not meet strength requirements') } From e64f2f991470242d58572f165959e1af29ae39d7 Mon Sep 17 00:00:00 2001 From: Tanuj Date: Fri, 31 Jul 2026 15:55:20 +1000 Subject: [PATCH 04/13] Wire PII context through password reset endpoint --- .../backend/src/controllers/UsersController.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/application/backend/src/controllers/UsersController.ts b/application/backend/src/controllers/UsersController.ts index 733e5598..9257b688 100644 --- a/application/backend/src/controllers/UsersController.ts +++ b/application/backend/src/controllers/UsersController.ts @@ -459,8 +459,19 @@ export class UsersController extends Controller { throw new PasswordResetTokenInvalidError('Reset token expired') } + // Fetch DOB from ParticipantProfile if this user has one (participants only) + const participantProfile = await prisma.participantProfile.findFirst({ + where: { userId: passwordResetToken.userId }, + }) + // Validate the new password against the strength requirements - const { isValid, fields } = await checkPasswordStrength(newPassword) + const { isValid, fields } = await checkPasswordStrength(newPassword, { + firstName: passwordResetToken.user.firstName, + middleName: passwordResetToken.user.middleName ?? undefined, + lastName: passwordResetToken.user.lastName, + email: passwordResetToken.user.email, + dob: participantProfile?.dob ?? undefined, + }) if (!isValid) { throw new ValidateError(fields, 'New password does not meet strength requirements') From b4cd0651007611694f24c6a68a02d99bcc345a5a Mon Sep 17 00:00:00 2001 From: Tanuj Date: Fri, 31 Jul 2026 16:06:41 +1000 Subject: [PATCH 05/13] Update tests to use passwords that don't collide with placeholder PII --- application/backend/src/controllers/UsersController.test.ts | 2 +- application/backend/tests/integration/ResetPassword.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/application/backend/src/controllers/UsersController.test.ts b/application/backend/src/controllers/UsersController.test.ts index 0b321735..7a394f1f 100644 --- a/application/backend/src/controllers/UsersController.test.ts +++ b/application/backend/src/controllers/UsersController.test.ts @@ -581,7 +581,7 @@ describe('UsersController', () => { it('should reset the password when given a valid reset token and new password', async () => { const requestBody: ResetPasswordRequest = { token: resetToken, - newPassword: TestUsers.GUARDIAN_2.password, // Providing a different test users password + newPassword: 'BrightMorningStar2026', } const response = await request(app).post('/users/password/reset').send(requestBody) diff --git a/application/backend/tests/integration/ResetPassword.test.ts b/application/backend/tests/integration/ResetPassword.test.ts index 032a9181..c27699d4 100644 --- a/application/backend/tests/integration/ResetPassword.test.ts +++ b/application/backend/tests/integration/ResetPassword.test.ts @@ -17,8 +17,8 @@ describe('User Password Reset', () => { const userEmail = TestUsers.PASSWORD_RESET_USER.email const userId = TestUsers.PASSWORD_RESET_USER.id const originalPassword = TestUsers.PASSWORD_RESET_USER.password - // Note: using different test data pw to ensure consistency with pw requirements - const newPassword = TestUsers.PARTICIPANT_COMPLETED.password + // Note: using a password that doesn't contain the seeded user's name/email for PII check + const newPassword = 'BrightMorningStar2026' const invalidPassword = 'password' beforeAll(async () => { From cc2ea36063f1777cb15f4a82d2654a416e6a90d1 Mon Sep 17 00:00:00 2001 From: Tanuj Date: Fri, 31 Jul 2026 16:12:14 +1000 Subject: [PATCH 06/13] Update test seed placeholders and add PII rejection integration test --- .../src/controllers/UsersController.test.ts | 2 +- .../tests/integration/ResetPassword.test.ts | 20 +++++++++++++++++-- application/common/testing/seed.ts | 4 ++-- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/application/backend/src/controllers/UsersController.test.ts b/application/backend/src/controllers/UsersController.test.ts index 7a394f1f..0b321735 100644 --- a/application/backend/src/controllers/UsersController.test.ts +++ b/application/backend/src/controllers/UsersController.test.ts @@ -581,7 +581,7 @@ describe('UsersController', () => { it('should reset the password when given a valid reset token and new password', async () => { const requestBody: ResetPasswordRequest = { token: resetToken, - newPassword: 'BrightMorningStar2026', + newPassword: TestUsers.GUARDIAN_2.password, // Providing a different test users password } const response = await request(app).post('/users/password/reset').send(requestBody) diff --git a/application/backend/tests/integration/ResetPassword.test.ts b/application/backend/tests/integration/ResetPassword.test.ts index c27699d4..16b5efe7 100644 --- a/application/backend/tests/integration/ResetPassword.test.ts +++ b/application/backend/tests/integration/ResetPassword.test.ts @@ -17,9 +17,10 @@ describe('User Password Reset', () => { const userEmail = TestUsers.PASSWORD_RESET_USER.email const userId = TestUsers.PASSWORD_RESET_USER.id const originalPassword = TestUsers.PASSWORD_RESET_USER.password - // Note: using a password that doesn't contain the seeded user's name/email for PII check - const newPassword = 'BrightMorningStar2026' + // Note: using different test data pw to ensure consistency with pw requirements + const newPassword = TestUsers.PARTICIPANT_COMPLETED.password const invalidPassword = 'password' + const piiPassword = 'MyResetLoginPass26' // contains "reset" - the seeded user's firstName beforeAll(async () => { api.run() @@ -104,6 +105,21 @@ describe('User Password Reset', () => { expect(isPasswordCorrect).toBe(true) }) + it('should reject a new password containing the user personal info', async () => { + const response = await request(app).post('/users/password/reset').send({ + token: resetToken, + newPassword: piiPassword, + }) + + expect(response.status).toBe(422) + expect(response.body.details).toHaveProperty('PersonalInfo') + + // Check the original password is still in the database + const updatedUser = await prisma.user.findUnique({ where: { id: userId } }) + const isPasswordCorrect = await verifyPassword(updatedUser!.password, originalPassword) + expect(isPasswordCorrect).toBe(true) + }) + it('should reset the password successfully', async () => { const response = await request(app).post('/users/password/reset').send({ token: resetToken, diff --git a/application/common/testing/seed.ts b/application/common/testing/seed.ts index 9133257c..4ea0fe02 100644 --- a/application/common/testing/seed.ts +++ b/application/common/testing/seed.ts @@ -400,8 +400,8 @@ export async function seedTests(prisma: PrismaClient) { data: { id: TestUsers.PASSWORD_RESET_USER.id, email: TestUsers.PASSWORD_RESET_USER.email, - firstName: 'Test', - lastName: 'User', + firstName: 'Reset', + lastName: 'Recipient', password: hashPassword(TestUsers.PASSWORD_RESET_USER.password), role: Role.Participant, }, From 29880f5751f0b5556dab04c7cd9dfce6d9612b77 Mon Sep 17 00:00:00 2001 From: Tanuj Date: Fri, 31 Jul 2026 16:15:28 +1000 Subject: [PATCH 07/13] Update client forms to pass PII context to password check --- application/admin-client/src/pages/setup/index.tsx | 5 ++++- application/user-client/src/pages/Register.tsx | 8 +++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/application/admin-client/src/pages/setup/index.tsx b/application/admin-client/src/pages/setup/index.tsx index cf7da645..f9219704 100644 --- a/application/admin-client/src/pages/setup/index.tsx +++ b/application/admin-client/src/pages/setup/index.tsx @@ -10,6 +10,7 @@ export const SetupPage = () => { register, handleSubmit, setError, + getValues, formState: { errors }, } = useForm() @@ -87,7 +88,9 @@ export const SetupPage = () => { {...register('password', { required: true, validate: (val) => { - const { isValid, fields } = checkPasswordStrength(val) + const { isValid, fields } = checkPasswordStrength(val, { + email: getValues('email'), + }) if (!isValid) { return `Invalid password. ${Object.values(fields).map((f) => ' ' + f.message)}` } diff --git a/application/user-client/src/pages/Register.tsx b/application/user-client/src/pages/Register.tsx index 787529e6..217c352a 100644 --- a/application/user-client/src/pages/Register.tsx +++ b/application/user-client/src/pages/Register.tsx @@ -62,6 +62,7 @@ export default function Register() { setError, watch, reset, + getValues, formState: { errors }, } = useForm({ defaultValues: { preferredContact: '' as ContactMethod, state: '' as StateTerritory }, @@ -240,7 +241,12 @@ export default function Register() { {...register('password', { required: 'This field is required', validate: (val) => { - const { isValid, fields } = checkPasswordStrength(val) + const { isValid, fields } = checkPasswordStrength(val, { + firstName: getValues('firstName'), + lastName: getValues('lastName'), + email: getValues('email'), + dob: getValues('dob'), + }) if (!isValid) { return `Invalid password. ${Object.values(fields).map((f) => ' ' + f.message)}` } From c54abfbcde6a4a0b8929f415c5f9a87d5af550a2 Mon Sep 17 00:00:00 2001 From: Tanuj Date: Fri, 31 Jul 2026 16:56:05 +1000 Subject: [PATCH 08/13] Add cypress tests for PII rejection and use robust integration test password --- .../cypress/e2e/passwordReset.cy.js | 7 +++++++ .../admin-client/cypress/e2e/setup.cy.js | 11 +++++++++++ .../tests/integration/ResetPassword.test.ts | 2 +- .../cypress/e2e/registration.cy.js | 19 +++++++++++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/application/admin-client/cypress/e2e/passwordReset.cy.js b/application/admin-client/cypress/e2e/passwordReset.cy.js index d5568b98..95f28192 100644 --- a/application/admin-client/cypress/e2e/passwordReset.cy.js +++ b/application/admin-client/cypress/e2e/passwordReset.cy.js @@ -41,6 +41,13 @@ describe('Password Reset', () => { cy.contains('must not contain easily guessable').should('exist') }) + it('rejects a new password containing the users personal info', () => { + cy.visit('/update-password?token=valid-reset-token') + cy.get('input[id="password"]').type('ResetCorduroy2026') + cy.get('input[id="confirmPassword"]').type('ResetCorduroy2026{enter}') + cy.contains('personal information').should('exist') + }) + it('opens password reset page and enters non-matching passwords', () => { cy.visit('/update-password?token=valid-reset-token') cy.get('input[id="password"]').type('pass') diff --git a/application/admin-client/cypress/e2e/setup.cy.js b/application/admin-client/cypress/e2e/setup.cy.js index 5927133b..169b3469 100644 --- a/application/admin-client/cypress/e2e/setup.cy.js +++ b/application/admin-client/cypress/e2e/setup.cy.js @@ -13,4 +13,15 @@ describe('Setup', () => { cy.visit('/surveys') cy.contains('Current Draft').should('exist') }) + + it('Rejects password containing the email local part', () => { + cy.task('wipe') + cy.visit('/') + cy.url().should('contain', '/setup') + cy.get('[data-cy="setup-email"]').type('tanuj@example.com') + cy.get('[data-cy="setup-password"]').type('MyTanujCorduroy26') + cy.get('[data-cy="setup-submit"]').click() + cy.contains('Invalid password').should('exist') + cy.contains('contains personal information').should('exist') + }) }) diff --git a/application/backend/tests/integration/ResetPassword.test.ts b/application/backend/tests/integration/ResetPassword.test.ts index 16b5efe7..94934c8b 100644 --- a/application/backend/tests/integration/ResetPassword.test.ts +++ b/application/backend/tests/integration/ResetPassword.test.ts @@ -20,7 +20,7 @@ describe('User Password Reset', () => { // Note: using different test data pw to ensure consistency with pw requirements const newPassword = TestUsers.PARTICIPANT_COMPLETED.password const invalidPassword = 'password' - const piiPassword = 'MyResetLoginPass26' // contains "reset" - the seeded user's firstName + const piiPassword = 'ResetConstellation26' // contains "reset" - the seeded user's firstName beforeAll(async () => { api.run() diff --git a/application/user-client/cypress/e2e/registration.cy.js b/application/user-client/cypress/e2e/registration.cy.js index 579d03a0..a9a77aa3 100644 --- a/application/user-client/cypress/e2e/registration.cy.js +++ b/application/user-client/cypress/e2e/registration.cy.js @@ -93,6 +93,25 @@ describe('registration', () => { cy.contains('must not contain easily guessable words').should('exist') }) + it('Input password containing personal info and get correct error message', () => { + cy.task('getInviteIdtask', { + email: TestInvites.INVITE_PENDING.email, + studyId: TestStudies.TEST_STUDY.id, + }) + .as('inviteId') + .then((inviteId) => { + cy.visit(`/register/${inviteId}`) + }) + cy.wait(500) // wait for form to be fully loaded + cy.get('[data-cy="reg-first"]').clear() + cy.get('[data-cy="reg-first"]').type('Tanuj') + cy.get('[data-cy="reg-password"]').type('TanujCorduroy2026') + cy.get('[data-cy="reg-confirm-password"]').type('TanujCorduroy2026') + cy.get('[data-cy="reg-button"]').click() + cy.contains('Invalid password').should('exist') + cy.contains('contains personal information').should('exist') + }) + it('Attempt to register existing email (i.e. no invite) and get correct error message', () => { cy.visit('/register/not-a-real-inviteId') fillValid() From 9ebbda07b3eafdb54b7e5c7878b870175fcbb2b5 Mon Sep 17 00:00:00 2001 From: Tanuj Date: Fri, 31 Jul 2026 17:05:52 +1000 Subject: [PATCH 09/13] Raise PII token length threshold from 3 to 4 characters --- application/common/src/PasswordStrength.test.ts | 4 ++-- application/common/src/PasswordStrength.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/application/common/src/PasswordStrength.test.ts b/application/common/src/PasswordStrength.test.ts index db2f78c4..a89fdf6f 100644 --- a/application/common/src/PasswordStrength.test.ts +++ b/application/common/src/PasswordStrength.test.ts @@ -111,9 +111,9 @@ describe('checkPasswordStrength', () => { expect(fields).toEqual({}) }) - it('ignores tokens shorter than 3 characters', () => { + it('ignores tokens shorter than 4 characters', () => { const { fields } = checkPasswordStrength(strongPassword, { - firstName: 'Li', + firstName: 'Bob', }) expect(fields.PersonalInfo).toBeUndefined() }) diff --git a/application/common/src/PasswordStrength.ts b/application/common/src/PasswordStrength.ts index 57a5e956..e6de4f29 100644 --- a/application/common/src/PasswordStrength.ts +++ b/application/common/src/PasswordStrength.ts @@ -28,7 +28,7 @@ function extractPiiTokens(context: PasswordContext): string[] { value .split(/\s+/) .map((token) => token.trim().toLowerCase()) - .filter((token) => token.length >= 3) + .filter((token) => token.length >= 4) .forEach((token) => tokens.add(token)) } addTokens(context.firstName) From 7db4f08d9cdd09301229c22c392efc9b2f3286c1 Mon Sep 17 00:00:00 2001 From: Tanuj Date: Fri, 31 Jul 2026 17:32:01 +1000 Subject: [PATCH 10/13] Verify PII rejection via response status code in admin reset cypress test --- application/admin-client/cypress/e2e/passwordReset.cy.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/application/admin-client/cypress/e2e/passwordReset.cy.js b/application/admin-client/cypress/e2e/passwordReset.cy.js index 95f28192..27d37870 100644 --- a/application/admin-client/cypress/e2e/passwordReset.cy.js +++ b/application/admin-client/cypress/e2e/passwordReset.cy.js @@ -42,10 +42,11 @@ describe('Password Reset', () => { }) it('rejects a new password containing the users personal info', () => { + cy.intercept('POST', '/users/password/reset').as('resetRequest') cy.visit('/update-password?token=valid-reset-token') cy.get('input[id="password"]').type('ResetCorduroy2026') cy.get('input[id="confirmPassword"]').type('ResetCorduroy2026{enter}') - cy.contains('personal information').should('exist') + cy.wait('@resetRequest').its('response.statusCode').should('eq', 422) }) it('opens password reset page and enters non-matching passwords', () => { From b13656f81afe0c782a2f21036587ecf3fc636529 Mon Sep 17 00:00:00 2001 From: Tanuj Date: Fri, 31 Jul 2026 17:39:01 +1000 Subject: [PATCH 11/13] Add STRONG_TEST_PASSWORD constant and use it in Auth integration tests --- application/backend/tests/integration/Auth.test.ts | 6 +++--- application/common/testing/constants.ts | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/application/backend/tests/integration/Auth.test.ts b/application/backend/tests/integration/Auth.test.ts index 73e7ab2a..03627a07 100644 --- a/application/backend/tests/integration/Auth.test.ts +++ b/application/backend/tests/integration/Auth.test.ts @@ -7,7 +7,7 @@ import { RegisterResponse, } from 'common/types/api/auth' import { resetDB } from 'common/testing/TestHelpers' -import { TestUsers, TestStudies, TestInvites } from 'common/testing/constants' +import { STRONG_TEST_PASSWORD, TestUsers, TestStudies, TestInvites } from 'common/testing/constants' import { ContactMethod, ParticipantType, @@ -33,7 +33,7 @@ describe('Auth', () => { firstName: 'Test', lastName: 'Admin', email: 'test@admin.com', - password: TestUsers.ORG_ADMIN.password, // Note: using test data so it fits password policy + password: STRONG_TEST_PASSWORD, role: Role.OrganisationAdmin, } @@ -41,7 +41,7 @@ describe('Auth', () => { firstName: 'Test', lastName: 'Participant', email: 'test@participant.com', - password: TestUsers.ORG_ADMIN.password, // Note: using test data so it fits password policy + password: STRONG_TEST_PASSWORD, role: Role.Participant, } diff --git a/application/common/testing/constants.ts b/application/common/testing/constants.ts index 738607da..f08ff9e0 100644 --- a/application/common/testing/constants.ts +++ b/application/common/testing/constants.ts @@ -1,5 +1,9 @@ // Constants shared for all tests +// Strong test password with no common test-user PII substrings (e.g. 'test', 'admin'). +// Use in tests that register/login with placeholder names. +export const STRONG_TEST_PASSWORD = 'Constellation2026' + // Test user credentials (for seed data and tests) export const TestUsers = { OPERATOR_ADMIN: { From c95866d032658cf45a305419d973b9db50cf07c1 Mon Sep 17 00:00:00 2001 From: Tanuj Date: Fri, 31 Jul 2026 17:53:18 +1000 Subject: [PATCH 12/13] Use STRONG_TEST_PASSWORD for participant register test to avoid PII collision --- application/backend/tests/integration/Auth.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/backend/tests/integration/Auth.test.ts b/application/backend/tests/integration/Auth.test.ts index 03627a07..28db0d0d 100644 --- a/application/backend/tests/integration/Auth.test.ts +++ b/application/backend/tests/integration/Auth.test.ts @@ -123,7 +123,7 @@ describe('Auth', () => { firstName: 'John', lastName: 'Doe', email: TestInvites.INVITE_2_PENDING.email, - password: 'johnDoesP@ssword123', + password: STRONG_TEST_PASSWORD, mobile: '+61477777777', addressLine: '123 Some Street', suburb: 'Sydney', From 48651852ae2b2a9b944ff353aadc941631742a0c Mon Sep 17 00:00:00 2001 From: Tanuj Date: Fri, 31 Jul 2026 17:55:37 +1000 Subject: [PATCH 13/13] Target the underlying input in reg-first clear() call --- application/user-client/cypress/e2e/registration.cy.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/user-client/cypress/e2e/registration.cy.js b/application/user-client/cypress/e2e/registration.cy.js index a9a77aa3..49c43d3f 100644 --- a/application/user-client/cypress/e2e/registration.cy.js +++ b/application/user-client/cypress/e2e/registration.cy.js @@ -103,7 +103,7 @@ describe('registration', () => { cy.visit(`/register/${inviteId}`) }) cy.wait(500) // wait for form to be fully loaded - cy.get('[data-cy="reg-first"]').clear() + cy.get('[data-cy="reg-first"] input').clear() cy.get('[data-cy="reg-first"]').type('Tanuj') cy.get('[data-cy="reg-password"]').type('TanujCorduroy2026') cy.get('[data-cy="reg-confirm-password"]').type('TanujCorduroy2026')