-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathDSUserDetailsServiceIntegrationTest.java
More file actions
303 lines (259 loc) · 12.4 KB
/
Copy pathDSUserDetailsServiceIntegrationTest.java
File metadata and controls
303 lines (259 loc) · 12.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
package com.digitalsanctuary.spring.user.integration;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.annotation.Transactional;
import com.digitalsanctuary.spring.user.persistence.model.Privilege;
import com.digitalsanctuary.spring.user.persistence.model.Role;
import com.digitalsanctuary.spring.user.persistence.model.User;
import com.digitalsanctuary.spring.user.persistence.repository.PasswordHistoryRepository;
import com.digitalsanctuary.spring.user.persistence.repository.RoleRepository;
import com.digitalsanctuary.spring.user.persistence.repository.UserRepository;
import com.digitalsanctuary.spring.user.service.DSUserDetails;
import com.digitalsanctuary.spring.user.service.DSUserDetailsService;
import com.digitalsanctuary.spring.user.test.annotations.IntegrationTest;
import com.digitalsanctuary.spring.user.test.builders.RoleTestDataBuilder;
import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder;
/**
* Integration tests for DSUserDetailsService.
*
* This test class verifies the full integration behavior including:
* - Database persistence
* - Transaction management
* - LoginHelperService integration
* - Authority loading
* - Account unlock functionality
*/
@IntegrationTest
// A positive lockout duration makes auto-unlock deterministic: a user locked longer ago than this window
// is eligible to be unlocked on load, while one just locked stays locked. (The framework reads
// user.security.accountLockoutDuration; >= 0 enables time-based auto-unlock.)
@TestPropertySource(properties = "user.security.accountLockoutDuration=30")
@DisplayName("DSUserDetailsService Integration Tests")
class DSUserDetailsServiceIntegrationTest {
@Autowired
private DSUserDetailsService dsUserDetailsService;
@Autowired
private UserRepository userRepository;
@Autowired
private RoleRepository roleRepository;
@Autowired
private PasswordHistoryRepository passwordHistoryRepository;
private Role userRole;
private Role adminRole;
@BeforeEach
@Transactional
void setUp() {
// Clean up
passwordHistoryRepository.deleteAll();
userRepository.deleteAll();
roleRepository.deleteAll();
// Force the DELETEs to hit the DB before the role INSERTs below. The framework seeds the configured
// roles (ROLE_USER/ROLE_ADMIN/...) at startup, so without an explicit flush Hibernate's action-queue
// ordering would run the new-role INSERTs before these DELETEs and trip the unique ROLE(NAME) index.
roleRepository.flush();
// Create privileges
Privilege userPrivilege = new Privilege();
userPrivilege.setName("ROLE_USER");
Privilege adminPrivilege = new Privilege();
adminPrivilege.setName("ROLE_ADMIN");
// Create roles with privileges
userRole = RoleTestDataBuilder.aRole()
.withName("ROLE_USER")
.withId(null)
.build();
userRole.getPrivileges().add(userPrivilege);
userRole = roleRepository.save(userRole);
adminRole = RoleTestDataBuilder.aRole()
.withName("ROLE_ADMIN")
.withId(null)
.build();
adminRole.getPrivileges().add(adminPrivilege);
adminRole = roleRepository.save(adminRole);
}
@Test
@Transactional
@DisplayName("Should load user and update lastActivityDate")
void loadUserByUsername_updatesLastActivityDate() {
// Given
Date originalDate = Date.from(
LocalDateTime.now().minusDays(1).atZone(ZoneId.systemDefault()).toInstant());
User user = UserTestDataBuilder.aVerifiedUser()
.withEmail("activity@test.com")
.withLastActivityDate(originalDate)
.withId(null)
.build();
user.setRoles(new ArrayList<>(Arrays.asList(userRole)));
user = userRepository.save(user);
Date beforeLoad = new Date();
// When
DSUserDetails result = dsUserDetailsService.loadUserByUsername("activity@test.com");
// Then
User updatedUser = userRepository.findByEmail("activity@test.com");
assertThat(updatedUser.getLastActivityDate()).isAfter(originalDate);
assertThat(updatedUser.getLastActivityDate()).isAfterOrEqualTo(beforeLoad);
assertThat(result.getUser().getLastActivityDate()).isAfterOrEqualTo(beforeLoad);
}
@Test
@Transactional
@DisplayName("Should auto-unlock eligible locked user")
void loadUserByUsername_autoUnlocksEligibleUser() {
// Given - Create a locked user with old lock date (should be unlocked)
Date oldLockDate = Date.from(
LocalDateTime.now().minusHours(2).atZone(ZoneId.systemDefault()).toInstant());
User lockedUser = UserTestDataBuilder.aLockedUser()
.withEmail("autounlock@test.com")
.withLockedDate(oldLockDate)
.withFailedLoginAttempts(5)
.verified() // enabled, so that once auto-unlocked the account is fully usable
.withId(null)
.build();
lockedUser.setRoles(new ArrayList<>(Arrays.asList(userRole)));
lockedUser = userRepository.save(lockedUser);
// Verify user is initially locked
assertThat(lockedUser.isLocked()).isTrue();
// When - the lock is older than accountLockoutDuration (30m), so loading auto-unlocks the account
DSUserDetails result = dsUserDetailsService.loadUserByUsername("autounlock@test.com");
// Then - the user is unlocked on load and authentication succeeds
assertThat(result).isNotNull();
assertThat(result.getUsername()).isEqualTo("autounlock@test.com");
assertThat(result.isAccountNonLocked()).isTrue();
assertThat(userRepository.findByEmail("autounlock@test.com").isLocked()).isFalse();
}
@Test
@Transactional
@DisplayName("Should load user with multiple roles and correct authorities")
void loadUserByUsername_withMultipleRoles_loadsAllAuthorities() {
// Given
User multiRoleUser = UserTestDataBuilder.aVerifiedUser()
.withEmail("multirole@test.com")
.withId(null)
.build();
multiRoleUser.setRoles(new ArrayList<>(Arrays.asList(userRole, adminRole)));
userRepository.save(multiRoleUser);
// When
DSUserDetails result = dsUserDetailsService.loadUserByUsername("multirole@test.com");
// Then
assertThat(result).isNotNull();
assertThat(result.getAuthorities())
.extracting(GrantedAuthority::getAuthority)
.containsExactlyInAnyOrder("ROLE_USER", "ROLE_ADMIN");
}
@Test
@DisplayName("Should throw exception for non-existent user")
void loadUserByUsername_nonExistentUser_throwsException() {
// When & Then
assertThatThrownBy(() -> dsUserDetailsService.loadUserByUsername("nonexistent@test.com"))
.isInstanceOf(UsernameNotFoundException.class)
.hasMessageContaining("No user found with email/username: nonexistent@test.com");
}
@Test
@Transactional
@DisplayName("Should handle concurrent access correctly")
void loadUserByUsername_concurrentAccess_handlesCorrectly() throws InterruptedException {
// Given
User user = UserTestDataBuilder.aVerifiedUser()
.withEmail("concurrent@test.com")
.withId(null)
.build();
user.setRoles(new ArrayList<>(Arrays.asList(userRole)));
userRepository.save(user);
// When - Simulate concurrent access
Thread thread1 = new Thread(() -> {
DSUserDetails result = dsUserDetailsService.loadUserByUsername("concurrent@test.com");
assertThat(result).isNotNull();
});
Thread thread2 = new Thread(() -> {
DSUserDetails result = dsUserDetailsService.loadUserByUsername("concurrent@test.com");
assertThat(result).isNotNull();
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
// Then - Both threads should complete successfully
User finalUser = userRepository.findByEmail("concurrent@test.com");
assertThat(finalUser).isNotNull();
assertThat(finalUser.getLastActivityDate()).isNotNull();
}
@Test
@Transactional
@DisplayName("Should correctly map all UserDetails properties")
void loadUserByUsername_mapsAllUserDetailsProperties() {
// Given
User user = UserTestDataBuilder.aUser()
.withEmail("mapping@test.com")
.withPassword("password123") // Will be encoded by builder
.withFirstName("Jane")
.withLastName("Smith")
.verified()
.withId(null)
.build();
user.setRoles(new ArrayList<>(Arrays.asList(userRole)));
userRepository.save(user);
// When
DSUserDetails result = dsUserDetailsService.loadUserByUsername("mapping@test.com");
// Then
assertThat(result.getUsername()).isEqualTo("mapping@test.com");
assertThat(result.getPassword()).isNotNull();
assertThat(result.getPassword()).startsWith("$2a$"); // BCrypt encoded
assertThat(result.getName()).isEqualTo("Jane Smith");
assertThat(result.isEnabled()).isTrue();
assertThat(result.isAccountNonExpired()).isTrue();
assertThat(result.isAccountNonLocked()).isTrue();
assertThat(result.isCredentialsNonExpired()).isTrue();
assertThat(result.getUser()).isNotNull();
assertThat(result.getUser().getEmail()).isEqualTo("mapping@test.com");
}
@Test
@Transactional
@DisplayName("Should reject loading a disabled user")
void loadUserByUsername_disabledUser_throwsDisabledException() {
// Given
User disabledUser = UserTestDataBuilder.anUnverifiedUser()
.withEmail("disabled@test.com")
.withId(null)
.build();
disabledUser.setRoles(new ArrayList<>(Arrays.asList(userRole)));
userRepository.save(disabledUser);
// When & Then - as of 4.4.0 the login helper enforces account status on every auth path, so loading
// a disabled (unverified) account throws rather than returning a principal with isEnabled()==false.
assertThatThrownBy(() -> dsUserDetailsService.loadUserByUsername("disabled@test.com"))
.isInstanceOf(DisabledException.class);
}
@Test
@Transactional
@DisplayName("Should reject loading a currently locked user")
void loadUserByUsername_currentlyLockedUser_throwsLockedException() {
// Given - a user locked just now: well inside the accountLockoutDuration window, so it must NOT
// auto-unlock and stays locked.
Date recentLockDate = new Date();
User lockedUser = UserTestDataBuilder.aLockedUser()
.withEmail("locked@test.com")
.withLockedDate(recentLockDate)
.verified() // enabled but locked, to prove lock (not disabled) is what rejects the load
.withId(null)
.build();
lockedUser.setRoles(new ArrayList<>(Arrays.asList(userRole)));
lockedUser = userRepository.save(lockedUser);
// Verify user is initially locked
assertThat(lockedUser.isLocked()).isTrue();
// When & Then - loading a still-locked account throws LockedException (checked before disabled status)
assertThatThrownBy(() -> dsUserDetailsService.loadUserByUsername("locked@test.com"))
.isInstanceOf(LockedException.class);
assertThat(userRepository.findByEmail("locked@test.com").isLocked()).isTrue();
}
}