Compare commits

..

27 Commits

Author SHA1 Message Date
Marcel
1f7509413d refactor(persons): extract inputCls/labelCls and PersonFormData type
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m26s
CI / OCR Service Tests (push) Successful in 1m4s
CI / Backend Unit Tests (push) Failing after 3m18s
CI / Unit & Component Tests (pull_request) Failing after 3m26s
CI / OCR Service Tests (pull_request) Successful in 42s
CI / Backend Unit Tests (pull_request) Failing after 3m2s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 12:34:49 +02:00
Marcel
da1270c419 refactor(persons): rename page.server.test.ts to normalizePersonType.test.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 12:30:07 +02:00
Marcel
409a8fc8f2 test(persons): replace fragile CSS class tests with aria-checked behavior tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 12:27:29 +02:00
Marcel
c154dcd47e a11y(persons): add aria-label to PersonTypeSelector radiogroup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 12:25:03 +02:00
Marcel
f5eb14a76d test(persons): assert error code in createPerson_returns400_whenPersonTypeIsSkip
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m16s
CI / OCR Service Tests (push) Successful in 49s
CI / Backend Unit Tests (push) Failing after 3m23s
CI / Unit & Component Tests (pull_request) Failing after 3m25s
CI / OCR Service Tests (pull_request) Successful in 57s
CI / Backend Unit Tests (pull_request) Failing after 2m56s
Adds jsonPath("$.code").value("INVALID_PERSON_TYPE") to verify the full
error response shape, not just the HTTP status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 10:32:13 +02:00
Marcel
00af97653d refactor(persons): remove what-comment from PersonCard title block
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 10:29:46 +02:00
Marcel
f9a982db43 fix(persons): localize validation error messages via Paraglide i18n
validatePersonFields now returns a PersonValidationKey instead of a
hardcoded German string. resolveValidationMessage() translates the key
through Paraglide so English and Spanish locale users no longer see
German error text. Adds validation_last_name_required and
validation_first_name_required to all three message files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 10:28:28 +02:00
Marcel
8e1733abbf refactor(persons): centralise PersonType, PERSON_TYPES and normalizePersonType in person-validation
Removes four independent PersonType type declarations and the duplicated
TYPES/PERSON_TYPES arrays. normalizePersonType moves from the edit route
module into the shared lib so page.server.test.ts no longer imports from a
route. Both server actions now use normalizePersonType for personType
extraction instead of an inline type cast.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 10:22:52 +02:00
Marcel
842ab28f59 fix(persons): keyboard navigation now updates PersonTypeSelector reactive state
radioGroupNav now accepts an onChange callback; PersonTypeSelector passes
select() as the callback so ArrowLeft/Right navigation updates the hidden
input value. aria-live region starts empty and announces only on user
interaction (fixes initial page-load announcement).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 10:17:47 +02:00
Marcel
0c47c22185 test(persons): extract validatePersonFields and cover validation branches
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m28s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Failing after 2m57s
CI / Unit & Component Tests (pull_request) Failing after 3m12s
CI / OCR Service Tests (pull_request) Successful in 50s
CI / Backend Unit Tests (pull_request) Failing after 3m3s
- New src/lib/person-validation.ts exports validatePersonFields (pure function)
- 8 unit tests covering: valid PERSON, lastName missing/undefined,
  firstName missing/undefined for PERSON, non-PERSON types without firstName
- Both edit and new-person server actions now call the shared helper instead
  of inline if-chains, making the logic testable and non-duplicated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 09:57:04 +02:00
Marcel
d97cd06f70 refactor(persons): export normalizePersonType from edit server module
Tests now import from production code instead of a local copy, giving real
regression protection if the inline logic is changed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 09:57:03 +02:00
Marcel
b41405cb4b fix(persons): trim title server-side and add SKIP controller test
- PersonController trims title (both create + update) matching the existing firstName/lastName trim pattern
- PersonControllerTest: verifies title is trimmed before service call (ArgumentCaptor)
- PersonControllerTest: verifies createPerson returns 400 when personType is SKIP

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 09:57:03 +02:00
Marcel
c9e9300216 fix(persons): use semantic color tokens in PersonTypeSelector for dark mode
Replaces hardcoded brand-navy/brand-sand/white classes with semantic
tokens (bg-primary/text-primary-fg, bg-surface/text-ink, border-line,
ring-focus-ring) so the segmented control adapts correctly in dark mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 09:57:03 +02:00
Marcel
92b082ec62 feat(persons): show title in small-caps above display name in PersonCard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 09:57:03 +02:00
Marcel
8770ca874b feat(persons): add type selector + title + conditional fields to new-person form
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 09:57:03 +02:00
Marcel
e54240ea1b feat(persons): extract personType + title in edit action; relax firstName for non-PERSON
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 09:57:03 +02:00
Marcel
437144174c feat(persons): add type selector + title + conditional fields to edit form
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 09:57:03 +02:00
Marcel
fe830ad64b feat(persons): add PersonTypeSelector segmented control component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:37:13 +02:00
Marcel
8f75552503 feat(i18n): add form_label_person_type, form_label_name, a11y_type_changed keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:31:11 +02:00
Marcel
e7573bbeda feat(persons): normalize SKIP→UNKNOWN in edit-route load function
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:28:10 +02:00
Marcel
bf31380141 feat(persons): add radioGroupNav action for keyboard navigation in type selector
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:17:06 +02:00
Marcel
58b3dabea2 feat(persons): relax firstName requirement for non-PERSON types in controller
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:07:55 +02:00
Marcel
60a278ad8e feat(persons): updatePerson rejects SKIP with INVALID_PERSON_TYPE
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:02:39 +02:00
Marcel
39f722fec0 feat(persons): updatePerson persists personType from DTO
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 20:59:58 +02:00
Marcel
fef021bf51 feat(persons): createPerson(DTO) rejects SKIP with INVALID_PERSON_TYPE
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 20:57:15 +02:00
Marcel
6c117611b8 feat(persons): add personType to PersonUpdateDTO and wire into createPerson
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 20:54:37 +02:00
Marcel
aac8250af0 feat(persons): add INVALID_PERSON_TYPE error code with i18n translations
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 20:51:00 +02:00
20 changed files with 40 additions and 598 deletions

