feat: add audit log system for tracking admin-significant actions (#2759)

This commit is contained in:
ACX
2026-02-15 00:24:25 -07:00
committed by GitHub
parent c7f0a910e0
commit c9551ef4ab
61 changed files with 1022 additions and 87 deletions

View File

@@ -26,6 +26,8 @@ import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
@Slf4j
@AllArgsConstructor
@@ -39,6 +41,7 @@ public class AuthenticationService {
private final PasswordEncoder passwordEncoder;
private final JwtUtils jwtUtils;
private final DefaultSettingInitializer defaultSettingInitializer;
private final AuditService auditService;
public BookLoreUser getAuthenticatedUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
@@ -87,9 +90,13 @@ public class AuthenticationService {
}
public ResponseEntity<Map<String, String>> loginUser(UserLoginRequest loginRequest) {
BookLoreUserEntity user = userRepository.findByUsername(loginRequest.getUsername()).orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(loginRequest.getUsername()));
BookLoreUserEntity user = userRepository.findByUsername(loginRequest.getUsername()).orElseThrow(() -> {
auditService.log(AuditAction.LOGIN_FAILED, "Login failed for unknown user: " + loginRequest.getUsername());
return ApiError.USER_NOT_FOUND.createException(loginRequest.getUsername());
});
if (!passwordEncoder.matches(loginRequest.getPassword(), user.getPasswordHash())) {
auditService.log(AuditAction.LOGIN_FAILED, "Login failed for user: " + loginRequest.getUsername());
throw ApiError.INVALID_CREDENTIALS.createException();
}
@@ -125,6 +132,7 @@ public class AuthenticationService {
.build();
refreshTokenRepository.save(refreshTokenEntity);
auditService.log(AuditAction.LOGIN_SUCCESS, "User", user.getId(), "Login successful for user: " + user.getUsername());
return ResponseEntity.ok(Map.of(
"accessToken", accessToken,

View File

@@ -0,0 +1,57 @@
package org.booklore.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import org.booklore.model.dto.response.AuditLogDto;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
@AllArgsConstructor
@RestController
@RequestMapping("/api/v1/audit-logs")
@Tag(name = "Audit Logs", description = "Endpoints for viewing audit logs")
public class AuditLogController {
private final AuditService auditService;
@Operation(summary = "Get audit logs", description = "Retrieve paginated audit logs with optional filters. Requires admin.")
@ApiResponse(responseCode = "200", description = "Audit logs returned successfully")
@GetMapping
@PreAuthorize("@securityUtil.isAdmin()")
public ResponseEntity<Page<AuditLogDto>> getAuditLogs(
@Parameter(description = "Page number") @RequestParam(defaultValue = "0") int page,
@Parameter(description = "Page size") @RequestParam(defaultValue = "25") int size,
@Parameter(description = "Filter by action") @RequestParam(required = false) AuditAction action,
@Parameter(description = "Filter by user ID") @RequestParam(required = false) Long userId,
@Parameter(description = "Filter by username") @RequestParam(required = false) String username,
@Parameter(description = "Filter from date") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from,
@Parameter(description = "Filter to date") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to) {
Page<AuditLogDto> logs;
if (action == null && userId == null && username == null && from == null && to == null) {
logs = auditService.getAuditLogs(PageRequest.of(page, size));
} else {
logs = auditService.getAuditLogs(action, userId, username, from, to, PageRequest.of(page, size));
}
return ResponseEntity.ok(logs);
}
@Operation(summary = "Get distinct usernames", description = "Retrieve distinct usernames from audit logs for filtering.")
@GetMapping("/usernames")
@PreAuthorize("@securityUtil.isAdmin()")
public ResponseEntity<List<String>> getDistinctUsernames() {
return ResponseEntity.ok(auditService.getDistinctUsernames());
}
}

View File

@@ -28,6 +28,8 @@ import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import java.util.List;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
@RestController
@RequestMapping("/api/v1/books")
@@ -41,6 +43,7 @@ public class MetadataController {
private final MetadataMatchService metadataMatchService;
private final BookRepository bookRepository;
private final MetadataManagementService metadataManagementService;
private final AuditService auditService;
@Operation(summary = "Get prospective metadata for a book", description = "Fetch prospective metadata for a book by its ID. Requires metadata edit permission or admin.")
@ApiResponse(responseCode = "200", description = "Prospective metadata returned successfully")
@@ -78,6 +81,7 @@ public class MetadataController {
bookMetadataUpdater.setBookMetadata(context);
bookRepository.save(bookEntity);
auditService.log(AuditAction.METADATA_UPDATED, "Book", bookId, "Updated metadata for book: " + bookEntity.getMetadata().getTitle());
BookMetadata bookMetadata = bookMetadataMapper.toBookMetadata(bookEntity.getMetadata(), true);
return ResponseEntity.ok(bookMetadata);
}

View File

@@ -0,0 +1,25 @@
package org.booklore.model.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.booklore.model.enums.AuditAction;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuditLogDto {
private Long id;
private Long userId;
private String username;
private AuditAction action;
private String entityType;
private Long entityId;
private String description;
private String ipAddress;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,53 @@
package org.booklore.model.entity;
import jakarta.persistence.*;
import lombok.*;
import org.booklore.model.enums.AuditAction;
import java.time.LocalDateTime;
@Entity
@Table(name = "audit_log")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuditLogEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id")
private Long userId;
@Column(name = "username", nullable = false)
private String username;
@Enumerated(EnumType.STRING)
@Column(name = "action", nullable = false, length = 100)
private AuditAction action;
@Column(name = "entity_type", length = 100)
private String entityType;
@Column(name = "entity_id")
private Long entityId;
@Column(name = "description", nullable = false, length = 1024)
private String description;
@Column(name = "ip_address", length = 45)
private String ipAddress;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
}

View File

@@ -0,0 +1,35 @@
package org.booklore.model.enums;
public enum AuditAction {
LOGIN_SUCCESS,
LOGIN_FAILED,
USER_CREATED,
USER_UPDATED,
USER_DELETED,
PASSWORD_CHANGED,
PERMISSIONS_CHANGED,
LIBRARY_CREATED,
LIBRARY_UPDATED,
LIBRARY_DELETED,
LIBRARY_SCANNED,
BOOK_UPLOADED,
BOOK_DELETED,
BOOK_SENT,
METADATA_UPDATED,
SETTINGS_UPDATED,
OIDC_CONFIG_CHANGED,
TASK_EXECUTED,
SHELF_CREATED,
SHELF_UPDATED,
SHELF_DELETED,
MAGIC_SHELF_CREATED,
MAGIC_SHELF_UPDATED,
MAGIC_SHELF_DELETED,
EMAIL_PROVIDER_CREATED,
EMAIL_PROVIDER_UPDATED,
EMAIL_PROVIDER_DELETED,
OPDS_USER_CREATED,
OPDS_USER_DELETED,
OPDS_USER_UPDATED,
NAMING_PATTERN_CHANGED
}

View File

@@ -0,0 +1,39 @@
package org.booklore.repository;
import org.booklore.model.entity.AuditLogEntity;
import org.booklore.model.enums.AuditAction;
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.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public interface AuditLogRepository extends JpaRepository<AuditLogEntity, Long> {
Page<AuditLogEntity> findAllByOrderByCreatedAtDesc(Pageable pageable);
@Query("""
SELECT a FROM AuditLogEntity a
WHERE (:action IS NULL OR a.action = :action)
AND (:userId IS NULL OR a.userId = :userId)
AND (:username IS NULL OR a.username = :username)
AND (:from IS NULL OR a.createdAt >= :from)
AND (:to IS NULL OR a.createdAt <= :to)
ORDER BY a.createdAt DESC
""")
Page<AuditLogEntity> findFiltered(
@Param("action") AuditAction action,
@Param("userId") Long userId,
@Param("username") String username,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
Pageable pageable);
@Query("SELECT DISTINCT a.username FROM AuditLogEntity a ORDER BY a.username")
List<String> findDistinctUsernames();
}

View File

@@ -1,5 +1,6 @@
package org.booklore.repository;
import org.booklore.model.entity.AuthorEntity;
import org.booklore.model.entity.BookEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -158,7 +159,7 @@ public interface BookOpdsRepository extends JpaRepository<BookEntity, Long>, Jpa
WHERE (b.deleted IS NULL OR b.deleted = false)
ORDER BY a.name
""")
List<org.booklore.model.entity.AuthorEntity> findDistinctAuthors();
List<AuthorEntity> findDistinctAuthors();
@Query("""
SELECT DISTINCT a FROM AuthorEntity a
@@ -168,7 +169,7 @@ public interface BookOpdsRepository extends JpaRepository<BookEntity, Long>, Jpa
AND b.library.id IN :libraryIds
ORDER BY a.name
""")
List<org.booklore.model.entity.AuthorEntity> findDistinctAuthorsByLibraryIds(@Param("libraryIds") Collection<Long> libraryIds);
List<AuthorEntity> findDistinctAuthorsByLibraryIds(@Param("libraryIds") Collection<Long> libraryIds);
// ============================================
// BOOKS BY AUTHOR - Two Query Pattern

View File

@@ -5,6 +5,8 @@ import org.booklore.model.dto.MagicShelf;
import org.booklore.model.entity.MagicShelfEntity;
import org.booklore.repository.MagicShelfRepository;
import lombok.AllArgsConstructor;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -17,6 +19,7 @@ public class MagicShelfService {
private final MagicShelfRepository magicShelfRepository;
private final AuthenticationService authenticationService;
private final AuditService auditService;
public List<MagicShelf> getUserShelves() {
Long userId = authenticationService.getAuthenticatedUser().getId();
@@ -59,12 +62,16 @@ public class MagicShelfService {
existing.setIconType(dto.getIconType());
existing.setFilterJson(dto.getFilterJson());
existing.setPublic(dto.getIsPublic());
return toDto(magicShelfRepository.save(existing));
MagicShelf result = toDto(magicShelfRepository.save(existing));
auditService.log(AuditAction.MAGIC_SHELF_UPDATED, "MagicShelf", dto.getId(), "Updated magic shelf: " + dto.getName());
return result;
}
if (magicShelfRepository.existsByUserIdAndName(userId, dto.getName())) {
throw new IllegalArgumentException("A shelf with the same name already exists for this user.");
}
return toDto(magicShelfRepository.save(toEntity(dto, userId)));
MagicShelf result = toDto(magicShelfRepository.save(toEntity(dto, userId)));
auditService.log(AuditAction.MAGIC_SHELF_CREATED, "MagicShelf", result.getId(), "Created magic shelf: " + dto.getName());
return result;
}
@Transactional
@@ -74,7 +81,9 @@ public class MagicShelfService {
if (!shelf.getUserId().equals(userId)) {
throw new SecurityException("You are not authorized to delete this shelf");
}
String shelfName = shelf.getName();
magicShelfRepository.deleteById(id);
auditService.log(AuditAction.MAGIC_SHELF_DELETED, "MagicShelf", id, "Deleted magic shelf: " + shelfName);
}
private MagicShelf toDto(MagicShelfEntity entity) {

View File

@@ -15,6 +15,8 @@ import org.booklore.repository.BookRepository;
import org.booklore.repository.ShelfRepository;
import org.booklore.repository.UserRepository;
import lombok.AllArgsConstructor;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@@ -31,6 +33,7 @@ public class ShelfService {
private final BookMapper bookMapper;
private final AuthenticationService authenticationService;
private final UserRepository userRepository;
private final AuditService auditService;
public Shelf createShelf(ShelfCreateRequest request) {
Long userId = getAuthenticatedUserId();
@@ -47,7 +50,9 @@ public class ShelfService {
.isPublic(request.isPublicShelf())
.user(fetchUserEntityById(userId))
.build();
return shelfMapper.toShelf(shelfRepository.save(shelfEntity));
Shelf result = shelfMapper.toShelf(shelfRepository.save(shelfEntity));
auditService.log(AuditAction.SHELF_CREATED, "Shelf", shelfEntity.getId(), "Created shelf: " + request.getName());
return result;
}
public Shelf updateShelf(Long id, ShelfCreateRequest request) {
@@ -59,7 +64,9 @@ public class ShelfService {
shelfEntity.setIcon(request.getIcon());
shelfEntity.setIconType(request.getIconType());
shelfEntity.setPublic(request.isPublicShelf());
return shelfMapper.toShelf(shelfRepository.save(shelfEntity));
Shelf result = shelfMapper.toShelf(shelfRepository.save(shelfEntity));
auditService.log(AuditAction.SHELF_UPDATED, "Shelf", id, "Updated shelf: " + request.getName());
return result;
}
public List<Shelf> getShelves() {
@@ -75,6 +82,7 @@ public class ShelfService {
public void deleteShelf(Long shelfId) {
shelfRepository.deleteById(shelfId);
auditService.log(AuditAction.SHELF_DELETED, "Shelf", shelfId, "Deleted shelf: " + shelfId);
}
public Shelf getUserKoboShelf() {

View File

@@ -20,6 +20,8 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
@Service
@DependsOnDatabaseInitialization
@@ -28,14 +30,16 @@ public class AppSettingService {
private final AppProperties appProperties;
private final SettingPersistenceHelper settingPersistenceHelper;
private final AuthenticationService authenticationService;
private final AuditService auditService;
private volatile AppSettings appSettings;
private final ReentrantLock lock = new ReentrantLock();
public AppSettingService(AppProperties appProperties, SettingPersistenceHelper settingPersistenceHelper, @Lazy AuthenticationService authenticationService) {
public AppSettingService(AppProperties appProperties, SettingPersistenceHelper settingPersistenceHelper, @Lazy AuthenticationService authenticationService, @Lazy AuditService auditService) {
this.appProperties = appProperties;
this.settingPersistenceHelper = settingPersistenceHelper;
this.authenticationService = authenticationService;
this.auditService = auditService;
}
public AppSettings getAppSettings() {
@@ -66,6 +70,8 @@ public class AppSettingService {
setting.setVal(settingPersistenceHelper.serializeSettingValue(key, val));
settingPersistenceHelper.appSettingsRepository.save(setting);
refreshCache();
AuditAction action = key.name().startsWith("OIDC_") ? AuditAction.OIDC_CONFIG_CHANGED : AuditAction.SETTINGS_UPDATED;
auditService.log(action, "Updated setting: " + key);
}
private void validatePermission(AppSettingKey key, BookLoreUser user) {

View File

@@ -0,0 +1,92 @@
package org.booklore.service.audit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.booklore.model.dto.BookLoreUser;
import org.booklore.model.dto.response.AuditLogDto;
import org.booklore.model.entity.AuditLogEntity;
import org.booklore.model.enums.AuditAction;
import org.booklore.repository.AuditLogRepository;
import org.booklore.util.RequestUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class AuditService {
private final AuditLogRepository auditLogRepository;
public void log(AuditAction action, String description) {
log(action, null, null, description);
}
public void log(AuditAction action, String entityType, Long entityId, String description) {
try {
Long userId = null;
String username = "system";
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof BookLoreUser user) {
userId = user.getId();
username = user.getUsername();
}
String ipAddress = null;
try {
ipAddress = RequestUtils.getCurrentRequest().getRemoteAddr();
} catch (Exception ignored) {
// Non-HTTP context (scheduled tasks, etc.)
}
AuditLogEntity entity = AuditLogEntity.builder()
.userId(userId)
.username(username)
.action(action)
.entityType(entityType)
.entityId(entityId)
.description(description)
.ipAddress(ipAddress)
.build();
auditLogRepository.save(entity);
} catch (Exception e) {
log.warn("Failed to write audit log: action={}, description={}", action, description, e);
}
}
public Page<AuditLogDto> getAuditLogs(Pageable pageable) {
return auditLogRepository.findAllByOrderByCreatedAtDesc(pageable)
.map(this::toDto);
}
public Page<AuditLogDto> getAuditLogs(AuditAction action, Long userId, String username, LocalDateTime from, LocalDateTime to, Pageable pageable) {
return auditLogRepository.findFiltered(action, userId, username, from, to, pageable)
.map(this::toDto);
}
public List<String> getDistinctUsernames() {
return auditLogRepository.findDistinctUsernames();
}
private AuditLogDto toDto(AuditLogEntity entity) {
return AuditLogDto.builder()
.id(entity.getId())
.userId(entity.getUserId())
.username(entity.getUsername())
.action(entity.getAction())
.entityType(entity.getEntityType())
.entityId(entity.getEntityId())
.description(entity.getDescription())
.ipAddress(entity.getIpAddress())
.createdAt(entity.getCreatedAt())
.build();
}
}

View File

@@ -46,6 +46,8 @@ import java.sql.Timestamp;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
@Slf4j
@AllArgsConstructor
@@ -69,6 +71,7 @@ public class BookService {
private final EbookViewerPreferenceRepository ebookViewerPreferencesRepository;
private final SidecarMetadataWriter sidecarMetadataWriter;
private final FileStreamingService fileStreamingService;
private final AuditService auditService;
public List<Book> getBookDTOs(boolean includeDescription) {
@@ -513,6 +516,7 @@ public class BookService {
}
bookRepository.deleteAll(books);
auditService.log(AuditAction.BOOK_DELETED, "Deleted " + ids.size() + " book(s)");
BookDeletionResponse response = new BookDeletionResponse(ids, failedFileDeletions);
return failedFileDeletions.isEmpty()
? ResponseEntity.ok(response)

View File

@@ -13,6 +13,8 @@ import org.booklore.repository.UserEmailProviderPreferenceRepository;
import jakarta.transaction.Transactional;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
import org.springframework.stereotype.Service;
import java.util.List;
@@ -27,6 +29,7 @@ public class EmailProviderV2Service {
private final UserEmailProviderPreferenceRepository preferenceRepository;
private final EmailProviderV2Mapper mapper;
private final AuthenticationService authService;
private final AuditService auditService;
public List<EmailProviderV2> getEmailProviders() {
BookLoreUser user = authService.getAuthenticatedUser();
@@ -64,6 +67,7 @@ public class EmailProviderV2Service {
}
Long defaultProviderId = getDefaultProviderIdForUser(user.getId());
auditService.log(AuditAction.EMAIL_PROVIDER_CREATED, "EmailProvider", savedEntity.getId(), "Created email provider: " + savedEntity.getHost() + ":" + savedEntity.getPort());
return mapper.toDTO(savedEntity, defaultProviderId);
}
@@ -78,6 +82,7 @@ public class EmailProviderV2Service {
existingProvider.setShared(request.isShared());
}
EmailProviderV2Entity updatedEntity = repository.save(existingProvider);
auditService.log(AuditAction.EMAIL_PROVIDER_UPDATED, "EmailProvider", id, "Updated email provider: " + updatedEntity.getHost() + ":" + updatedEntity.getPort());
Long defaultProviderId = getDefaultProviderIdForUser(user.getId());
return mapper.toDTO(updatedEntity, defaultProviderId);
@@ -118,6 +123,7 @@ public class EmailProviderV2Service {
}
repository.deleteById(id);
auditService.log(AuditAction.EMAIL_PROVIDER_DELETED, "EmailProvider", id, "Deleted email provider");
}
private Long getDefaultProviderIdForUser(Long userId) {

View File

@@ -23,6 +23,8 @@ import jakarta.mail.internet.MimeMessage;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
@@ -41,6 +43,7 @@ public class SendEmailV2Service {
private final EmailRecipientV2Repository emailRecipientRepository;
private final NotificationService notificationService;
private final AuthenticationService authenticationService;
private final AuditService auditService;
public void emailBookQuick(Long bookId) {
BookLoreUser user = authenticationService.getAuthenticatedUser();
@@ -72,6 +75,7 @@ public class SendEmailV2Service {
SecurityContextVirtualThread.runWithSecurityContext(() -> {
try {
sendEmail(emailProvider, recipientEmail, book, bookFile);
auditService.log(AuditAction.BOOK_SENT, "Book", book.getId(), "Sent book: " + bookTitle + " to " + recipientEmail);
String successMessage = "The book: " + bookTitle + " has been successfully sent to " + recipientEmail;
notificationService.sendMessage(Topic.LOG, LogNotification.info(successMessage));
log.info(successMessage);

View File

@@ -16,6 +16,7 @@ import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.BookLoreUserEntity;
import org.booklore.model.entity.LibraryEntity;
import org.booklore.model.entity.LibraryPathEntity;
import org.booklore.model.enums.AuditAction;
import org.booklore.model.enums.BookFileType;
import org.booklore.model.websocket.Topic;
import org.booklore.repository.BookRepository;
@@ -42,6 +43,7 @@ import java.nio.file.Paths;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import org.booklore.service.audit.AuditService;
@Slf4j
@Service
@@ -71,6 +73,7 @@ public class LibraryService {
private final MonitoringService monitoringService;
private final AuthenticationService authenticationService;
private final UserRepository userRepository;
private final AuditService auditService;
@Transactional
@EventListener(ApplicationReadyEvent.class)
@@ -158,6 +161,7 @@ public class LibraryService {
});
}
auditService.log(AuditAction.LIBRARY_UPDATED, "Library", libraryId, "Updated library: " + library.getName());
return libraryMapper.toLibrary(savedLibrary);
}
@@ -208,11 +212,13 @@ public class LibraryService {
log.info("Parsing task completed!");
});
auditService.log(AuditAction.LIBRARY_CREATED, "Library", libraryEntity.getId(), "Created library: " + libraryEntity.getName());
return libraryMapper.toLibrary(libraryEntity);
}
public void rescanLibrary(long libraryId) {
libraryRepository.findById(libraryId).orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId));
LibraryEntity lib = libraryRepository.findById(libraryId).orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId));
auditService.log(AuditAction.LIBRARY_SCANNED, "Library", libraryId, "Scanned library: " + lib.getName());
SecurityContextVirtualThread.runWithSecurityContext(() -> {
if (!scanningLibraries.add(libraryId)) {
@@ -265,7 +271,9 @@ public class LibraryService {
monitoringService.unregisterLibrary(id);
Set<Long> bookIds = library.getBookEntities().stream().map(BookEntity::getId).collect(Collectors.toSet());
fileService.deleteBookCovers(bookIds);
String libraryName = library.getName();
libraryRepository.deleteById(id);
auditService.log(AuditAction.LIBRARY_DELETED, "Library", id, "Deleted library: " + libraryName);
log.info("Library deleted successfully: {}", id);
}
@@ -284,7 +292,9 @@ public class LibraryService {
public Library setFileNamingPattern(long libraryId, String pattern) {
LibraryEntity library = libraryRepository.findById(libraryId).orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId));
library.setFileNamingPattern(pattern);
return libraryMapper.toLibrary(libraryRepository.save(library));
Library result = libraryMapper.toLibrary(libraryRepository.save(library));
auditService.log(AuditAction.NAMING_PATTERN_CHANGED, "Library", libraryId, "Changed naming pattern for library: " + library.getName() + " to: " + pattern);
return result;
}
public Map<String, Long> getBookCountsByFormat(long libraryId) {

View File

@@ -367,7 +367,7 @@ public class EpubMetadataWriter implements MetadataWriter {
return;
}
DocumentBuilder builder = org.booklore.util.SecureXmlUtils.createSecureDocumentBuilder(true);
DocumentBuilder builder = SecureXmlUtils.createSecureDocumentBuilder(true);
Document opfDoc = builder.parse(opfFile);
applyCoverImageToEpub(tempDir, opfDoc, coverData);
@@ -486,7 +486,7 @@ public class EpubMetadataWriter implements MetadataWriter {
throw new IOException("container.xml not found at expected location: " + containerXml);
}
DocumentBuilder builder = org.booklore.util.SecureXmlUtils.createSecureDocumentBuilder(false);
DocumentBuilder builder = SecureXmlUtils.createSecureDocumentBuilder(false);
Document containerDoc = builder.parse(containerXml.toFile());
Node rootfile = containerDoc.getElementsByTagName("rootfile").item(0);
if (rootfile == null) {

View File

@@ -15,6 +15,7 @@ import org.booklore.model.dto.settings.MetadataPersistenceSettings;
import org.booklore.model.entity.BookMetadataEntity;
import org.booklore.model.enums.BookFileType;
import org.booklore.service.appsettings.AppSettingService;
import org.booklore.util.SecureXmlUtils;
import org.booklore.service.metadata.BookLoreMetadata;
import org.springframework.stereotype.Component;
import org.w3c.dom.Document;
@@ -247,7 +248,7 @@ public class PdfMetadataWriter implements MetadataWriter {
* - Booklore (booklore:) for series, subtitle, ISBNs, external IDs, ratings, moods, tags, page count
*/
private byte[] addCustomIdentifiersToXmp(byte[] xmpBytes, BookMetadataEntity metadata, MetadataCopyHelper helper, MetadataClearFlags clear) throws Exception {
DocumentBuilder builder = org.booklore.util.SecureXmlUtils.createSecureDocumentBuilder(true);
DocumentBuilder builder = SecureXmlUtils.createSecureDocumentBuilder(true);
Document doc = builder.parse(new ByteArrayInputStream(xmpBytes));
Element rdfRoot = (Element) doc.getElementsByTagNameNS("http://www.w3.org/1999/02/22-rdf-syntax-ns#", "RDF").item(0);
@@ -442,7 +443,7 @@ public class PdfMetadataWriter implements MetadataWriter {
private boolean isXmpMetadataDifferent(byte[] existingBytes, byte[] newBytes) {
if (existingBytes == null || newBytes == null) return true;
try {
DocumentBuilder builder = org.booklore.util.SecureXmlUtils.createSecureDocumentBuilder(false);
DocumentBuilder builder = SecureXmlUtils.createSecureDocumentBuilder(false);
Document doc1 = builder.parse(new ByteArrayInputStream(existingBytes));
Document doc2 = builder.parse(new ByteArrayInputStream(newBytes));
return !Objects.equals(

View File

@@ -4,6 +4,7 @@ import org.booklore.exception.ApiError;
import org.booklore.mapper.BookMapper;
import org.booklore.mapper.custom.BookLoreUserTransformer;
import org.booklore.model.dto.*;
import org.booklore.model.entity.AuthorEntity;
import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.BookLoreUserEntity;
import org.booklore.model.entity.ShelfEntity;
@@ -171,7 +172,7 @@ public class OpdsBookService {
.orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(userId));
BookLoreUser user = bookLoreUserTransformer.toDTO(entity);
List<org.booklore.model.entity.AuthorEntity> authors;
List<AuthorEntity> authors;
if (user.getPermissions().isAdmin()) {
authors = bookOpdsRepository.findDistinctAuthors();
@@ -183,7 +184,7 @@ public class OpdsBookService {
}
return authors.stream()
.map(org.booklore.model.entity.AuthorEntity::getName)
.map(AuthorEntity::getName)
.filter(Objects::nonNull)
.distinct()
.sorted()

View File

@@ -8,9 +8,12 @@ import org.booklore.model.dto.request.OpdsUserV2CreateRequest;
import org.booklore.model.dto.request.OpdsUserV2UpdateRequest;
import org.booklore.model.entity.BookLoreUserEntity;
import org.booklore.model.entity.OpdsUserV2Entity;
import org.booklore.model.enums.OpdsSortOrder;
import org.booklore.repository.OpdsUserV2Repository;
import org.booklore.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
@@ -28,6 +31,7 @@ public class OpdsUserV2Service {
private final UserRepository userRepository;
private final OpdsUserV2Mapper mapper;
private final PasswordEncoder passwordEncoder;
private final AuditService auditService;
public List<OpdsUserV2> getOpdsUsers() {
@@ -46,10 +50,12 @@ public class OpdsUserV2Service {
.user(userEntity)
.username(request.getUsername())
.passwordHash(passwordEncoder.encode(request.getPassword()))
.sortOrder(request.getSortOrder() != null ? request.getSortOrder() : org.booklore.model.enums.OpdsSortOrder.RECENT)
.sortOrder(request.getSortOrder() != null ? request.getSortOrder() : OpdsSortOrder.RECENT)
.build();
return mapper.toDto(opdsUserV2Repository.save(opdsUserV2));
OpdsUserV2 result = mapper.toDto(opdsUserV2Repository.save(opdsUserV2));
auditService.log(AuditAction.OPDS_USER_CREATED, "OpdsUser", result.getId(), "Created OPDS user: " + request.getUsername());
return result;
} catch (DataIntegrityViolationException e) {
if (e.getMostSpecificCause().getMessage().contains("uq_username")) {
throw new DataIntegrityViolationException("Username '" + request.getUsername() + "' is already taken");
@@ -64,7 +70,9 @@ public class OpdsUserV2Service {
if (!user.getUser().getId().equals(bookLoreUser.getId())) {
throw new AccessDeniedException("You are not allowed to delete this user");
}
String username = user.getUsername();
opdsUserV2Repository.delete(user);
auditService.log(AuditAction.OPDS_USER_DELETED, "OpdsUser", userId, "Deleted OPDS user: " + username);
}
public OpdsUserV2 updateOpdsUser(Long userId, OpdsUserV2UpdateRequest request) {
@@ -77,7 +85,9 @@ public class OpdsUserV2Service {
}
user.setSortOrder(request.sortOrder());
return mapper.toDto(opdsUserV2Repository.save(user));
OpdsUserV2 result = mapper.toDto(opdsUserV2Repository.save(user));
auditService.log(AuditAction.OPDS_USER_UPDATED, "OpdsUser", userId, "Updated OPDS user: " + user.getUsername());
return result;
}
public OpdsUserV2Entity findByUsername(String username) {

View File

@@ -15,6 +15,8 @@ import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.*;
import java.util.stream.Collectors;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
@Service
@RequiredArgsConstructor
@@ -22,6 +24,7 @@ import java.util.stream.Collectors;
public class TaskHistoryService {
private final TaskHistoryRepository taskHistoryRepository;
private final AuditService auditService;
@Transactional
public void createTask(String taskId, TaskType type, Long userId, Map<String, Object> options) {
@@ -35,6 +38,23 @@ public class TaskHistoryService {
.taskOptions(options)
.build();
taskHistoryRepository.save(task);
auditService.log(AuditAction.TASK_EXECUTED, "Task", null, buildTaskDescription(type, options));
}
private String buildTaskDescription(TaskType type, Map<String, Object> options) {
String taskName = type != null ? type.getName() : "Unknown";
StringBuilder sb = new StringBuilder("Started task: ").append(taskName);
if (options == null || options.isEmpty()) {
return sb.toString();
}
Object bookIds = options.get("bookIds");
Object libraryId = options.get("libraryId");
if (bookIds instanceof Collection<?> ids && !ids.isEmpty()) {
sb.append(" (Book IDs: ").append(ids.stream().map(Object::toString).collect(Collectors.joining(", "))).append(")");
} else if (libraryId != null) {
sb.append(" (Library ID: ").append(libraryId).append(")");
}
return sb.toString();
}
@Transactional

View File

@@ -34,6 +34,8 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.util.Optional;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
@RequiredArgsConstructor
@Service
@@ -54,6 +56,7 @@ public class FileUploadService {
private final AdditionalFileMapper additionalFileMapper;
private final FileMovingHelper fileMovingHelper;
private final MonitoringRegistrationService monitoringRegistrationService;
private final AuditService auditService;
public void uploadFile(MultipartFile file, long libraryId, long pathId) {
validateFile(file);
@@ -78,6 +81,7 @@ public class FileUploadService {
moveFileToFinalLocation(tempPath, finalPath);
log.info("File uploaded to final location: {}", finalPath);
auditService.log(AuditAction.BOOK_UPLOADED, "Library", libraryId, "Uploaded file: " + originalFileName);
} catch (IOException e) {
log.error("Failed to upload file: {}", originalFileName, e);

View File

@@ -19,6 +19,8 @@ import org.springframework.stereotype.Service;
import java.util.*;
import java.util.regex.Pattern;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
@Slf4j
@Service
@@ -31,6 +33,7 @@ public class UserProvisioningService {
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
private final UserDefaultsService userDefaultsService;
private final AppSettingService appSettingService;
private final AuditService auditService;
public boolean isInitialUserAlreadyProvisioned() {
return userRepository.count() > 0;
@@ -264,6 +267,7 @@ public class UserProvisioningService {
BookLoreUserEntity save = userRepository.save(user);
userDefaultsService.addDefaultShelves(save);
userDefaultsService.addDefaultSettings(save);
auditService.log(AuditAction.USER_CREATED, "User", save.getId(), "Created user: " + save.getUsername());
return save;
}
}

View File

@@ -22,6 +22,8 @@ import tools.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.stream.Collectors;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
@Service
@RequiredArgsConstructor
@@ -33,6 +35,7 @@ public class UserService {
private final PasswordEncoder passwordEncoder;
private final ObjectMapper objectMapper;
private final BookLoreUserTransformer bookLoreUserTransformer;
private final AuditService auditService;
public List<BookLoreUser> getBookLoreUsers() {
return userRepository.findAll()
@@ -48,6 +51,7 @@ public class UserService {
if (updateRequest.getPermissions() != null && getMyself().getPermissions().isAdmin()) {
UserPermission.copyFromRequestToEntity(updateRequest.getPermissions(), user.getPermissions());
auditService.log(AuditAction.PERMISSIONS_CHANGED, "User", id, "Changed permissions for user: " + user.getUsername());
}
if (updateRequest.getAssignedLibraries() != null && getMyself().getPermissions().isAdmin()) {
@@ -57,6 +61,7 @@ public class UserService {
}
userRepository.save(user);
auditService.log(AuditAction.USER_UPDATED, "User", id, "Updated user: " + user.getUsername());
return bookLoreUserTransformer.toDTO(user);
}
@@ -71,6 +76,7 @@ public class UserService {
throw ApiError.SELF_DELETION_NOT_ALLOWED.createException();
}
userRepository.delete(userToDelete);
auditService.log(AuditAction.USER_DELETED, "User", id, "Deleted user: " + userToDelete.getUsername());
}
public BookLoreUser getBookLoreUser(Long id) {
@@ -107,6 +113,7 @@ public class UserService {
bookLoreUserEntity.setDefaultPassword(false);
bookLoreUserEntity.setPasswordHash(passwordEncoder.encode(changePasswordRequest.getNewPassword()));
userRepository.save(bookLoreUserEntity);
auditService.log(AuditAction.PASSWORD_CHANGED, "User", bookLoreUser.getId(), "Password changed by user: " + bookLoreUser.getUsername());
}
public void changeUserPassword(ChangeUserPasswordRequest request) {
@@ -116,6 +123,7 @@ public class UserService {
}
userEntity.setPasswordHash(passwordEncoder.encode(request.getNewPassword()));
userRepository.save(userEntity);
auditService.log(AuditAction.PASSWORD_CHANGED, "User", request.getUserId(), "Password changed for user: " + userEntity.getUsername());
}
public void updateUserSetting(Long userId, UpdateUserSettingRequest request) {

View File

@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS audit_log
(
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NULL,
username VARCHAR(255) NOT NULL,
action VARCHAR(100) NOT NULL,
entity_type VARCHAR(100) NULL,
entity_id BIGINT NULL,
description VARCHAR(1024) NOT NULL,
ip_address VARCHAR(45) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log (created_at);
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log (user_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log (action);

View File

@@ -11,6 +11,7 @@ import org.booklore.service.progress.ReadingProgressService;
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
import org.booklore.service.monitoring.MonitoringRegistrationService;
import org.booklore.service.FileStreamingService;
import org.booklore.service.audit.AuditService;
import org.booklore.util.FileService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -55,6 +56,7 @@ class BookServiceDeleteTests {
BookUpdateService bookUpdateService = Mockito.mock(BookUpdateService.class);
SidecarMetadataWriter sidecarMetadataWriter = Mockito.mock(SidecarMetadataWriter.class);
FileStreamingService fileStreamingService = Mockito.mock(FileStreamingService.class);
AuditService auditService = Mockito.mock(AuditService.class);
bookService = new BookService(
bookRepository,
@@ -73,7 +75,8 @@ class BookServiceDeleteTests {
bookUpdateService,
ebookViewerPreferenceRepository,
sidecarMetadataWriter,
fileStreamingService
fileStreamingService,
auditService
);
}

View File

@@ -8,6 +8,7 @@ import org.booklore.model.dto.BookMetadata;
import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.BookMetadataEntity;
import org.booklore.repository.BookRepository;
import org.booklore.service.audit.AuditService;
import org.booklore.service.metadata.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -38,6 +39,8 @@ class MetadataControllerTest {
private BookRepository bookRepository;
@Mock
private MetadataManagementService metadataManagementService;
@Mock
private AuditService auditService;
@InjectMocks
private MetadataController metadataController;

View File

@@ -2,10 +2,12 @@ package org.booklore.repository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.booklore.BookloreApplication;
import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.BookMetadataEntity;
import org.booklore.model.entity.LibraryEntity;
import org.booklore.model.entity.LibraryPathEntity;
import org.booklore.service.task.TaskCronService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@@ -19,11 +21,12 @@ import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
@SpringBootTest(classes = {
org.booklore.BookloreApplication.class
BookloreApplication.class
})
@Transactional
@TestPropertySource(properties = {
@@ -57,13 +60,13 @@ class BookOpdsRepositoryDataJpaTest {
@Bean("flyway")
@Primary
public org.flywaydb.core.Flyway flyway() {
return org.mockito.Mockito.mock(org.flywaydb.core.Flyway.class);
return mock(org.flywaydb.core.Flyway.class);
}
@Bean
@Primary
public org.booklore.service.task.TaskCronService taskCronService() {
return org.mockito.Mockito.mock(org.booklore.service.task.TaskCronService.class);
public TaskCronService taskCronService() {
return mock(TaskCronService.class);
}
}

View File

@@ -2,10 +2,12 @@ package org.booklore.service;
import org.booklore.config.BookmarkProperties;
import org.booklore.config.security.service.AuthenticationService;
import org.booklore.exception.APIException;
import org.booklore.mapper.BookMarkMapper;
import org.booklore.model.dto.BookLoreUser;
import org.booklore.model.dto.BookMark;
import org.booklore.model.dto.CreateBookMarkRequest;
import org.booklore.model.dto.UpdateBookMarkRequest;
import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.BookLoreUserEntity;
import org.booklore.model.entity.BookMarkEntity;
@@ -112,7 +114,7 @@ class BookMarkServiceTest {
when(authenticationService.getAuthenticatedUser()).thenReturn(userDto);
when(bookMarkRepository.existsByCfiAndBookIdAndUserId("new-cfi", bookId, userId)).thenReturn(true); // Duplicate exists
assertThrows(org.booklore.exception.APIException.class, () -> bookMarkService.createBookmark(request));
assertThrows(APIException.class, () -> bookMarkService.createBookmark(request));
verify(bookMarkRepository, never()).save(any());
}
@@ -153,7 +155,7 @@ class BookMarkServiceTest {
@Test
void updateBookmark_Success() {
var updateRequest = org.booklore.model.dto.UpdateBookMarkRequest.builder()
var updateRequest = UpdateBookMarkRequest.builder()
.title("Updated Title")
.color("#FF0000")
.notes("Updated notes")
@@ -174,7 +176,7 @@ class BookMarkServiceTest {
@Test
void updateBookmark_NotFound() {
var updateRequest = org.booklore.model.dto.UpdateBookMarkRequest.builder()
var updateRequest = UpdateBookMarkRequest.builder()
.title("Updated Title")
.build();

View File

@@ -2,12 +2,14 @@ package org.booklore.service;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.booklore.BookloreApplication;
import org.booklore.model.dto.*;
import org.booklore.model.entity.*;
import org.booklore.model.enums.BookFileType;
import org.booklore.model.enums.ComicCreatorRole;
import org.booklore.model.enums.ReadStatus;
import org.booklore.repository.BookRepository;
import org.booklore.service.task.TaskCronService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@@ -30,8 +32,9 @@ import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
@SpringBootTest(classes = {org.booklore.BookloreApplication.class})
@SpringBootTest(classes = {BookloreApplication.class})
@Transactional
@TestPropertySource(properties = {
"spring.flyway.enabled=false",
@@ -58,13 +61,13 @@ class BookRuleEvaluatorServiceIntegrationTest {
@Bean("flyway")
@Primary
public org.flywaydb.core.Flyway flyway() {
return org.mockito.Mockito.mock(org.flywaydb.core.Flyway.class);
return mock(org.flywaydb.core.Flyway.class);
}
@Bean
@Primary
public org.booklore.service.task.TaskCronService taskCronService() {
return org.mockito.Mockito.mock(org.booklore.service.task.TaskCronService.class);
public TaskCronService taskCronService() {
return mock(TaskCronService.class);
}
}

View File

@@ -3,6 +3,7 @@ package org.booklore.service;
import org.booklore.config.security.service.AuthenticationService;
import org.booklore.model.dto.Book;
import org.booklore.model.dto.BookLoreUser;
import org.booklore.model.dto.settings.AppSettings;
import org.booklore.model.dto.kobo.KoboBookMetadata;
import org.booklore.model.dto.kobo.KoboTag;
import org.booklore.model.dto.kobo.KoboTagWrapper;
@@ -144,8 +145,8 @@ class KoboEntitlementServiceTest {
return book;
}
private org.booklore.model.dto.settings.AppSettings createAppSettingsWithKoboSettings() {
var appSettings = new org.booklore.model.dto.settings.AppSettings();
private AppSettings createAppSettingsWithKoboSettings() {
var appSettings = new AppSettings();
KoboSettings koboSettings = KoboSettings.builder()
.convertCbxToEpub(true)
.conversionLimitInMbForCbx(50)

View File

@@ -9,6 +9,7 @@ import org.booklore.exception.APIException;
import org.booklore.model.dto.progress.KoreaderProgress;
import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.BookLoreUserEntity;
import org.booklore.model.entity.KoreaderUserEntity;
import org.booklore.model.entity.UserBookProgressEntity;
import org.booklore.model.enums.ReadStatus;
import org.booklore.repository.BookRepository;
@@ -73,7 +74,7 @@ class KoreaderServiceTest {
@Test
void authorizeUser_success() {
var userEntity = new org.booklore.model.entity.KoreaderUserEntity();
var userEntity = new KoreaderUserEntity();
userEntity.setPasswordMD5("MD5PWD");
when(koreaderUserRepo.findByUsername("u"))
.thenReturn(Optional.of(userEntity));
@@ -93,7 +94,7 @@ class KoreaderServiceTest {
@Test
void authorizeUser_badPassword() {
var userEntity = new org.booklore.model.entity.KoreaderUserEntity();
var userEntity = new KoreaderUserEntity();
userEntity.setPasswordMD5("OTHER");
when(koreaderUserRepo.findByUsername("u"))
.thenReturn(Optional.of(userEntity));

View File

@@ -4,6 +4,8 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.booklore.config.security.service.AuthenticationService;
import org.booklore.exception.APIException;
import org.booklore.mapper.KoreaderUserMapper;
import org.booklore.model.dto.BookLoreUser;
import org.booklore.model.dto.KoreaderUser;
import org.booklore.model.entity.BookLoreUserEntity;
@@ -29,7 +31,7 @@ class KoreaderUserServiceTest {
@Mock AuthenticationService authService;
@Mock UserRepository userRepository;
@Mock KoreaderUserRepository koreaderUserRepository;
@Mock org.booklore.mapper.KoreaderUserMapper koreaderUserMapper;
@Mock KoreaderUserMapper koreaderUserMapper;
@InjectMocks
KoreaderUserService service;
@@ -100,7 +102,7 @@ class KoreaderUserServiceTest {
@Test
void upsertUser_throws_whenOwnerMissing() {
when(userRepository.findById(123L)).thenReturn(Optional.empty());
assertThrows(org.booklore.exception.APIException.class,
assertThrows(APIException.class,
() -> service.upsertUser("x", "y"));
}
@@ -114,7 +116,7 @@ class KoreaderUserServiceTest {
@Test
void getUser_throws_whenNotFound() {
when(koreaderUserRepository.findByBookLoreUserId(123L)).thenReturn(Optional.empty());
assertThrows(org.booklore.exception.APIException.class, () -> service.getUser());
assertThrows(APIException.class, () -> service.getUser());
}
@Test
@@ -128,6 +130,6 @@ class KoreaderUserServiceTest {
@Test
void toggleSync_throws_whenEntityMissing() {
when(koreaderUserRepository.findByBookLoreUserId(123L)).thenReturn(Optional.empty());
assertThrows(org.booklore.exception.APIException.class, () -> service.toggleSync(false));
assertThrows(APIException.class, () -> service.toggleSync(false));
}
}

View File

@@ -2,6 +2,7 @@ package org.booklore.service;
import org.booklore.config.security.service.AuthenticationService;
import org.booklore.model.dto.BookLoreUser;
import org.booklore.service.audit.AuditService;
import org.booklore.model.dto.MagicShelf;
import org.booklore.model.entity.MagicShelfEntity;
import org.booklore.model.enums.IconType;
@@ -28,6 +29,8 @@ class MagicShelfServiceTest {
private MagicShelfRepository magicShelfRepository;
@Mock
private AuthenticationService authenticationService;
@Mock
private AuditService auditService;
@InjectMocks
private MagicShelfService magicShelfService;

View File

@@ -7,6 +7,7 @@ import org.booklore.model.dto.*;
import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.BookLoreUserEntity;
import org.booklore.model.entity.ShelfEntity;
import org.booklore.model.entity.UserPermissionsEntity;
import org.booklore.repository.BookOpdsRepository;
import org.booklore.repository.ShelfRepository;
import org.booklore.repository.UserRepository;
@@ -123,7 +124,7 @@ class OpdsBookServiceTest {
void getBooksPage_legacyUser_delegatesToLegacyMethod() {
OpdsUserDetails details = legacyUserDetails();
BookLoreUserEntity entity = mock(BookLoreUserEntity.class);
var permissionsEntity = mock(org.booklore.model.entity.UserPermissionsEntity.class);
var permissionsEntity = mock(UserPermissionsEntity.class);
when(permissionsEntity.isPermissionAccessOpds()).thenReturn(true);
when(permissionsEntity.isPermissionAdmin()).thenReturn(false);
when(entity.getPermissions()).thenReturn(permissionsEntity);
@@ -182,7 +183,7 @@ class OpdsBookServiceTest {
when(bookOpdsRepository.findAllWithFullMetadataByIdsAndShelfIds(anyList(), anySet())).thenReturn(List.of());
BookLoreUserEntity entity = mock(BookLoreUserEntity.class);
var permissionsEntity = mock(org.booklore.model.entity.UserPermissionsEntity.class);
var permissionsEntity = mock(UserPermissionsEntity.class);
when(permissionsEntity.isPermissionAccessOpds()).thenReturn(true);
when(permissionsEntity.isPermissionAdmin()).thenReturn(true);
when(entity.getPermissions()).thenReturn(permissionsEntity);
@@ -329,7 +330,7 @@ class OpdsBookServiceTest {
void getBooksPageForV2User_throwsForbidden_whenNoPermission() {
OpdsUserV2 v2 = OpdsUserV2.builder().userId(1L).build();
BookLoreUserEntity entity = mock(BookLoreUserEntity.class);
var permissionsEntity = mock(org.booklore.model.entity.UserPermissionsEntity.class);
var permissionsEntity = mock(UserPermissionsEntity.class);
when(permissionsEntity.isPermissionAccessOpds()).thenReturn(false);
when(permissionsEntity.isPermissionAdmin()).thenReturn(false);
when(entity.getPermissions()).thenReturn(permissionsEntity);
@@ -344,7 +345,7 @@ class OpdsBookServiceTest {
void getBooksPage_withSingleShelfId_returnsShelfBooks() {
OpdsUserDetails details = v2UserDetails(1L, false, Set.of(1L));
BookLoreUserEntity entity = mock(BookLoreUserEntity.class);
var permissionsEntity = mock(org.booklore.model.entity.UserPermissionsEntity.class);
var permissionsEntity = mock(UserPermissionsEntity.class);
when(permissionsEntity.isPermissionAccessOpds()).thenReturn(true);
when(permissionsEntity.isPermissionAdmin()).thenReturn(false);
when(entity.getPermissions()).thenReturn(permissionsEntity);
@@ -383,7 +384,7 @@ class OpdsBookServiceTest {
void getBooksPage_withMultipleShelfIds_returnsShelfBooks() {
OpdsUserDetails details = v2UserDetails(1L, false, Set.of(1L));
BookLoreUserEntity entity = mock(BookLoreUserEntity.class);
var permissionsEntity = mock(org.booklore.model.entity.UserPermissionsEntity.class);
var permissionsEntity = mock(UserPermissionsEntity.class);
when(permissionsEntity.isPermissionAccessOpds()).thenReturn(true);
when(permissionsEntity.isPermissionAdmin()).thenReturn(false);
when(entity.getPermissions()).thenReturn(permissionsEntity);
@@ -429,7 +430,7 @@ class OpdsBookServiceTest {
void getBooksPage_withShelfIdAndQuery_searchesInShelf() {
OpdsUserDetails details = v2UserDetails(1L, false, Set.of(1L));
BookLoreUserEntity entity = mock(BookLoreUserEntity.class);
var permissionsEntity = mock(org.booklore.model.entity.UserPermissionsEntity.class);
var permissionsEntity = mock(UserPermissionsEntity.class);
when(permissionsEntity.isPermissionAccessOpds()).thenReturn(true);
when(permissionsEntity.isPermissionAdmin()).thenReturn(false);
when(entity.getPermissions()).thenReturn(permissionsEntity);
@@ -468,7 +469,7 @@ class OpdsBookServiceTest {
void getBooksPage_withShelfId_throwsForbidden_whenNotOwner() {
OpdsUserDetails details = v2UserDetails(1L, false, Set.of(1L));
BookLoreUserEntity entity = mock(BookLoreUserEntity.class);
var permissionsEntity = mock(org.booklore.model.entity.UserPermissionsEntity.class);
var permissionsEntity = mock(UserPermissionsEntity.class);
when(permissionsEntity.isPermissionAccessOpds()).thenReturn(true);
when(permissionsEntity.isPermissionAdmin()).thenReturn(false);
when(entity.getPermissions()).thenReturn(permissionsEntity);
@@ -498,7 +499,7 @@ class OpdsBookServiceTest {
void getBooksPage_withShelfId_allowsAdmin_evenIfNotOwner() {
OpdsUserDetails details = v2UserDetails(1L, true, Set.of(1L));
BookLoreUserEntity entity = mock(BookLoreUserEntity.class);
var permissionsEntity = mock(org.booklore.model.entity.UserPermissionsEntity.class);
var permissionsEntity = mock(UserPermissionsEntity.class);
when(permissionsEntity.isPermissionAccessOpds()).thenReturn(true);
when(permissionsEntity.isPermissionAdmin()).thenReturn(true);
when(entity.getPermissions()).thenReturn(permissionsEntity);

View File

@@ -2,6 +2,7 @@ package org.booklore.service;
import org.booklore.config.security.service.AuthenticationService;
import org.booklore.mapper.OpdsUserV2Mapper;
import org.booklore.service.audit.AuditService;
import org.booklore.model.dto.BookLoreUser;
import org.booklore.model.dto.OpdsUserV2;
import org.booklore.model.dto.request.OpdsUserV2CreateRequest;
@@ -40,6 +41,8 @@ class OpdsUserV2ServiceTest {
private OpdsUserV2Mapper mapper;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private AuditService auditService;
@InjectMocks
private OpdsUserV2Service service;

View File

@@ -2,6 +2,7 @@ package org.booklore.service;
import org.booklore.config.security.service.AuthenticationService;
import org.booklore.mapper.ShelfMapper;
import org.booklore.service.audit.AuditService;
import org.booklore.model.dto.BookLoreUser;
import org.booklore.model.dto.Shelf;
import org.booklore.model.dto.request.ShelfCreateRequest;
@@ -38,6 +39,8 @@ class ShelfServiceTest {
private AuthenticationService authenticationService;
@Mock
private UserRepository userRepository;
@Mock
private AuditService auditService;
@InjectMocks
private ShelfService shelfService;

View File

@@ -2,6 +2,7 @@ package org.booklore.service.book;
import org.booklore.config.security.service.AuthenticationService;
import org.booklore.exception.APIException;
import org.booklore.service.audit.AuditService;
import org.booklore.mapper.BookMapper;
import org.booklore.model.dto.*;
import org.booklore.model.dto.request.ReadProgressRequest;
@@ -69,6 +70,8 @@ class BookServiceTest {
private MonitoringRegistrationService monitoringRegistrationService;
@Mock
private BookUpdateService bookUpdateService;
@Mock
private AuditService auditService;
@InjectMocks
private BookService bookService;

View File

@@ -1,5 +1,6 @@
package org.booklore.service.library;
import org.booklore.model.dto.settings.LibraryFile;
import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.LibraryEntity;
import org.booklore.model.entity.LibraryPathEntity;
@@ -89,7 +90,7 @@ class LibraryProcessingServiceRegressionTest {
when(libraryRepository.findById(libraryId)).thenReturn(Optional.of(libraryEntity));
when(libraryFileHelper.getLibraryFiles(libraryEntity)).thenReturn(List.of(
org.booklore.model.dto.settings.LibraryFile.builder()
LibraryFile.builder()
.libraryPathEntity(pathEntity)
.fileName("other.epub")
.fileSubPath("")

View File

@@ -2,6 +2,7 @@ package org.booklore.service.library;
import org.booklore.config.security.service.AuthenticationService;
import org.booklore.mapper.LibraryMapper;
import org.booklore.service.audit.AuditService;
import org.booklore.model.dto.BookLoreUser;
import org.booklore.model.dto.Library;
import org.booklore.model.dto.LibraryPath;
@@ -60,6 +61,8 @@ class LibraryServiceIconTest {
private AuthenticationService authenticationService;
@Mock
private UserRepository userRepository;
@Mock
private AuditService auditService;
@InjectMocks
private LibraryService libraryService;

View File

@@ -279,7 +279,7 @@ class BookCoverServiceTest {
MetadataPersistenceSettings settings = new MetadataPersistenceSettings();
settings.setSaveToOriginalFile(saveToOriginalFileObj);
settings.setConvertCbrCb7ToCbz(convertCbrCb7ToCbz);
return new org.booklore.model.dto.settings.AppSettings() {
return new AppSettings() {
@Override
public MetadataPersistenceSettings getMetadataPersistenceSettings() {
return settings;

View File

@@ -388,8 +388,8 @@ class BookMetadataUpdaterTest {
metadataEntity.setBook(bookEntity);
bookEntity.setMetadata(metadataEntity);
Set<org.booklore.model.entity.AuthorEntity> existingAuthors = new HashSet<>();
existingAuthors.add(org.booklore.model.entity.AuthorEntity.builder().name("Old Author").build());
Set<AuthorEntity> existingAuthors = new HashSet<>();
existingAuthors.add(AuthorEntity.builder().name("Old Author").build());
metadataEntity.setAuthors(existingAuthors);
BookFileEntity primaryFile = new BookFileEntity();
@@ -416,12 +416,12 @@ class BookMetadataUpdaterTest {
.replaceMode(MetadataReplaceMode.REPLACE_ALL)
.build();
when(authorRepository.findByName("New Author")).thenReturn(Optional.of(org.booklore.model.entity.AuthorEntity.builder().name("New Author").build()));
when(authorRepository.findByName("New Author")).thenReturn(Optional.of(AuthorEntity.builder().name("New Author").build()));
bookMetadataUpdater.setBookMetadata(context);
// Verify authors are replaced
Set<org.booklore.model.entity.AuthorEntity> authors = bookEntity.getMetadata().getAuthors();
Set<AuthorEntity> authors = bookEntity.getMetadata().getAuthors();
assertEquals(1, authors.size());
assertTrue(authors.stream().anyMatch(a -> a.getName().equals("New Author")));
assertFalse(authors.stream().anyMatch(a -> a.getName().equals("Old Author")));
@@ -436,8 +436,8 @@ class BookMetadataUpdaterTest {
metadataEntity.setBook(bookEntity);
bookEntity.setMetadata(metadataEntity);
Set<org.booklore.model.entity.CategoryEntity> existingCategories = new HashSet<>();
existingCategories.add(org.booklore.model.entity.CategoryEntity.builder().name("Old Category").build());
Set<CategoryEntity> existingCategories = new HashSet<>();
existingCategories.add(CategoryEntity.builder().name("Old Category").build());
metadataEntity.setCategories(existingCategories);
BookFileEntity primaryFile = new BookFileEntity();
@@ -464,12 +464,12 @@ class BookMetadataUpdaterTest {
.replaceMode(MetadataReplaceMode.REPLACE_ALL)
.build();
when(categoryRepository.findByName("New Category")).thenReturn(Optional.of(org.booklore.model.entity.CategoryEntity.builder().name("New Category").build()));
when(categoryRepository.findByName("New Category")).thenReturn(Optional.of(CategoryEntity.builder().name("New Category").build()));
bookMetadataUpdater.setBookMetadata(context);
// Verify categories are replaced
Set<org.booklore.model.entity.CategoryEntity> categories = bookEntity.getMetadata().getCategories();
Set<CategoryEntity> categories = bookEntity.getMetadata().getCategories();
assertEquals(1, categories.size());
assertTrue(categories.stream().anyMatch(c -> c.getName().equals("New Category")));
assertFalse(categories.stream().anyMatch(c -> c.getName().equals("Old Category")));

View File

@@ -8,6 +8,7 @@ import org.booklore.model.entity.ShelfEntity;
import org.booklore.model.enums.BookFileType;
import org.booklore.model.enums.OpdsSortOrder;
import org.booklore.service.MagicShelfService;
import org.booklore.util.ArchiveUtils;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.Page;
@@ -480,7 +481,7 @@ class OpdsFeedServiceTest {
BookFile bookFile = BookFile.builder()
.bookType(BookFileType.CBX)
.fileName("comic.cbz")
.archiveType(org.booklore.util.ArchiveUtils.ArchiveType.UNKNOWN)
.archiveType(ArchiveUtils.ArchiveType.UNKNOWN)
.build();
String mimeType = (String) method.invoke(opdsFeedService, bookFile);
@@ -495,7 +496,7 @@ class OpdsFeedServiceTest {
BookFile bookFile = BookFile.builder()
.bookType(BookFileType.CBX)
.fileName("comic.cbr")
.archiveType(org.booklore.util.ArchiveUtils.ArchiveType.UNKNOWN)
.archiveType(ArchiveUtils.ArchiveType.UNKNOWN)
.build();
String mimeType = (String) method.invoke(opdsFeedService, bookFile);

View File

@@ -5,6 +5,7 @@ import org.booklore.model.dto.response.AudiobookInfo;
import org.booklore.model.dto.response.AudiobookTrack;
import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.BookFileEntity;
import org.booklore.model.entity.BookMetadataEntity;
import org.jaudiotagger.audio.AudioFile;
import org.jaudiotagger.audio.AudioFileIO;
import org.jaudiotagger.audio.AudioHeader;
@@ -168,7 +169,7 @@ class AudioMetadataServiceTest {
.build()
));
var bookMetadata = new org.booklore.model.entity.BookMetadataEntity();
var bookMetadata = new BookMetadataEntity();
bookMetadata.setTitle("DB Book Title");
bookMetadata.setNarrator("DB Narrator");
bookEntity.setMetadata(bookMetadata);

View File

@@ -6,6 +6,7 @@ import org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry;
import org.apache.commons.compress.archivers.sevenz.SevenZFile;
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipFile;
import org.booklore.exception.APIException;
import org.booklore.exception.ApiError;
import org.booklore.model.entity.BookEntity;
import org.booklore.repository.BookRepository;
@@ -296,7 +297,7 @@ class CbxReaderServiceTest {
Files.createFile(unknownPath);
Files.setLastModifiedTime(unknownPath, FileTime.fromMillis(System.currentTimeMillis()));
assertThrows(org.booklore.exception.APIException.class, () -> cbxReaderService.getAvailablePages(1L));
assertThrows(APIException.class, () -> cbxReaderService.getAvailablePages(1L));
}
}

View File

@@ -2,6 +2,7 @@ package org.booklore.service.task;
import org.booklore.repository.TaskHistoryRepository;
import org.booklore.model.entity.TaskHistoryEntity;
import org.booklore.service.audit.AuditService;
import org.booklore.task.TaskStatus;
import org.booklore.model.dto.response.TasksHistoryResponse;
import org.booklore.model.enums.TaskType;
@@ -22,6 +23,8 @@ class TaskHistoryServiceTest {
@Mock
private TaskHistoryRepository taskHistoryRepository;
@Mock
private AuditService auditService;
@InjectMocks
private TaskHistoryService taskHistoryService;

View File

@@ -9,6 +9,7 @@ import org.booklore.model.dto.response.CronConfig;
import org.booklore.model.dto.response.TaskCreateResponse;
import org.booklore.model.enums.TaskType;
import org.booklore.task.TaskCancellationManager;
import org.booklore.task.TaskStatus;
import org.booklore.task.tasks.Task;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
@@ -197,7 +198,7 @@ class TaskServiceTest {
TaskCreateResponse resp = taskService.runAsUser(req);
assertEquals(asyncType, resp.getTaskType());
assertEquals(org.booklore.task.TaskStatus.ACCEPTED, resp.getStatus());
assertEquals(TaskStatus.ACCEPTED, resp.getStatus());
}
@Test

View File

@@ -20,6 +20,7 @@ import org.booklore.service.file.FileMovingHelper;
import org.booklore.model.dto.BookMetadata;
import org.booklore.model.enums.BookFileExtension;
import org.booklore.service.metadata.extractor.MetadataExtractorFactory;
import org.booklore.service.audit.AuditService;
import org.booklore.service.monitoring.MonitoringRegistrationService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
@@ -76,6 +77,8 @@ class FileUploadServiceTest {
@Mock
MonitoringRegistrationService monitoringRegistrationService;
@Mock
AuditService auditService;
AppProperties appProperties;
FileUploadService service;
@@ -93,7 +96,7 @@ class FileUploadServiceTest {
service = new FileUploadService(
libraryRepository, bookRepository, bookAdditionalFileRepository,
appSettingService, appProperties, metadataExtractorFactory, additionalFileMapper, fileMovingHelper, monitoringRegistrationService
appSettingService, appProperties, metadataExtractorFactory, additionalFileMapper, fileMovingHelper, monitoringRegistrationService, auditService
);
}