3131
3232import static org .junit .jupiter .api .Assertions .assertEquals ;
3333import static org .junit .jupiter .api .Assertions .assertThrows ;
34+ import static org .mockito .ArgumentMatchers .any ;
35+ import static org .mockito .ArgumentMatchers .anyString ;
36+ import static org .mockito .ArgumentMatchers .eq ;
37+ import static org .mockito .Mockito .never ;
38+ import static org .mockito .Mockito .verify ;
3439import static org .mockito .Mockito .when ;
3540
3641import java .io .IOException ;
4045import org .hisp .dhis .common .auth .UserRegistrationParams ;
4146import org .hisp .dhis .configuration .ConfigurationService ;
4247import org .hisp .dhis .feedback .BadRequestException ;
48+ import org .hisp .dhis .feedback .ForbiddenException ;
49+ import org .hisp .dhis .security .PasswordManager ;
4350import org .hisp .dhis .security .spring2fa .TwoFactorAuthenticationProvider ;
4451import org .hisp .dhis .setting .SystemSettings ;
4552import org .hisp .dhis .setting .SystemSettingsService ;
@@ -59,6 +66,7 @@ class UserAccountServiceTest {
5966 @ Mock private TwoFactorAuthenticationProvider twoFactorAuthProvider ;
6067 @ Mock private SystemSettingsService settingsService ;
6168 @ Mock private PasswordValidationService passwordValidationService ;
69+ @ Mock private PasswordManager passwordManager ;
6270
6371 @ BeforeEach
6472 public void init () {
@@ -68,7 +76,8 @@ public void init() {
6876 configService ,
6977 twoFactorAuthProvider ,
7078 settingsService ,
71- passwordValidationService );
79+ passwordValidationService ,
80+ passwordManager );
7281 }
7382
7483 @ Test
@@ -95,6 +104,116 @@ void failedRecaptchaResponseUserRegTest() throws IOException {
95104 "Recaptcha validation failed: [invalid challenge received]" , exception .getMessage ());
96105 }
97106
107+ @ Test
108+ @ DisplayName ("updateExpiredPassword with unknown username is generic and burns one verification" )
109+ void updateExpiredPasswordUnknownUserTest () {
110+ when (userService .getUserByUsername ("ghost" )).thenReturn (null );
111+
112+ BadRequestException exception =
113+ assertThrows (
114+ BadRequestException .class ,
115+ () -> userAccountService .updateExpiredPassword ("ghost" , "Old_pw1!" , "New_pw1!" ));
116+
117+ assertEquals ("Invalid username or password" , exception .getMessage ());
118+ // The timing-equalization verification must run, so unknown and known usernames respond in
119+ // similar time; no recovery attempt is registered for a nonexistent account.
120+ verify (passwordManager ).matches (eq ("Old_pw1!" ), anyString ());
121+ verify (userService , never ()).registerRecoveryAttempt (anyString ());
122+ }
123+
124+ @ Test
125+ @ DisplayName ("updateExpiredPassword rejects a recovery-locked account before checking passwords" )
126+ void updateExpiredPasswordLockedTest () {
127+ User user = expiredPasswordUser ();
128+ when (userService .getUserByUsername ("mia" )).thenReturn (user );
129+ when (userService .isRecoveryLocked ("mia" )).thenReturn (true );
130+
131+ assertThrows (
132+ ForbiddenException .class ,
133+ () -> userAccountService .updateExpiredPassword ("mia" , "Old_pw1!" , "New_pw1!" ));
134+
135+ verify (userService , never ()).registerRecoveryAttempt (anyString ());
136+ verify (passwordManager , never ()).matches (anyString (), anyString ());
137+ }
138+
139+ @ Test
140+ @ DisplayName ("updateExpiredPassword with wrong old password is generic and registers an attempt" )
141+ void updateExpiredPasswordWrongOldPasswordTest () {
142+ User user = expiredPasswordUser ();
143+ when (userService .getUserByUsername ("mia" )).thenReturn (user );
144+ when (userService .isRecoveryLocked ("mia" )).thenReturn (false );
145+ when (passwordManager .matches ("Wrong_pw1!" , "encoded-old" )).thenReturn (false );
146+
147+ BadRequestException exception =
148+ assertThrows (
149+ BadRequestException .class ,
150+ () -> userAccountService .updateExpiredPassword ("mia" , "Wrong_pw1!" , "New_pw1!" ));
151+
152+ assertEquals ("Invalid username or password" , exception .getMessage ());
153+ verify (userService ).registerRecoveryAttempt ("mia" );
154+ // Guard order: expiry state must not be consulted before the password is verified, so
155+ // "Account is not expired" is only observable by a caller who knows the password.
156+ verify (userService , never ()).userNonExpired (any (User .class ));
157+ }
158+
159+ @ Test
160+ @ DisplayName ("updateExpiredPassword rejects a non-expired account" )
161+ void updateExpiredPasswordNonExpiredTest () {
162+ User user = expiredPasswordUser ();
163+ when (userService .getUserByUsername ("mia" )).thenReturn (user );
164+ when (userService .isRecoveryLocked ("mia" )).thenReturn (false );
165+ when (passwordManager .matches ("Old_pw1!" , "encoded-old" )).thenReturn (true );
166+ when (userService .userNonExpired (user )).thenReturn (true );
167+
168+ BadRequestException exception =
169+ assertThrows (
170+ BadRequestException .class ,
171+ () -> userAccountService .updateExpiredPassword ("mia" , "Old_pw1!" , "New_pw1!" ));
172+
173+ assertEquals ("Account is not expired" , exception .getMessage ());
174+ }
175+
176+ @ Test
177+ @ DisplayName ("updateExpiredPassword rejects a new password equal to the old one" )
178+ void updateExpiredPasswordSameAsOldTest () {
179+ User user = expiredPasswordUser ();
180+ when (userService .getUserByUsername ("mia" )).thenReturn (user );
181+ when (userService .isRecoveryLocked ("mia" )).thenReturn (false );
182+ when (passwordManager .matches ("Old_pw1!" , "encoded-old" )).thenReturn (true );
183+ when (userService .userNonExpired (user )).thenReturn (false );
184+
185+ BadRequestException exception =
186+ assertThrows (
187+ BadRequestException .class ,
188+ () -> userAccountService .updateExpiredPassword ("mia" , "Old_pw1!" , "Old_pw1!" ));
189+
190+ assertEquals ("New password must be different from the old password" , exception .getMessage ());
191+ }
192+
193+ @ Test
194+ @ DisplayName ("updateExpiredPassword sets and persists a valid new password" )
195+ void updateExpiredPasswordOkTest () throws BadRequestException , ForbiddenException {
196+ User user = expiredPasswordUser ();
197+ when (userService .getUserByUsername ("mia" )).thenReturn (user );
198+ when (userService .isRecoveryLocked ("mia" )).thenReturn (false );
199+ when (passwordManager .matches ("Old_pw1!" , "encoded-old" )).thenReturn (true );
200+ when (userService .userNonExpired (user )).thenReturn (false );
201+ when (passwordValidationService .validate (any (CredentialsInfo .class )))
202+ .thenReturn (PasswordValidationResult .VALID );
203+
204+ userAccountService .updateExpiredPassword ("mia" , "Old_pw1!" , "New_pw1!" );
205+
206+ verify (userService ).encodeAndSetPassword (user , "New_pw1!" );
207+ verify (userService ).updateUser (eq (user ), any (SystemUser .class ));
208+ }
209+
210+ private User expiredPasswordUser () {
211+ User user = new User ();
212+ user .setUsername ("mia" );
213+ user .setPassword ("encoded-old" );
214+ return user ;
215+ }
216+
98217 @ Test
99218 @ DisplayName ("Failed recaptcha response during user invite throws an exception" )
100219 void failedRecaptchaResponseUserInviteTest () throws IOException {
0 commit comments