View File

@@ -26,16 +26,7 @@ public enum AuditKind {
COMMENT_ADDED, COMMENT_ADDED,
/** Payload: {@code {"commentId": "uuid", "mentionedUserId": "uuid"}} */ /** Payload: {@code {"commentId": "uuid", "mentionedUserId": "uuid"}} */
MENTION_CREATED, MENTION_CREATED;
/** Payload: {@code {"userId": "uuid", "email": "addr"}} */
USER_CREATED,
/** Payload: {@code {"userId": "uuid", "email": "addr"}} */
USER_DELETED,
/** Payload: {@code {"userId": "uuid", "email": "addr", "addedGroups": ["Admin"], "removedGroups": []}} */
GROUP_MEMBERSHIP_CHANGED;
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of( public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED, TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,

View File

@@ -1,7 +1,5 @@
package org.raddatz.familienarchiv.audit; package org.raddatz.familienarchiv.audit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
@@ -199,6 +197,4 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
ORDER BY ranked.document_id, ranked.rn ORDER BY ranked.document_id, ranked.rn
""", nativeQuery = true) """, nativeQuery = true)
List<ContributorRow> findRecentContributorsForDocuments(@Param("documentIds") List<UUID> documentIds); List<ContributorRow> findRecentContributorsForDocuments(@Param("documentIds") List<UUID> documentIds);
Page<AuditLog> findByKindIn(Collection<AuditKind> kinds, Pageable pageable);
} }

View File

