Compare commits

...

3 Commits

Author SHA1 Message Date
Marcel
ce41e96a45 test(audit): add 401 unauthenticated tests for createUser, adminUpdateUser, deleteUser
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m1s
CI / OCR Service Tests (pull_request) Successful in 34s
CI / Backend Unit Tests (pull_request) Failing after 3m0s
CI / Unit & Component Tests (push) Failing after 2m59s
CI / OCR Service Tests (push) Successful in 40s
CI / Backend Unit Tests (push) Failing after 2m55s
Regression guards verifying that Spring Security returns 401 (not 200) when
no credentials are provided, complementing the existing 403 permission tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 17:44:03 +02:00
Marcel
a6c8af0971 test(audit): replace null-actorId bootstrap calls with createUserForBootstrap(), increase timeouts to 10s
Removes the wait+clear cycles that existed only to drain the audit events
emitted by createUserOrUpdate(null, ...). Timeouts increased 5 → 10 s to
reduce CI flakiness under load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 17:41:56 +02:00
Marcel
6d9910b805 refactor(audit): extract createUserForBootstrap() to make null actorId contract explicit
createUserOrUpdate(UUID actorId, ...) is always called from the controller with
a real authenticated actor. createUserForBootstrap() handles seeding/test setup
without emitting an audit event, making the two contracts unambiguous.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 17:39:09 +02:00
4 changed files with 81 additions and 17 deletions

View File

@@ -80,6 +80,34 @@ public class UserService {
return saved; return saved;
} }
@Transactional
public AppUser createUserForBootstrap(CreateUserRequest request) {
log.info("Bootstrap user creation (no audit): {}", request.getEmail());
Set<UserGroup> groups = new HashSet<>();
if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) {
groups.addAll(groupRepository.findAllById(request.getGroupIds()));
}
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
if (existingUser.isPresent()) {
AppUser updated = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
return userRepository.save(updated);
}
AppUser user = AppUser.builder()
.email(request.getEmail())
.password(passwordEncoder.encode(request.getInitialPassword()))
.groups(groups)
.firstName(request.getFirstName())
.lastName(request.getLastName())
.birthDate(request.getBirthDate())
.contact(request.getContact())
.enabled(true)
.build();
return userRepository.save(user);
}
@Transactional @Transactional
public AppUser createUser(String email, String rawPassword, String firstName, String lastName, Set<UUID> groupIds) { public AppUser createUser(String email, String rawPassword, String firstName, String lastName, Set<UUID> groupIds) {
userRepository.findByEmail(email).ifPresent(existing -> { userRepository.findByEmail(email).ifPresent(existing -> {

View File

@@ -45,18 +45,13 @@ class UserManagementAuditIntegrationTest {
@Test @Test
void createAndDeleteUser_producesOrderedAuditEntries() { void createAndDeleteUser_producesOrderedAuditEntries() {
// Create the actor (admin) user directly — bypasses audit logging so no FK issue // Bootstrap actor with no audit event — clean slate guaranteed by @BeforeEach
CreateUserRequest adminReq = new CreateUserRequest(); CreateUserRequest adminReq = new CreateUserRequest();
adminReq.setEmail("admin@test.example.com"); adminReq.setEmail("admin@test.example.com");
adminReq.setInitialPassword("admin-secret"); adminReq.setInitialPassword("admin-secret");
AppUser actor = transactionTemplate.execute(status -> AppUser actor = transactionTemplate.execute(status -> userService.createUserForBootstrap(adminReq));
userService.createUserOrUpdate(null, adminReq));
UUID actorId = actor.getId(); UUID actorId = actor.getId();
// The admin creation is logged with null actorId — clear to start with a clean slate
await().atMost(5, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED));
transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; });
// Create the target user — should emit USER_CREATED // Create the target user — should emit USER_CREATED
CreateUserRequest req = new CreateUserRequest(); CreateUserRequest req = new CreateUserRequest();
req.setEmail("audit-test@example.com"); req.setEmail("audit-test@example.com");
@@ -65,7 +60,7 @@ class UserManagementAuditIntegrationTest {
userService.createUserOrUpdate(actorId, req); userService.createUserOrUpdate(actorId, req);
return null; return null;
}); });
await().atMost(5, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED)); await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED));
// Delete the target user — should emit USER_DELETED // Delete the target user — should emit USER_DELETED
AppUser created = userRepository.findByEmail("audit-test@example.com").orElseThrow(); AppUser created = userRepository.findByEmail("audit-test@example.com").orElseThrow();
@@ -73,7 +68,7 @@ class UserManagementAuditIntegrationTest {
userService.deleteUser(actorId, created.getId()); userService.deleteUser(actorId, created.getId());
return null; return null;
}); });
await().atMost(5, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_DELETED)); await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_DELETED));
List<AuditLog> events = auditLogQueryService.findRecentUserManagementEvents(10); List<AuditLog> events = auditLogQueryService.findRecentUserManagementEvents(10);
assertThat(events).hasSize(2); assertThat(events).hasSize(2);
@@ -83,27 +78,24 @@ class UserManagementAuditIntegrationTest {
@Test @Test
void updateUserGroups_producesGroupMembershipChangedEvent() { void updateUserGroups_producesGroupMembershipChangedEvent() {
// Create groups before creating users — required for group assignment on creation
GroupDTO groupADto = new GroupDTO(); groupADto.setName("Viewers"); groupADto.setPermissions(Set.of("READ_ALL")); GroupDTO groupADto = new GroupDTO(); groupADto.setName("Viewers"); groupADto.setPermissions(Set.of("READ_ALL"));
GroupDTO groupBDto = new GroupDTO(); groupBDto.setName("Editors"); groupBDto.setPermissions(Set.of("WRITE_ALL")); GroupDTO groupBDto = new GroupDTO(); groupBDto.setName("Editors"); groupBDto.setPermissions(Set.of("WRITE_ALL"));
UserGroup gA = transactionTemplate.execute(status -> userService.createGroup(groupADto)); UserGroup gA = transactionTemplate.execute(status -> userService.createGroup(groupADto));
UserGroup gB = transactionTemplate.execute(status -> userService.createGroup(groupBDto)); UserGroup gB = transactionTemplate.execute(status -> userService.createGroup(groupBDto));
// Create actor (bootstrap — null actorId, event not relevant) // Bootstrap actor with no audit event — clean slate guaranteed by @BeforeEach
CreateUserRequest actorReq = new CreateUserRequest(); CreateUserRequest actorReq = new CreateUserRequest();
actorReq.setEmail("actor-group-test@test.example.com"); actorReq.setEmail("actor-group-test@test.example.com");
actorReq.setInitialPassword("secret"); actorReq.setInitialPassword("secret");
AppUser actor = transactionTemplate.execute(status -> userService.createUserOrUpdate(null, actorReq)); AppUser actor = transactionTemplate.execute(status -> userService.createUserForBootstrap(actorReq));
await().atMost(5, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED));
transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; });
// Create target user pre-assigned to gA // Create target user pre-assigned to gA — emits USER_CREATED
CreateUserRequest targetReq = new CreateUserRequest(); CreateUserRequest targetReq = new CreateUserRequest();
targetReq.setEmail("target-group-test@test.example.com"); targetReq.setEmail("target-group-test@test.example.com");
targetReq.setInitialPassword("secret"); targetReq.setInitialPassword("secret");
targetReq.setGroupIds(List.of(gA.getId())); targetReq.setGroupIds(List.of(gA.getId()));
transactionTemplate.execute(status -> userService.createUserOrUpdate(actor.getId(), targetReq)); transactionTemplate.execute(status -> userService.createUserOrUpdate(actor.getId(), targetReq));
await().atMost(5, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED)); await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED));
transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; }); transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; });
AppUser target = userRepository.findByEmail("target-group-test@test.example.com").orElseThrow(); AppUser target = userRepository.findByEmail("target-group-test@test.example.com").orElseThrow();
@@ -113,7 +105,7 @@ class UserManagementAuditIntegrationTest {
dto.setGroupIds(List.of(gB.getId())); dto.setGroupIds(List.of(gB.getId()));
transactionTemplate.execute(status -> userService.adminUpdateUser(actor.getId(), target.getId(), dto)); transactionTemplate.execute(status -> userService.adminUpdateUser(actor.getId(), target.getId(), dto));
await().atMost(5, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.GROUP_MEMBERSHIP_CHANGED)); await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.GROUP_MEMBERSHIP_CHANGED));
List<AuditLog> events = auditLogQueryService.findRecentUserManagementEvents(10); List<AuditLog> events = auditLogQueryService.findRecentUserManagementEvents(10);
assertThat(events).hasSize(1); assertThat(events).hasSize(1);

