Compare commits
23 Commits
feat/issue
...
f5eb14a76d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5eb14a76d | ||
|
|
00af97653d | ||
|
|
f9a982db43 | ||
|
|
8e1733abbf | ||
|
|
842ab28f59 | ||
|
|
0c47c22185 | ||
|
|
d97cd06f70 | ||
|
|
b41405cb4b | ||
|
|
c9e9300216 | ||
|
|
92b082ec62 | ||
|
|
8770ca874b | ||
|
|
e54240ea1b | ||
|
|
437144174c | ||
|
|
fe830ad64b | ||
|
|
8f75552503 | ||
|
|
e7573bbeda | ||
|
|
bf31380141 | ||
|
|
58b3dabea2 | ||
|
|
60a278ad8e | ||
|
|
39f722fec0 | ||
|
|
fef021bf51 | ||
|
|
6c117611b8 | ||
|
|
aac8250af0 |
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'':*',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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' })];
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ function select(type: PersonType) {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-label={m.form_label_person_type()}
|
|
||||||
class="grid grid-cols-2 gap-2 sm:grid-cols-4"
|
class="grid grid-cols-2 gap-2 sm:grid-cols-4"
|
||||||
use:radioGroupNav={(v) => { if (TYPES.includes(v as PersonType)) select(v as PersonType); }}
|
use:radioGroupNav={(v) => { if (TYPES.includes(v as PersonType)) select(v as PersonType); }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,13 +7,6 @@ import PersonTypeSelector from './PersonTypeSelector.svelte';
|
|||||||
afterEach(() => cleanup());
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
describe('PersonTypeSelector', () => {
|
describe('PersonTypeSelector', () => {
|
||||||
it('radiogroup has an accessible name via aria-label', () => {
|
|
||||||
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
|
||||||
const radiogroup = container.querySelector('[role="radiogroup"]');
|
|
||||||
expect(radiogroup).not.toBeNull();
|
|
||||||
expect(radiogroup!.getAttribute('aria-label')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hidden input value updates when user navigates with ArrowRight', async () => {
|
it('hidden input value updates when user navigates with ArrowRight', async () => {
|
||||||
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||||
const hiddenInput = container.querySelector('input[type="hidden"]') as HTMLInputElement;
|
const hiddenInput = container.querySelector('input[type="hidden"]') as HTMLInputElement;
|
||||||
@@ -25,47 +18,32 @@ describe('PersonTypeSelector', () => {
|
|||||||
|
|
||||||
expect(hiddenInput.value).toBe('INSTITUTION');
|
expect(hiddenInput.value).toBe('INSTITUTION');
|
||||||
});
|
});
|
||||||
|
it('selected button uses semantic bg-primary and text-primary-fg classes', () => {
|
||||||
it('hidden input value updates when user navigates with ArrowLeft (wraps around)', async () => {
|
|
||||||
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||||
const hiddenInput = container.querySelector('input[type="hidden"]') as HTMLInputElement;
|
const buttons = container.querySelectorAll('[role="radio"]');
|
||||||
expect(hiddenInput.value).toBe('PERSON');
|
const selected = Array.from(buttons).find((b) => b.getAttribute('aria-checked') === 'true');
|
||||||
|
expect(selected).not.toBeNull();
|
||||||
const personButton = container.querySelector('[aria-checked="true"]') as HTMLElement;
|
expect(selected!.className).toContain('bg-primary');
|
||||||
personButton.focus();
|
expect(selected!.className).toContain('text-primary-fg');
|
||||||
await userEvent.keyboard('{ArrowLeft}');
|
|
||||||
|
|
||||||
expect(hiddenInput.value).toBe('UNKNOWN');
|
|
||||||
});
|
|
||||||
it('exactly one button is aria-checked=true for the initial value', () => {
|
|
||||||
const { container } = render(PersonTypeSelector, { value: 'INSTITUTION' });
|
|
||||||
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
|
|
||||||
const checked = buttons.filter((b) => b.getAttribute('aria-checked') === 'true');
|
|
||||||
const unchecked = buttons.filter((b) => b.getAttribute('aria-checked') === 'false');
|
|
||||||
expect(checked).toHaveLength(1);
|
|
||||||
expect(unchecked).toHaveLength(3);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('aria-checked=true moves to clicked button on click', async () => {
|
it('unselected buttons use semantic bg-surface, text-ink, border-line classes', () => {
|
||||||
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||||
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
|
const buttons = container.querySelectorAll('[role="radio"]');
|
||||||
const groupButton = buttons.find((b) => b.getAttribute('value') === 'GROUP') as HTMLElement;
|
const unselected = Array.from(buttons).filter((b) => b.getAttribute('aria-checked') !== 'true');
|
||||||
await userEvent.click(groupButton);
|
expect(unselected.length).toBeGreaterThan(0);
|
||||||
expect(groupButton.getAttribute('aria-checked')).toBe('true');
|
|
||||||
const others = buttons.filter((b) => b !== groupButton);
|
|
||||||
for (const btn of others) {
|
|
||||||
expect(btn.getAttribute('aria-checked')).toBe('false');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('selected button has tabindex=0, unselected buttons have tabindex=-1', () => {
|
|
||||||
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
|
||||||
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
|
|
||||||
const selected = buttons.find((b) => b.getAttribute('aria-checked') === 'true');
|
|
||||||
const unselected = buttons.filter((b) => b.getAttribute('aria-checked') !== 'true');
|
|
||||||
expect(selected!.getAttribute('tabindex')).toBe('0');
|
|
||||||
for (const btn of unselected) {
|
for (const btn of unselected) {
|
||||||
expect(btn.getAttribute('tabindex')).toBe('-1');
|
expect(btn.className).toContain('bg-surface');
|
||||||
|
expect(btn.className).toContain('text-ink');
|
||||||
|
expect(btn.className).toContain('border-line');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focus ring uses semantic ring-focus-ring class', () => {
|
||||||
|
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||||
|
const buttons = container.querySelectorAll('[role="radio"]');
|
||||||
|
for (const btn of buttons) {
|
||||||
|
expect(btn.className).toContain('ring-focus-ring');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3,17 +3,6 @@ import { m } from '$lib/paraglide/messages.js';
|
|||||||
export const PERSON_TYPES = ['PERSON', 'INSTITUTION', 'GROUP', 'UNKNOWN'] as const;
|
export const PERSON_TYPES = ['PERSON', 'INSTITUTION', 'GROUP', 'UNKNOWN'] as const;
|
||||||
export type PersonType = (typeof PERSON_TYPES)[number];
|
export type PersonType = (typeof PERSON_TYPES)[number];
|
||||||
|
|
||||||
export type PersonFormData = {
|
|
||||||
personType?: string | null;
|
|
||||||
title?: string | null;
|
|
||||||
firstName?: string | null;
|
|
||||||
lastName: string;
|
|
||||||
alias?: string | null;
|
|
||||||
birthYear?: number | null;
|
|
||||||
deathYear?: number | null;
|
|
||||||
notes?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function normalizePersonType(raw: string | undefined | null): PersonType {
|
export function normalizePersonType(raw: string | undefined | null): PersonType {
|
||||||
return raw === 'SKIP' ? 'UNKNOWN' : ((raw ?? 'PERSON') as PersonType);
|
return raw === 'SKIP' ? 'UNKNOWN' : ((raw ?? 'PERSON') as PersonType);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -2,13 +2,22 @@
|
|||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import PersonTypeSelector from '$lib/components/PersonTypeSelector.svelte';
|
import PersonTypeSelector from '$lib/components/PersonTypeSelector.svelte';
|
||||||
import {
|
import { PERSON_TYPES as TYPES, type PersonType } from '$lib/person-validation';
|
||||||
PERSON_TYPES as TYPES,
|
|
||||||
type PersonType,
|
|
||||||
type PersonFormData
|
|
||||||
} from '$lib/person-validation';
|
|
||||||
|
|
||||||
let { person }: { person: PersonFormData } = $props();
|
let {
|
||||||
|
person
|
||||||
|
}: {
|
||||||
|
person: {
|
||||||
|
personType?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName: string;
|
||||||
|
alias?: string | null;
|
||||||
|
birthYear?: number | null;
|
||||||
|
deathYear?: number | null;
|
||||||
|
notes?: string | null;
|
||||||
|
};
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let selectedType = $state<PersonType>(
|
let selectedType = $state<PersonType>(
|
||||||
untrack(() =>
|
untrack(() =>
|
||||||
@@ -22,15 +31,11 @@ const lastNameLabel = $derived(
|
|||||||
? m.form_label_name()
|
? m.form_label_name()
|
||||||
: m.form_label_last_name()
|
: m.form_label_last_name()
|
||||||
);
|
);
|
||||||
|
|
||||||
const labelCls = 'mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase';
|
|
||||||
const inputCls =
|
|
||||||
'block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<p class={labelCls}>
|
<p class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{m.form_label_person_type()}
|
{m.form_label_person_type()}
|
||||||
</p>
|
</p>
|
||||||
<PersonTypeSelector
|
<PersonTypeSelector
|
||||||
@@ -42,48 +47,68 @@ const inputCls =
|
|||||||
|
|
||||||
{#if isPerson}
|
{#if isPerson}
|
||||||
<div>
|
<div>
|
||||||
<label for="title" class={labelCls}>{m.form_label_title()}</label>
|
<label for="title" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
|
>{m.form_label_title()}</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
id="title"
|
id="title"
|
||||||
name="title"
|
name="title"
|
||||||
type="text"
|
type="text"
|
||||||
maxlength="50"
|
maxlength="50"
|
||||||
value={person.title ?? ''}
|
value={person.title ?? ''}
|
||||||
class={inputCls}
|
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="firstName" class={labelCls}>{m.form_label_first_name()} *</label>
|
<label
|
||||||
|
for="firstName"
|
||||||
|
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
|
>{m.form_label_first_name()} *</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
id="firstName"
|
id="firstName"
|
||||||
name="firstName"
|
name="firstName"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={person.firstName ?? ''}
|
value={person.firstName ?? ''}
|
||||||
class={inputCls}
|
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class={!isPerson ? 'md:col-span-2' : ''}>
|
<div class={!isPerson ? 'md:col-span-2' : ''}>
|
||||||
<label for="lastName" class={labelCls}>{lastNameLabel} *</label>
|
<label for="lastName" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
|
>{lastNameLabel} *</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
id="lastName"
|
id="lastName"
|
||||||
name="lastName"
|
name="lastName"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={person.lastName}
|
value={person.lastName}
|
||||||
class={inputCls}
|
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if isPerson}
|
{#if isPerson}
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<label for="alias" class={labelCls}>{m.form_label_alias()}</label>
|
<label for="alias" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
<input id="alias" name="alias" type="text" value={person.alias ?? ''} class={inputCls} />
|
>{m.form_label_alias()}</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="alias"
|
||||||
|
name="alias"
|
||||||
|
type="text"
|
||||||
|
value={person.alias ?? ''}
|
||||||
|
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="birthYear" class={labelCls}>{m.person_label_birth_year()}</label>
|
<label
|
||||||
|
for="birthYear"
|
||||||
|
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
|
>{m.person_label_birth_year()}</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
id="birthYear"
|
id="birthYear"
|
||||||
name="birthYear"
|
name="birthYear"
|
||||||
@@ -92,11 +117,15 @@ const inputCls =
|
|||||||
max="2100"
|
max="2100"
|
||||||
placeholder={m.person_placeholder_year()}
|
placeholder={m.person_placeholder_year()}
|
||||||
value={person.birthYear ?? ''}
|
value={person.birthYear ?? ''}
|
||||||
class={inputCls}
|
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="deathYear" class={labelCls}>{m.person_label_death_year()}</label>
|
<label
|
||||||
|
for="deathYear"
|
||||||
|
class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
|
>{m.person_label_death_year()}</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
id="deathYear"
|
id="deathYear"
|
||||||
name="deathYear"
|
name="deathYear"
|
||||||
@@ -105,13 +134,15 @@ const inputCls =
|
|||||||
max="2100"
|
max="2100"
|
||||||
placeholder={m.person_placeholder_year()}
|
placeholder={m.person_placeholder_year()}
|
||||||
value={person.deathYear ?? ''}
|
value={person.deathYear ?? ''}
|
||||||
class={inputCls}
|
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<label for="notes" class={labelCls}>{m.person_label_notes()}</label>
|
<label for="notes" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||||
|
>{m.person_label_notes()}</label
|
||||||
|
>
|
||||||
<textarea
|
<textarea
|
||||||
id="notes"
|
id="notes"
|
||||||
name="notes"
|
name="notes"
|
||||||
|
|||||||
Reference in New Issue
Block a user