@@ -1,17 +1,11 @@
package org.raddatz.familienarchiv.audit; package org.raddatz.familienarchiv.audit;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.*; import java.util.*;
import static org.raddatz.familienarchiv.audit.AuditKind.GROUP_MEMBERSHIP_CHANGED;
import static org.raddatz.familienarchiv.audit.AuditKind.USER_CREATED;
import static org.raddatz.familienarchiv.audit.AuditKind.USER_DELETED;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class AuditLogQueryService { public class AuditLogQueryService {
@@ -57,11 +51,6 @@ public class AuditLogQueryService {
return toContributorMap(queryRepository.findRecentContributorsForDocuments(documentIds)); return toContributorMap(queryRepository.findRecentContributorsForDocuments(documentIds));
} }
public List<AuditLog> findRecentUserManagementEvents(int limit) {
PageRequest page = PageRequest.of(0, limit, Sort.by("happenedAt").descending());
return queryRepository.findByKindIn(Set.of(USER_CREATED, USER_DELETED, GROUP_MEMBERSHIP_CHANGED), page).getContent();
}
private Map<UUID, List<ActivityActorDTO>> toContributorMap(List<ContributorRow> rows) { private Map<UUID, List<ActivityActorDTO>> toContributorMap(List<ContributorRow> rows) {
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>(); Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
for (ContributorRow row : rows) { for (ContributorRow row : rows) {

View File

@@ -5,5 +5,4 @@ import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID; import java.util.UUID;
public interface AuditLogRepository extends JpaRepository<AuditLog, UUID> { public interface AuditLogRepository extends JpaRepository<AuditLog, UUID> {
boolean existsByKind(AuditKind kind);
} }

View File

@@ -78,31 +78,24 @@ public class UserController {
@PostMapping("/users") @PostMapping("/users")
@RequirePermission(Permission.ADMIN_USER) @RequirePermission(Permission.ADMIN_USER)
public ResponseEntity<AppUser> createUser(Authentication authentication, public ResponseEntity<AppUser> createUser(@Valid @RequestBody CreateUserRequest request) {
@Valid @RequestBody CreateUserRequest request) { return ResponseEntity.ok(userService.createUserOrUpdate(request));
return ResponseEntity.ok(userService.createUserOrUpdate(actorId(authentication), request));
} }
@PutMapping("/users/{id}") @PutMapping("/users/{id}")
@RequirePermission(Permission.ADMIN_USER) @RequirePermission(Permission.ADMIN_USER)
public ResponseEntity<AppUser> adminUpdateUser(Authentication authentication, public ResponseEntity<AppUser> adminUpdateUser(@PathVariable UUID id,
@PathVariable UUID id,
@RequestBody AdminUpdateUserRequest dto) { @RequestBody AdminUpdateUserRequest dto) {
AppUser updated = userService.adminUpdateUser(actorId(authentication), id, dto); AppUser updated = userService.adminUpdateUser(id, dto);
updated.setPassword(null); updated.setPassword(null);
return ResponseEntity.ok(updated); return ResponseEntity.ok(updated);
} }
@DeleteMapping("/users/{id}") @DeleteMapping("/users/{id}")
@RequirePermission(Permission.ADMIN_USER) @RequirePermission(Permission.ADMIN_USER)
public ResponseEntity<Void> deleteUser(Authentication authentication, public ResponseEntity<Void> deleteUser(@PathVariable UUID id) {
@PathVariable UUID id) { userService.deleteUser(id);
userService.deleteUser(actorId(authentication), id);
return ResponseEntity.ok().build(); return ResponseEntity.ok().build();
} }
private UUID actorId(Authentication auth) {
return userService.findByEmail(auth.getName()).getId();
}
} }

View File

@@ -87,7 +87,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
SELECT d.id FROM documents d SELECT d.id FROM documents d
CROSS JOIN LATERAL ( CROSS JOIN LATERAL (
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> '' SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
THEN to_tsquery('simple', regexp_replace( THEN to_tsquery('german', regexp_replace(
websearch_to_tsquery('german', :query)::text, websearch_to_tsquery('german', :query)::text,
'''([^'']+)''', '''([^'']+)''',
'''\\1'':*', '''\\1'':*',
@@ -149,7 +149,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
FROM documents d FROM documents d
CROSS JOIN LATERAL ( CROSS JOIN LATERAL (
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> '' SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
THEN to_tsquery('simple', regexp_replace( THEN to_tsquery('german', regexp_replace(
websearch_to_tsquery('german', :query)::text, websearch_to_tsquery('german', :query)::text,
'''([^'']+)''', '''([^'']+)''',
'''\\1'':*', '''\\1'':*',

View File

@@ -3,8 +3,6 @@ package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest; import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
import org.raddatz.familienarchiv.dto.ChangePasswordDTO; import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
import org.raddatz.familienarchiv.dto.CreateUserRequest; import org.raddatz.familienarchiv.dto.CreateUserRequest;
@@ -23,13 +21,10 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.Collection; import java.util.Collection;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import static java.util.stream.Collectors.toSet;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
@@ -38,10 +33,9 @@ public class UserService {
private final AppUserRepository userRepository; private final AppUserRepository userRepository;
private final UserGroupRepository groupRepository; private final UserGroupRepository groupRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final AuditService auditService;
@Transactional @Transactional
public AppUser createUserOrUpdate(UUID actorId, CreateUserRequest request) { public AppUser createUserOrUpdate(CreateUserRequest request) {
log.info("Creating or updating user: {}", request.getEmail()); log.info("Creating or updating user: {}", request.getEmail());
Set<UserGroup> groups = new HashSet<>(); Set<UserGroup> groups = new HashSet<>();
@@ -51,12 +45,10 @@ public class UserService {
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail()); Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
AppUser user; AppUser user;
boolean isNew;
if (existingUser.isPresent()) { if (existingUser.isPresent()) {
log.info("User exists, updating: {}", request.getEmail()); log.info("User exists, updating: {}", request.getEmail());
user = existingUser.get().updateFromRequest(request, passwordEncoder, groups); user = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
isNew = false;
} else { } else {
log.info("Creating new user: {}", request.getEmail()); log.info("Creating new user: {}", request.getEmail());
user = AppUser.builder() user = AppUser.builder()
@@ -69,42 +61,8 @@ public class UserService {
.contact(request.getContact()) .contact(request.getContact())
.enabled(true) .enabled(true)
.build(); .build();
isNew = true;
} }
AppUser saved = userRepository.save(user);
if (isNew) {
auditService.logAfterCommit(AuditKind.USER_CREATED, actorId, null,
Map.of("userId", saved.getId().toString(), "email", saved.getEmail()));
}
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); return userRepository.save(user);
} }
@@ -136,13 +94,10 @@ public class UserService {
} }
@Transactional @Transactional
public void deleteUser(UUID actorId, UUID userId) { public void deleteUser(UUID userId) {
AppUser user = userRepository.findById(userId) AppUser user = userRepository.findById(userId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + userId)); .orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + userId));
String email = user.getEmail();
userRepository.delete(user); userRepository.delete(user);
auditService.logAfterCommit(AuditKind.USER_DELETED, actorId, null,
Map.of("userId", userId.toString(), "email", email));
} }
public AppUser getById(UUID id) { public AppUser getById(UUID id) {
@@ -186,7 +141,7 @@ public class UserService {
} }
@Transactional @Transactional
public AppUser adminUpdateUser(UUID actorId, UUID id, AdminUpdateUserRequest dto) { public AppUser adminUpdateUser(UUID id, AdminUpdateUserRequest dto) {
AppUser user = getById(id); AppUser user = getById(id);
if (dto.getEmail() != null && !dto.getEmail().isBlank()) { if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
@@ -211,27 +166,13 @@ public class UserService {
} }
if (dto.getGroupIds() != null) { if (dto.getGroupIds() != null) {
Set<UserGroup> before = new HashSet<>(user.getGroups()); Set<UserGroup> groups = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
Set<UserGroup> after = new HashSet<>(groupRepository.findAllById(dto.getGroupIds())); user.setGroups(groups);
user.setGroups(after);
groupChangePayload(before, after, id, user.getEmail())
.ifPresent(payload -> auditService.logAfterCommit(AuditKind.GROUP_MEMBERSHIP_CHANGED, actorId, null, payload));
} }
return userRepository.save(user); return userRepository.save(user);
} }
private Optional<Map<String, Object>> groupChangePayload(
Set<UserGroup> before, Set<UserGroup> after, UUID userId, String email) {
Set<UUID> beforeIds = before.stream().map(UserGroup::getId).collect(toSet());
Set<UUID> afterIds = after.stream().map(UserGroup::getId).collect(toSet());
if (beforeIds.equals(afterIds)) return Optional.empty();
List<String> added = after.stream().filter(g -> !beforeIds.contains(g.getId())).map(UserGroup::getName).toList();
List<String> removed = before.stream().filter(g -> !afterIds.contains(g.getId())).map(UserGroup::getName).toList();
return Optional.of(Map.of("userId", userId.toString(), "email", email,
"addedGroups", added, "removedGroups", removed));
}
@Transactional @Transactional
public void changePassword(UUID userId, ChangePasswordDTO dto) { public void changePassword(UUID userId, ChangePasswordDTO dto) {
AppUser user = getById(userId); AppUser user = getById(userId);

View File

@@ -6,19 +6,12 @@ import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.model.AppUser;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection; import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -54,21 +47,4 @@ class AuditLogQueryServiceTest {
verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10), verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10),
eq(AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList())); eq(AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList()));
} }
@Test
void findRecentUserManagementEvents_delegatesToRepositoryWithAllThreeKinds() {
AuditLog entry = AuditLog.builder().id(UUID.randomUUID()).kind(AuditKind.USER_CREATED).build();
when(queryRepository.findByKindIn(anyCollection(), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(entry)));
List<AuditLog> result = auditLogQueryService.findRecentUserManagementEvents(5);
assertThat(result).containsExactly(entry);
verify(queryRepository).findByKindIn(
argThat((Collection<AuditKind> kinds) ->
kinds.contains(AuditKind.USER_CREATED) &&
kinds.contains(AuditKind.USER_DELETED) &&
kinds.contains(AuditKind.GROUP_MEMBERSHIP_CHANGED)),
any(Pageable.class));
}
} }

View File

@@ -1,122 +0,0 @@
package org.raddatz.familienarchiv.audit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
import org.raddatz.familienarchiv.dto.CreateUserRequest;
import org.raddatz.familienarchiv.dto.GroupDTO;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.UserGroup;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.support.TransactionTemplate;
import software.amazon.awssdk.services.s3.S3Client;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class UserManagementAuditIntegrationTest {
@MockitoBean S3Client s3Client;
@Autowired UserService userService;
@Autowired AppUserRepository userRepository;
@Autowired AuditLogRepository auditLogRepository;
@Autowired AuditLogQueryService auditLogQueryService;
@Autowired TransactionTemplate transactionTemplate;
@BeforeEach
void clearAuditLog() {
transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; });
}
@Test
void createAndDeleteUser_producesOrderedAuditEntries() {
// Bootstrap actor with no audit event — clean slate guaranteed by @BeforeEach
CreateUserRequest adminReq = new CreateUserRequest();
adminReq.setEmail("admin@test.example.com");
adminReq.setInitialPassword("admin-secret");
AppUser actor = transactionTemplate.execute(status -> userService.createUserForBootstrap(adminReq));
UUID actorId = actor.getId();
// Create the target user — should emit USER_CREATED
CreateUserRequest req = new CreateUserRequest();
req.setEmail("audit-test@example.com");
req.setInitialPassword("secret");
transactionTemplate.execute(status -> {
userService.createUserOrUpdate(actorId, req);
return null;
});
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED));
// Delete the target user — should emit USER_DELETED
AppUser created = userRepository.findByEmail("audit-test@example.com").orElseThrow();
transactionTemplate.execute(status -> {
userService.deleteUser(actorId, created.getId());
return null;
});
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_DELETED));
List<AuditLog> events = auditLogQueryService.findRecentUserManagementEvents(10);
assertThat(events).hasSize(2);
assertThat(events.get(0).getKind()).isEqualTo(AuditKind.USER_DELETED);
assertThat(events.get(1).getKind()).isEqualTo(AuditKind.USER_CREATED);
}
@Test
void updateUserGroups_producesGroupMembershipChangedEvent() {
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"));
UserGroup gA = transactionTemplate.execute(status -> userService.createGroup(groupADto));
UserGroup gB = transactionTemplate.execute(status -> userService.createGroup(groupBDto));
// Bootstrap actor with no audit event — clean slate guaranteed by @BeforeEach
CreateUserRequest actorReq = new CreateUserRequest();
actorReq.setEmail("actor-group-test@test.example.com");
actorReq.setInitialPassword("secret");
AppUser actor = transactionTemplate.execute(status -> userService.createUserForBootstrap(actorReq));
// Create target user pre-assigned to gA — emits USER_CREATED
CreateUserRequest targetReq = new CreateUserRequest();
targetReq.setEmail("target-group-test@test.example.com");
targetReq.setInitialPassword("secret");
targetReq.setGroupIds(List.of(gA.getId()));
transactionTemplate.execute(status -> userService.createUserOrUpdate(actor.getId(), targetReq));
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED));
transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; });
AppUser target = userRepository.findByEmail("target-group-test@test.example.com").orElseThrow();
// Change groups: Viewers → Editors
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setGroupIds(List.of(gB.getId()));
transactionTemplate.execute(status -> userService.adminUpdateUser(actor.getId(), target.getId(), dto));
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.GROUP_MEMBERSHIP_CHANGED));
List<AuditLog> events = auditLogQueryService.findRecentUserManagementEvents(10);
assertThat(events).hasSize(1);
AuditLog event = events.get(0);
assertThat(event.getKind()).isEqualTo(AuditKind.GROUP_MEMBERSHIP_CHANGED);
assertThat(event.getPayload()).containsEntry("email", "target-group-test@test.example.com");
@SuppressWarnings("unchecked")
List<String> added = (List<String>) event.getPayload().get("addedGroups");
@SuppressWarnings("unchecked")
List<String> removed = (List<String>) event.getPayload().get("removedGroups");
assertThat(added).containsExactlyInAnyOrder("Editors");
assertThat(removed).containsExactlyInAnyOrder("Viewers");
}
}