View File

@@ -133,4 +133,28 @@ class UserControllerTest {
mockMvc.perform(delete("/api/users/" + UUID.randomUUID())) mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
// ─── unauthenticated access ───────────────────────────────────────────────
@Test
void createUser_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
.andExpect(status().isUnauthorized());
}
@Test
void adminUpdateUser_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isUnauthorized());
}
@Test
void deleteUser_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
.andExpect(status().isUnauthorized());
}
} }

View File

@@ -837,6 +837,26 @@ class UserServiceTest {
verify(auditService, never()).logAfterCommit(any(), any(), any(), any()); verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
} }
// ─── createUserForBootstrap ───────────────────────────────────────────────
@Test
void createUserForBootstrap_createsUserWithoutAuditEvent() {
CreateUserRequest req = new CreateUserRequest();
req.setEmail("bootstrap@example.com");
req.setInitialPassword("secret");
req.setGroupIds(List.of());
when(userRepository.findByEmail("bootstrap@example.com")).thenReturn(Optional.empty());
when(passwordEncoder.encode("secret")).thenReturn("encoded");
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("bootstrap@example.com").build();
when(userRepository.save(any())).thenReturn(saved);
AppUser result = userService.createUserForBootstrap(req);
assertThat(result).isEqualTo(saved);
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
}
// ─── createGroup ────────────────────────────────────────────────────────── // ─── createGroup ──────────────────────────────────────────────────────────
@Test @Test