diff --git a/backend/src/main/java/org/raddatz/familienarchiv/user/UserDataInitializer.java b/backend/src/main/java/org/raddatz/familienarchiv/user/UserDataInitializer.java index 590ee8ae..8bec121d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/user/UserDataInitializer.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/user/UserDataInitializer.java @@ -67,11 +67,16 @@ public class UserDataInitializer { } log.info("Kein Admin-User '{}' gefunden. Erstelle Default-Admin...", adminEmail); - UserGroup adminGroup = UserGroup.builder() - .name("Administrators") - .permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ANNOTATE_ALL", "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION")) - .build(); - groupRepository.save(adminGroup); + // Reuse the Administrators group if it already exists (e.g. a + // previous boot seeded the group but failed before creating + // the admin user, or the operator deleted just the user row + // to retry the seed with a new email). Blind-INSERTing would + // violate user_groups_name_key and abort the context. See #518. + UserGroup adminGroup = groupRepository.findByName("Administrators") + .orElseGet(() -> groupRepository.save(UserGroup.builder() + .name("Administrators") + .permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ANNOTATE_ALL", "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION")) + .build())); AppUser admin = AppUser.builder() .email(adminEmail) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/user/AdminSeedFailClosedTest.java b/backend/src/test/java/org/raddatz/familienarchiv/user/AdminSeedFailClosedTest.java index 16471496..a0f1666e 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/user/AdminSeedFailClosedTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/user/AdminSeedFailClosedTest.java @@ -14,7 +14,9 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -78,6 +80,8 @@ class AdminSeedFailClosedTest { @Test void allows_seed_when_both_values_are_set_and_profile_is_not_dev() throws Exception { when(userRepository.findByEmail(anyString())).thenReturn(Optional.empty()); + when(groupRepository.findByName("Administrators")).thenReturn(Optional.empty()); + when(groupRepository.save(any(UserGroup.class))).thenAnswer(inv -> inv.getArgument(0)); when(environment.matchesProfiles("dev", "test", "e2e")).thenReturn(false); when(passwordEncoder.encode(anyString())).thenReturn("$2a$10$stub"); ReflectionTestUtils.setField(initializer, "adminEmail", "admin@archiv.raddatz.cloud"); @@ -86,12 +90,14 @@ class AdminSeedFailClosedTest { CommandLineRunner runner = initializer.initAdminUser(passwordEncoder); runner.run(); - verify(userRepository).save(org.mockito.ArgumentMatchers.any(AppUser.class)); + verify(userRepository).save(any(AppUser.class)); } @Test void allows_seed_with_defaults_when_profile_is_dev() throws Exception { when(userRepository.findByEmail(anyString())).thenReturn(Optional.empty()); + when(groupRepository.findByName("Administrators")).thenReturn(Optional.empty()); + when(groupRepository.save(any(UserGroup.class))).thenAnswer(inv -> inv.getArgument(0)); when(environment.matchesProfiles("dev", "test", "e2e")).thenReturn(true); when(passwordEncoder.encode(anyString())).thenReturn("$2a$10$stub"); ReflectionTestUtils.setField(initializer, "adminEmail", UserDataInitializer.DEFAULT_ADMIN_EMAIL); @@ -100,7 +106,7 @@ class AdminSeedFailClosedTest { CommandLineRunner runner = initializer.initAdminUser(passwordEncoder); runner.run(); - verify(userRepository).save(org.mockito.ArgumentMatchers.any(AppUser.class)); + verify(userRepository).save(any(AppUser.class)); } @Test @@ -120,4 +126,49 @@ class AdminSeedFailClosedTest { // Importantly, no IllegalStateException — re-deploys must not panic over // historical default-seeded data they cannot retroactively fix. } + + @Test + void reuses_existing_Administrators_group_when_seeding_a_new_admin() throws Exception { + // Setup: admin user does not exist, but the Administrators group does + // (e.g. previous boot seeded the group then failed; operator deleted + // the bad user row to retry with a corrected APP_ADMIN_USERNAME). The + // re-seed must reuse the group, not blind-INSERT a duplicate. See #518. + UserGroup existingGroup = UserGroup.builder() + .name("Administrators") + .build(); + when(userRepository.findByEmail(anyString())).thenReturn(Optional.empty()); + when(groupRepository.findByName("Administrators")).thenReturn(Optional.of(existingGroup)); + when(environment.matchesProfiles("dev", "test", "e2e")).thenReturn(false); + when(passwordEncoder.encode(anyString())).thenReturn("$2a$10$stub"); + ReflectionTestUtils.setField(initializer, "adminEmail", "admin@archiv.raddatz.cloud"); + ReflectionTestUtils.setField(initializer, "adminPassword", "a-real-strong-password"); + + CommandLineRunner runner = initializer.initAdminUser(passwordEncoder); + runner.run(); + + // Group must not be re-inserted — that would violate user_groups_name_key. + verify(groupRepository, never()).save(any(UserGroup.class)); + // But the admin user IS created, with the existing group attached. + org.mockito.ArgumentCaptor captor = org.mockito.ArgumentCaptor.forClass(AppUser.class); + verify(userRepository).save(captor.capture()); + assertThat(captor.getValue().getGroups()).containsExactly(existingGroup); + } + + @Test + void creates_Administrators_group_when_seeding_admin_on_a_fresh_database() throws Exception { + when(userRepository.findByEmail(anyString())).thenReturn(Optional.empty()); + when(groupRepository.findByName("Administrators")).thenReturn(Optional.empty()); + when(groupRepository.save(any(UserGroup.class))).thenAnswer(inv -> inv.getArgument(0)); + when(environment.matchesProfiles("dev", "test", "e2e")).thenReturn(false); + when(passwordEncoder.encode(anyString())).thenReturn("$2a$10$stub"); + ReflectionTestUtils.setField(initializer, "adminEmail", "admin@archiv.raddatz.cloud"); + ReflectionTestUtils.setField(initializer, "adminPassword", "a-real-strong-password"); + + CommandLineRunner runner = initializer.initAdminUser(passwordEncoder); + runner.run(); + + // Group should be inserted exactly once. + verify(groupRepository).save(any(UserGroup.class)); + verify(userRepository).save(any(AppUser.class)); + } }