View File

@@ -18,10 +18,8 @@ import java.util.UUID;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -106,55 +104,4 @@ class UserControllerTest {
.content("{\"email\":\"\",\"initialPassword\":\"secret123\"}")) .content("{\"email\":\"\",\"initialPassword\":\"secret123\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
} }
// ─── permission enforcement ───────────────────────────────────────────────
@Test
@WithMockUser(username = "reader@example.com")
void createUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "reader@example.com")
void adminUpdateUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "reader@example.com")
void deleteUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
.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

@@ -179,22 +179,6 @@ class DocumentFtsTest {
assertThat(ids).isEmpty(); assertThat(ids).isEmpty();
} }
@Test
void should_find_document_whose_transcription_contains_word_that_stems_to_german_stop_word() {
// "Wille" stems to "will" via the German Snowball stemmer.
// "will" is also a German stop word, so to_tsquery('german','will:*') drops it.
// The prefix-transform step must use to_tsquery('simple',...) to avoid this.
Document doc = documentRepository.saveAndFlush(document("Foto"));
UUID annotationId = annotation(doc.getId());
blockRepository.saveAndFlush(block(doc.getId(), annotationId, "Der Wille des Volkes", 0));
em.flush();
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Wille");
assertThat(ids).contains(doc.getId());
}
@Test @Test
void should_not_throw_when_query_contains_invalid_tsquery_syntax() { void should_not_throw_when_query_contains_invalid_tsquery_syntax() {
documentRepository.saveAndFlush(document("Brief")); documentRepository.saveAndFlush(document("Brief"));

View File

@@ -2,12 +2,9 @@ package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest; import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
import org.raddatz.familienarchiv.dto.ChangePasswordDTO; import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
import org.raddatz.familienarchiv.dto.CreateUserRequest; import org.raddatz.familienarchiv.dto.CreateUserRequest;
@@ -37,7 +34,6 @@ class UserServiceTest {
@Mock AppUserRepository userRepository; @Mock AppUserRepository userRepository;
@Mock UserGroupRepository groupRepository; @Mock UserGroupRepository groupRepository;
@Mock PasswordEncoder passwordEncoder; @Mock PasswordEncoder passwordEncoder;
@Mock AuditService auditService;
@InjectMocks UserService userService; @InjectMocks UserService userService;
// ─── findByEmail ────────────────────────────────────────────────────────── // ─── findByEmail ──────────────────────────────────────────────────────────
@@ -65,7 +61,7 @@ class UserServiceTest {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
when(userRepository.findById(id)).thenReturn(Optional.empty()); when(userRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> userService.deleteUser(UUID.randomUUID(), id)) assertThatThrownBy(() -> userService.deleteUser(id))
.isInstanceOf(DomainException.class); .isInstanceOf(DomainException.class);
} }
@@ -75,7 +71,7 @@ class UserServiceTest {
AppUser user = AppUser.builder().id(id).email("gast@example.com").build(); AppUser user = AppUser.builder().id(id).email("gast@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user)); when(userRepository.findById(id)).thenReturn(Optional.of(user));
userService.deleteUser(UUID.randomUUID(), id); userService.deleteUser(id);
verify(userRepository).delete(user); verify(userRepository).delete(user);
} }
@@ -94,7 +90,7 @@ class UserServiceTest {
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build(); AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
when(userRepository.save(any())).thenReturn(saved); when(userRepository.save(any())).thenReturn(saved);
AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req); AppUser result = userService.createUserOrUpdate(req);
assertThat(result).isEqualTo(saved); assertThat(result).isEqualTo(saved);
verify(userRepository).save(any()); verify(userRepository).save(any());
@@ -112,7 +108,7 @@ class UserServiceTest {
when(passwordEncoder.encode(any())).thenReturn("encoded"); when(passwordEncoder.encode(any())).thenReturn("encoded");
when(userRepository.save(any())).thenReturn(existing); when(userRepository.save(any())).thenReturn(existing);
userService.createUserOrUpdate(UUID.randomUUID(), req); userService.createUserOrUpdate(req);
verify(userRepository, times(1)).save(existing); verify(userRepository, times(1)).save(existing);
} }
@@ -233,7 +229,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setFirstName("Ada"); dto.setLastName("Lovelace"); dto.setFirstName("Ada"); dto.setLastName("Lovelace");
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto); AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getFirstName()).isEqualTo("Ada"); assertThat(result.getFirstName()).isEqualTo("Ada");
assertThat(result.getLastName()).isEqualTo("Lovelace"); assertThat(result.getLastName()).isEqualTo("Lovelace");
@@ -250,7 +246,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setFirstName("Ada"); dto.setFirstName("Ada");
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto); AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getGroups()).containsExactly(adminGroup); assertThat(result.getGroups()).containsExactly(adminGroup);
} }
@@ -268,7 +264,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setGroupIds(List.of(newGroup.getId())); dto.setGroupIds(List.of(newGroup.getId()));
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto); AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getGroups()).containsExactly(newGroup); assertThat(result.getGroups()).containsExactly(newGroup);
} }
@@ -285,7 +281,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setGroupIds(List.of()); dto.setGroupIds(List.of());
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto); AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getGroups()).isEmpty(); assertThat(result.getGroups()).isEmpty();
} }
@@ -317,7 +313,7 @@ class UserServiceTest {
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build(); AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
when(userRepository.save(any())).thenReturn(saved); when(userRepository.save(any())).thenReturn(saved);
AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req); AppUser result = userService.createUserOrUpdate(req);
assertThat(result).isEqualTo(saved); assertThat(result).isEqualTo(saved);
verify(groupRepository).findAllById(List.of(group.getId())); verify(groupRepository).findAllById(List.of(group.getId()));
@@ -382,7 +378,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setNewPassword("newSecret"); dto.setNewPassword("newSecret");
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto); AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getPassword()).isEqualTo("newHashed"); assertThat(result.getPassword()).isEqualTo("newHashed");
} }
@@ -397,7 +393,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setNewPassword(" "); dto.setNewPassword(" ");
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto); AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getPassword()).isEqualTo("original"); assertThat(result.getPassword()).isEqualTo("original");
verify(passwordEncoder, never()).encode(any()); verify(passwordEncoder, never()).encode(any());
@@ -412,7 +408,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setEmail(" "); dto.setEmail(" ");
assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto)) assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
.isInstanceOf(DomainException.class) .isInstanceOf(DomainException.class)
.hasMessageContaining("blank"); .hasMessageContaining("blank");
} }
@@ -429,7 +425,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setEmail("taken@example.com"); dto.setEmail("taken@example.com");
assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto)) assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
.isInstanceOf(DomainException.class) .isInstanceOf(DomainException.class)
.hasMessageContaining("E-Mail"); .hasMessageContaining("E-Mail");
} }
@@ -501,7 +497,7 @@ class UserServiceTest {
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build(); AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
when(userRepository.save(any())).thenReturn(saved); when(userRepository.save(any())).thenReturn(saved);
userService.createUserOrUpdate(UUID.randomUUID(), req); userService.createUserOrUpdate(req);
verify(groupRepository, never()).findAllById(any()); verify(groupRepository, never()).findAllById(any());
} }
@@ -565,7 +561,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setContact(null); dto.setContact(null);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto); AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getContact()).isNull(); assertThat(result.getContact()).isNull();
} }
@@ -580,7 +576,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setContact(" "); dto.setContact(" ");
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto); AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getContact()).isNull(); assertThat(result.getContact()).isNull();
} }
@@ -595,7 +591,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setContact(" phone: 555 "); dto.setContact(" phone: 555 ");
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto); AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getContact()).isEqualTo("phone: 555"); assertThat(result.getContact()).isEqualTo("phone: 555");
} }
@@ -610,7 +606,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setEmail(null); dto.setEmail(null);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto); AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getEmail()).isEqualTo("keep@example.com"); assertThat(result.getEmail()).isEqualTo("keep@example.com");
} }
@@ -626,7 +622,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setEmail("me@example.com"); dto.setEmail("me@example.com");
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto); AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getEmail()).isEqualTo("me@example.com"); assertThat(result.getEmail()).isEqualTo("me@example.com");
} }
@@ -644,7 +640,7 @@ class UserServiceTest {
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("ng@example.com").build(); AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("ng@example.com").build();
when(userRepository.save(any())).thenReturn(saved); when(userRepository.save(any())).thenReturn(saved);
userService.createUserOrUpdate(UUID.randomUUID(), req); userService.createUserOrUpdate(req);
verify(groupRepository, never()).findAllById(any()); verify(groupRepository, never()).findAllById(any());
} }
@@ -703,160 +699,6 @@ class UserServiceTest {
assertThat(result).containsExactly(g); assertThat(result).containsExactly(g);
} }
// ─── audit: GROUP_MEMBERSHIP_CHANGED ─────────────────────────────────────
@Test
void adminUpdateUser_logsGroupMembershipChanged_whenGroupSetChanges() {
UUID actorId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
UserGroup oldGroup = UserGroup.builder().id(UUID.randomUUID()).name("Viewers").permissions(Set.of("READ_ALL")).build();
UserGroup newGroup = UserGroup.builder().id(UUID.randomUUID()).name("Editors").permissions(Set.of("WRITE_ALL")).build();
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(oldGroup)).build();
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
when(groupRepository.findAllById(List.of(newGroup.getId()))).thenReturn(List.of(newGroup));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setGroupIds(List.of(newGroup.getId()));
userService.adminUpdateUser(actorId, userId, dto);
@SuppressWarnings("unchecked")
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
verify(auditService).logAfterCommit(
org.mockito.ArgumentMatchers.eq(AuditKind.GROUP_MEMBERSHIP_CHANGED),
org.mockito.ArgumentMatchers.eq(actorId),
org.mockito.ArgumentMatchers.isNull(),
payloadCaptor.capture());
java.util.Map<String, Object> payload = payloadCaptor.getValue();
assertThat(payload).containsEntry("email", "u@example.com");
assertThat((java.util.List<String>) payload.get("addedGroups")).containsExactly("Editors");
assertThat((java.util.List<String>) payload.get("removedGroups")).containsExactly("Viewers");
}
@Test
void adminUpdateUser_doesNotLogGroupMembershipChanged_whenGroupsUnchanged() {
UUID actorId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins").build();
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(group)).build();
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
when(groupRepository.findAllById(List.of(group.getId()))).thenReturn(List.of(group));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setGroupIds(List.of(group.getId()));
userService.adminUpdateUser(actorId, userId, dto);
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
}
@Test
void adminUpdateUser_doesNotLogGroupMembershipChanged_whenGroupIdsIsNull() {
UUID actorId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins").build();
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(group)).build();
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
// groupIds not set → null
userService.adminUpdateUser(actorId, userId, dto);
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
}
// ─── audit: USER_DELETED ──────────────────────────────────────────────────
@Test
void deleteUser_logsUserDeleted_withEmailInPayload() {
UUID actorId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
AppUser user = AppUser.builder().id(userId).email("gone@example.com").build();
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
userService.deleteUser(actorId, userId);
@SuppressWarnings("unchecked")
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
verify(auditService).logAfterCommit(
org.mockito.ArgumentMatchers.eq(AuditKind.USER_DELETED),
org.mockito.ArgumentMatchers.eq(actorId),
org.mockito.ArgumentMatchers.isNull(),
payloadCaptor.capture());
assertThat(payloadCaptor.getValue()).containsEntry("email", "gone@example.com");
assertThat(payloadCaptor.getValue()).containsKey("userId");
}
// ─── audit: USER_CREATED ──────────────────────────────────────────────────
@Test
void createUserOrUpdate_logsUserCreated_whenUserIsNew() {
UUID actorId = UUID.randomUUID();
CreateUserRequest req = new CreateUserRequest();
req.setEmail("new@example.com");
req.setInitialPassword("secret");
req.setGroupIds(List.of());
when(userRepository.findByEmail("new@example.com")).thenReturn(Optional.empty());
when(passwordEncoder.encode("secret")).thenReturn("encoded");
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
when(userRepository.save(any())).thenReturn(saved);
userService.createUserOrUpdate(actorId, req);
@SuppressWarnings("unchecked")
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
verify(auditService).logAfterCommit(
org.mockito.ArgumentMatchers.eq(AuditKind.USER_CREATED),
org.mockito.ArgumentMatchers.eq(actorId),
org.mockito.ArgumentMatchers.isNull(),
payloadCaptor.capture());
assertThat(payloadCaptor.getValue()).containsKey("userId");
assertThat(payloadCaptor.getValue()).containsEntry("email", "new@example.com");
}
@Test
void createUserOrUpdate_doesNotLogUserCreated_whenUserAlreadyExists() {
UUID actorId = UUID.randomUUID();
CreateUserRequest req = new CreateUserRequest();
req.setEmail("existing@example.com");
req.setInitialPassword("pass");
req.setGroupIds(List.of());
AppUser existing = AppUser.builder().id(UUID.randomUUID()).email("existing@example.com").build();
when(userRepository.findByEmail("existing@example.com")).thenReturn(Optional.of(existing));
when(passwordEncoder.encode(any())).thenReturn("encoded");
when(userRepository.save(any())).thenReturn(existing);
userService.createUserOrUpdate(actorId, req);
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

View File

@@ -23,8 +23,6 @@
"nav_conversations": "Briefwechsel", "nav_conversations": "Briefwechsel",
"nav_admin": "Admin", "nav_admin": "Admin",
"nav_logout": "Abmelden", "nav_logout": "Abmelden",
"theme_toggle_to_light": "Zu hellem Design wechseln",
"theme_toggle_to_dark": "Zu dunklem Design wechseln",
"btn_save": "Speichern", "btn_save": "Speichern",
"btn_cancel": "Abbrechen", "btn_cancel": "Abbrechen",
"btn_confirm": "Bestätigen", "btn_confirm": "Bestätigen",

View File

@@ -23,8 +23,6 @@
"nav_conversations": "Letters", "nav_conversations": "Letters",
"nav_admin": "Admin", "nav_admin": "Admin",
"nav_logout": "Sign out", "nav_logout": "Sign out",
"theme_toggle_to_light": "Switch to light mode",
"theme_toggle_to_dark": "Switch to dark mode",
"btn_save": "Save", "btn_save": "Save",
"btn_cancel": "Cancel", "btn_cancel": "Cancel",
"btn_confirm": "Confirm", "btn_confirm": "Confirm",

View File

@@ -23,8 +23,6 @@
"nav_conversations": "Cartas", "nav_conversations": "Cartas",
"nav_admin": "Admin", "nav_admin": "Admin",
"nav_logout": "Cerrar sesión", "nav_logout": "Cerrar sesión",
"theme_toggle_to_light": "Cambiar a modo claro",
"theme_toggle_to_dark": "Cambiar a modo oscuro",
"btn_save": "Guardar", "btn_save": "Guardar",
"btn_cancel": "Cancelar", "btn_cancel": "Cancelar",
"btn_confirm": "Confirmar", "btn_confirm": "Confirmar",

View File

@@ -48,12 +48,6 @@ function handleKeydown(event: KeyboardEvent) {
} }
} }
const bellLabel = $derived(
stream.unreadCount > 0
? m.notification_bell_unread_label({ count: stream.unreadCount })
: m.notification_bell_label()
);
function attachBellButton(node: HTMLButtonElement) { function attachBellButton(node: HTMLButtonElement) {
bellButtonEl = node; bellButtonEl = node;
return () => { return () => {
@@ -78,11 +72,12 @@ onDestroy(() => {
{@attach attachBellButton} {@attach attachBellButton}
type="button" type="button"
onclick={toggleDropdown} onclick={toggleDropdown}
aria-label={bellLabel} aria-label={stream.unreadCount > 0
title={bellLabel} ? m.notification_bell_unread_label({ count: stream.unreadCount })
: m.notification_bell_label()}
aria-expanded={open} aria-expanded={open}
aria-haspopup="true" aria-haspopup="true"
class="relative cursor-pointer rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" class="relative rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View File

@@ -55,34 +55,6 @@ async function openDropdownAndClickFirstNotification() {
notifButton.click(); notifButton.click();
} }
describe('NotificationBell — cursor and tooltip', () => {
it('bell button has cursor-pointer class', async () => {
render(NotificationBell);
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
expect(btn.classList.contains('cursor-pointer')).toBe(true);
});
it('bell button title equals aria-label when unreadCount is 0', async () => {
mockNotificationList.value = [];
render(NotificationBell);
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
expect(btn.getAttribute('title')).toBe('Benachrichtigungen');
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
});
it('bell button title equals aria-label when unreadCount is 3', async () => {
mockNotificationList.value = [
makeNotification({ id: 'n1' }),
makeNotification({ id: 'n2' }),
makeNotification({ id: 'n3' })
];
render(NotificationBell);
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
expect(btn.getAttribute('title')).toBe('3 ungelesene Benachrichtigungen');
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
});
});
describe('NotificationBell', () => { describe('NotificationBell', () => {
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => { it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })]; mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];

View File

@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
type Theme = 'light' | 'dark'; type Theme = 'light' | 'dark';
@@ -20,10 +19,6 @@ onMount(() => {
theme = resolveInitialTheme(); theme = resolveInitialTheme();
}); });
const themeLabel = $derived(
theme === 'dark' ? m.theme_toggle_to_light() : m.theme_toggle_to_dark()
);
function toggle() { function toggle() {
theme = theme === 'dark' ? 'light' : 'dark'; theme = theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', theme); localStorage.setItem('theme', theme);
@@ -34,8 +29,8 @@ function toggle() {
<button <button
type="button" type="button"
onclick={toggle} onclick={toggle}
aria-label={themeLabel} aria-label={theme === 'dark' ? 'light mode' : 'dark mode'}
title={themeLabel} title={theme === 'dark' ? 'light mode' : 'dark mode'}
class="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" class="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
> >
{#if theme === 'dark'} {#if theme === 'dark'}

View File

@@ -1,45 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ThemeToggle from './ThemeToggle.svelte';
afterEach(() => {
cleanup();
localStorage.removeItem('theme');
});
describe('ThemeToggle — label derivation (light mode)', () => {
beforeEach(() => {
localStorage.setItem('theme', 'light');
});
it('aria-label invites switching to dark mode when theme is light', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('aria-label')).toBe('Zu dunklem Design wechseln');
});
it('title equals aria-label in light mode', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
});
});
describe('ThemeToggle — label derivation (dark mode)', () => {
beforeEach(() => {
localStorage.setItem('theme', 'dark');
});
it('aria-label invites switching to light mode when theme is dark', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('aria-label')).toBe('Zu hellem Design wechseln');
});
it('title equals aria-label in dark mode', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
});
});

View File

@@ -365,11 +365,6 @@
text-underline-offset: 4px; text-underline-offset: 4px;
} }
/* Tailwind preflight resets cursor on *, overriding the browser default for buttons */
button {
cursor: pointer;
}
/* Fallback focus ring for any interactive element not styled with ring-focus-ring */ /* Fallback focus ring for any interactive element not styled with ring-focus-ring */
:focus-visible { :focus-visible {
outline: 2px solid var(--c-focus-ring); outline: 2px solid var(--c-focus-ring);