diff --git a/NEWS.md b/NEWS.md index b36b55c3..28a08fea 100644 --- a/NEWS.md +++ b/NEWS.md @@ -5,6 +5,7 @@ * [MODAUD-288](https://folio-org.atlassian.net/browse/MODAUD-288) - assertj: Upgrade from 3.27.3 to 3.27.7, change scope from compile to test * [MODAUD-296](https://folio-org.atlassian.net/browse/MODAUD-296) - Implement User Audit Enabled Setting * [MODAUD-298](https://folio-org.atlassian.net/browse/MODAUD-298) - Extract shared utilities and decouple DiffCalculator from inventory types +* [MODAUD-297](https://folio-org.atlassian.net/browse/MODAUD-297) - Consume user domain events and store audit history ## 2.11.1 2025-04-15 * [MODAUD-250](https://folio-org.atlassian.net/browse/MODAUD-250) - Version history of "MARC" records is not tracked diff --git a/PERSONAL_DATA_DISCLOSURE.md b/PERSONAL_DATA_DISCLOSURE.md index aa41c225..a639f9e1 100644 --- a/PERSONAL_DATA_DISCLOSURE.md +++ b/PERSONAL_DATA_DISCLOSURE.md @@ -12,24 +12,25 @@ For the purposes of this form, "store" includes the following: ## Personal Data Stored by This Module - [ ] This module does not store any personal data. - [ ] This module provides [custom fields](https://github.com/folio-org/folio-custom-fields). -- [ ] This module stores fields with free-form text (tags, notes, descriptions, etc.) +- [x] This module stores custom fields values +- [x] This module stores fields with free-form text (tags, notes, descriptions, etc.) - [x] This module caches personal data --- - [x] First name - [x] Last name -- [ ] Middle name -- [ ] Pseudonym / Alias / Nickname / Username / User ID +- [x] Middle name +- [x] Pseudonym / Alias / Nickname / Username / User ID - [ ] Gender -- [ ] Date of birth +- [x] Date of birth - [ ] Place of birth - [ ] Racial or ethnic origin -- [ ] Address +- [x] Address - [ ] Location information -- [ ] Phone numbers +- [x] Phone numbers - [ ] Passport number / National identification numbers - [ ] Driver’s license number - [ ] Social security number -- [ ] Email address +- [x] Email address - [ ] Web cookies - [ ] IP address - [ ] Geolocation data diff --git a/mod-audit-server/src/main/java/org/folio/dao/user/UserAuditEntity.java b/mod-audit-server/src/main/java/org/folio/dao/user/UserAuditEntity.java new file mode 100644 index 00000000..84621f14 --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/dao/user/UserAuditEntity.java @@ -0,0 +1,8 @@ +package org.folio.dao.user; + +import java.sql.Timestamp; +import java.util.UUID; +import org.folio.domain.diff.ChangeRecordDto; + +public record UserAuditEntity(UUID eventId, Timestamp eventDate, UUID userId, + String action, UUID performedBy, ChangeRecordDto diff) { } diff --git a/mod-audit-server/src/main/java/org/folio/dao/user/UserEventDao.java b/mod-audit-server/src/main/java/org/folio/dao/user/UserEventDao.java new file mode 100644 index 00000000..3a126f4b --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/dao/user/UserEventDao.java @@ -0,0 +1,15 @@ +package org.folio.dao.user; + +import io.vertx.core.Future; +import io.vertx.sqlclient.Row; +import io.vertx.sqlclient.RowSet; +import java.util.UUID; + +public interface UserEventDao { + + Future> save(UserAuditEntity userAuditEntity, String tenantId); + + Future deleteByUserId(UUID userId, String tenantId); + + String tableName(); +} diff --git a/mod-audit-server/src/main/java/org/folio/dao/user/impl/UserEventDaoImpl.java b/mod-audit-server/src/main/java/org/folio/dao/user/impl/UserEventDaoImpl.java new file mode 100644 index 00000000..9351f566 --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/dao/user/impl/UserEventDaoImpl.java @@ -0,0 +1,87 @@ +package org.folio.dao.user.impl; + +import static org.folio.util.DbUtils.formatDBTableName; + +import io.vertx.core.Future; +import io.vertx.core.Promise; +import io.vertx.core.json.JsonObject; +import io.vertx.sqlclient.Row; +import io.vertx.sqlclient.RowSet; +import io.vertx.sqlclient.Tuple; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.UUID; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.folio.dao.user.UserAuditEntity; +import org.folio.dao.user.UserEventDao; +import org.folio.util.PostgresClientFactory; +import org.springframework.stereotype.Repository; + +@Repository +public class UserEventDaoImpl implements UserEventDao { + + private static final Logger LOGGER = LogManager.getLogger(); + private static final String USER_AUDIT_TABLE = "user_audit"; + + private static final String INSERT_SQL = """ + INSERT INTO %s (event_id, event_date, user_id, action, performed_by, diff) + VALUES ($1, $2, $3, $4, $5, $6) + """; + + private static final String DELETE_BY_USER_ID_SQL = """ + DELETE FROM %s + WHERE user_id = $1 + """; + + private final PostgresClientFactory pgClientFactory; + + public UserEventDaoImpl(PostgresClientFactory pgClientFactory) { + this.pgClientFactory = pgClientFactory; + } + + @Override + public Future> save(UserAuditEntity event, String tenantId) { + LOGGER.debug("save:: Trying to save UserAuditEntity with [tenantId: {}, eventId: {}, userId: {}]", + tenantId, event.eventId(), event.userId()); + var promise = Promise.>promise(); + var table = formatDBTableName(tenantId, tableName()); + var query = INSERT_SQL.formatted(table); + makeSaveCall(promise, query, event, tenantId); + return promise.future(); + } + + @Override + public Future deleteByUserId(UUID userId, String tenantId) { + LOGGER.debug("deleteByUserId:: Deleting user audit records with [tenantId: {}, userId: {}]", + tenantId, userId); + var table = formatDBTableName(tenantId, tableName()); + var query = DELETE_BY_USER_ID_SQL.formatted(table); + return pgClientFactory.createInstance(tenantId).execute(query, Tuple.of(userId)) + .mapEmpty(); + } + + @Override + public String tableName() { + return USER_AUDIT_TABLE; + } + + private void makeSaveCall(Promise> promise, String query, UserAuditEntity event, String tenantId) { + LOGGER.debug("makeSaveCall:: Making save call with query : {} and tenant id : {}", query, tenantId); + try { + pgClientFactory.createInstance(tenantId).execute(query, Tuple.of(event.eventId(), + LocalDateTime.ofInstant(event.eventDate().toInstant(), ZoneId.systemDefault()), + event.userId(), + event.action(), + event.performedBy(), + event.diff() != null ? JsonObject.mapFrom(event.diff()) : null), + promise); + LOGGER.info("makeSaveCall:: Saving UserAuditEntity with [tenantId: {}, eventId:{}, userId:{}]", + tenantId, event.eventId(), event.userId()); + } catch (Exception e) { + LOGGER.error("Failed to save record with [eventId:{}, userId:{}, tableName: {}]", + event.eventId(), event.userId(), tableName(), e); + promise.fail(e); + } + } +} diff --git a/mod-audit-server/src/main/java/org/folio/mapper/user/UserEventToEntityMapper.java b/mod-audit-server/src/main/java/org/folio/mapper/user/UserEventToEntityMapper.java new file mode 100644 index 00000000..4bd3566e --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/mapper/user/UserEventToEntityMapper.java @@ -0,0 +1,43 @@ +package org.folio.mapper.user; + +import java.sql.Timestamp; +import java.util.UUID; +import java.util.function.Function; +import org.folio.dao.user.UserAuditEntity; +import org.folio.domain.diff.ChangeRecordDto; +import org.folio.services.diff.user.UserDiffCalculator; +import org.folio.util.user.UserEvent; +import org.folio.util.user.UserEventType; +import org.folio.util.user.UserUtils; +import org.springframework.stereotype.Component; + +@Component +public class UserEventToEntityMapper implements Function { + + private final UserDiffCalculator userDiffCalculator; + + public UserEventToEntityMapper(UserDiffCalculator userDiffCalculator) { + this.userDiffCalculator = userDiffCalculator; + } + + @Override + public UserAuditEntity apply(UserEvent event) { + var performedByStr = UserUtils.extractPerformedBy(event); + var performedBy = performedByStr != null ? UUID.fromString(performedByStr) : null; + var diff = UserEventType.UPDATED.equals(event.getType()) + ? getDiff(event) + : null; + return new UserAuditEntity( + UUID.fromString(event.getId()), + new Timestamp(event.getTimestamp()), + UUID.fromString(event.getUserId()), + event.getType().name(), + performedBy, + diff + ); + } + + private ChangeRecordDto getDiff(UserEvent event) { + return userDiffCalculator.calculateDiff(event.getOldValue(), event.getNewValue()); + } +} diff --git a/mod-audit-server/src/main/java/org/folio/rest/impl/InitAPIs.java b/mod-audit-server/src/main/java/org/folio/rest/impl/InitAPIs.java index 961cc463..66b89b56 100644 --- a/mod-audit-server/src/main/java/org/folio/rest/impl/InitAPIs.java +++ b/mod-audit-server/src/main/java/org/folio/rest/impl/InitAPIs.java @@ -26,6 +26,7 @@ import org.folio.verticle.inventory.InstanceConsumersVerticle; import org.folio.verticle.inventory.ItemConsumersVerticle; import org.folio.verticle.marc.MarcRecordEventConsumersVerticle; +import org.folio.verticle.user.UserConsumersVerticle; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.support.AbstractApplicationContext; @@ -85,6 +86,11 @@ public class InitAPIs implements InitAPI { @Value("${src.source-records.kafka.consumer.pool.size:5}") private int srsSourceRecordsConsumerPoolSize; + @Value("${user.users.kafka.consumer.instancesNumber:1}") + private int userConsumerInstancesNumber; + @Value("${user.users.kafka.consumer.pool.size:5}") + private int userConsumerPoolSize; + @Override public void init(Vertx vertx, Context context, Handler> handler) { LOGGER.debug("init:: InitAPI starting..."); @@ -122,6 +128,7 @@ private Future deployConsumersVerticles(Vertx vertx) { Promise inventoryHoldingsConsumer = Promise.promise(); Promise inventoryItemConsumer = Promise.promise(); Promise sourceRecordsConsumer = Promise.promise(); + Promise userEventsConsumer = Promise.promise(); deployVerticle(vertx, verticleFactory, OrderEventConsumersVerticle.class, acqOrderConsumerInstancesNumber, acqOrderConsumerPoolSize, orderEventsConsumer); deployVerticle(vertx, verticleFactory, OrderLineEventConsumersVerticle.class, acqOrderLineConsumerInstancesNumber, acqOrderLineConsumerPoolSize, orderLineEventsConsumer); @@ -133,6 +140,7 @@ private Future deployConsumersVerticles(Vertx vertx) { deployVerticle(vertx, verticleFactory, HoldingsConsumersVerticle.class, invHoldingsConsumerInstancesNumber, invHoldingsConsumerPoolSize, inventoryHoldingsConsumer); deployVerticle(vertx, verticleFactory, ItemConsumersVerticle.class, invItemConsumerInstancesNumber, invItemConsumerPoolSize, inventoryItemConsumer); deployVerticle(vertx, verticleFactory, MarcRecordEventConsumersVerticle.class, srsSourceRecordsConsumerInstancesNumber, srsSourceRecordsConsumerPoolSize, sourceRecordsConsumer); + deployVerticle(vertx, verticleFactory, UserConsumersVerticle.class, userConsumerInstancesNumber, userConsumerPoolSize, userEventsConsumer); LOGGER.info("deployConsumersVerticles:: All consumer verticles were successfully deployed"); return GenericCompositeFuture.all(Arrays.asList( @@ -145,7 +153,8 @@ private Future deployConsumersVerticles(Vertx vertx) { inventoryInstanceConsumer.future(), inventoryHoldingsConsumer.future(), inventoryItemConsumer.future(), - sourceRecordsConsumer.future() + sourceRecordsConsumer.future(), + userEventsConsumer.future() )); } diff --git a/mod-audit-server/src/main/java/org/folio/services/diff/DiffCalculator.java b/mod-audit-server/src/main/java/org/folio/services/diff/DiffCalculator.java index 08183798..25a5fac5 100644 --- a/mod-audit-server/src/main/java/org/folio/services/diff/DiffCalculator.java +++ b/mod-audit-server/src/main/java/org/folio/services/diff/DiffCalculator.java @@ -26,6 +26,11 @@ import org.javers.core.diff.changetype.container.ElementValueChange; import org.javers.core.diff.changetype.container.ValueAddOrRemove; import org.javers.core.diff.changetype.container.ValueAdded; +import org.javers.core.diff.changetype.map.EntryAdded; +import org.javers.core.diff.changetype.map.EntryChange; +import org.javers.core.diff.changetype.map.EntryRemoved; +import org.javers.core.diff.changetype.map.EntryValueChange; +import org.javers.core.diff.changetype.map.MapChange; import org.javers.core.metamodel.object.ValueObjectId; public abstract class DiffCalculator { @@ -71,6 +76,10 @@ private ChangeRecordDto convert(Changes changes) { fieldChanges.add(processValueChange(valueChange)); } else if (change instanceof CollectionChange collectionChange) { collectionChanges.add(processCollectionChange(collectionChange, groupedChanges)); + // MapChanges are skipped by default to filter out noise from JSON schema additionalProperties. + // Subclasses opt in for map properties that carry real data (see UserDiffCalculator). + } else if (change instanceof MapChange mapChange && shouldProcessMapChange(mapChange)) { + fieldChanges.addAll(processMapChange(mapChange)); } } @@ -129,6 +138,48 @@ private CollectionItemChangeDto processElementValueChange(ElementValueChange ele ); } + /** + * Determines whether a {@link MapChange} should be processed and included in the diff output. + * + *

JSON schema-generated models include an {@code additionalProperties} catch-all map that + * Javers treats as a real property, producing spurious {@link MapChange} events for every entity. + * The default implementation returns {@code false} to suppress these. + * + *

Subclasses should override this when the entity has map-type properties that carry real + * semantic data (e.g., {@code CustomFields} in the User model, whose values are stored in the + * underlying {@code additionalProperties} map). + * + * @param mapChange the map change reported by Javers + * @return {@code true} to include this change in the diff, {@code false} to skip it + */ + protected boolean shouldProcessMapChange(MapChange mapChange) { + return false; + } + + private List processMapChange(MapChange mapChange) { + var result = new ArrayList(); + String basePath = mapChange.getPropertyNameWithPath(); + String propName = mapChange.getPropertyName(); + String prefix = basePath.endsWith(propName) + ? basePath.substring(0, basePath.length() - propName.length()) + : ""; + + for (var entry : mapChange.getEntryChanges()) { + EntryChange entryChange = (EntryChange) entry; + String key = String.valueOf(entryChange.getKey()); + String fullPath = prefix + key; + + if (entryChange instanceof EntryValueChange evc) { + result.add(FieldChangeDto.modified(key, fullPath, evc.getLeftValue(), evc.getRightValue())); + } else if (entryChange instanceof EntryAdded ea) { + result.add(FieldChangeDto.added(key, fullPath, ea.getValue())); + } else if (entryChange instanceof EntryRemoved er) { + result.add(FieldChangeDto.removed(key, fullPath, er.getValue())); + } + } + return result; + } + private FieldChangeDto processValueChange(ValueChange valueChange) { return FieldChangeDto.of( valueChange.getPropertyName(), diff --git a/mod-audit-server/src/main/java/org/folio/services/diff/user/UserDiffCalculator.java b/mod-audit-server/src/main/java/org/folio/services/diff/user/UserDiffCalculator.java new file mode 100644 index 00000000..13cd8cfd --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/services/diff/user/UserDiffCalculator.java @@ -0,0 +1,44 @@ +package org.folio.services.diff.user; + +import java.util.function.Supplier; +import org.folio.rest.external.CustomFields; +import org.javers.core.diff.changetype.map.MapChange; +import org.folio.rest.external.Metadata; +import org.folio.rest.external.Personal__1; +import org.folio.rest.external.Tags__3; +import org.folio.rest.external.User; +import org.folio.services.diff.DiffCalculator; +import org.springframework.stereotype.Component; + +@Component +public class UserDiffCalculator extends DiffCalculator { + + @Override + protected Supplier access(User value) { + return () -> { + if (value.getPersonal() == null) { + value.setPersonal(new Personal__1()); + } + if (value.getMetadata() == null) { + value.setMetadata(new Metadata()); + } + if (value.getTags() == null) { + value.setTags(new Tags__3()); + } + if (value.getCustomFields() == null) { + value.setCustomFields(new CustomFields()); + } + return value; + }; + } + + @Override + protected boolean shouldProcessMapChange(MapChange mapChange) { + return mapChange.getPropertyNameWithPath().startsWith("customFields."); + } + + @Override + protected Class getType() { + return User.class; + } +} diff --git a/mod-audit-server/src/main/java/org/folio/services/user/UserEventService.java b/mod-audit-server/src/main/java/org/folio/services/user/UserEventService.java new file mode 100644 index 00000000..d5c5a720 --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/services/user/UserEventService.java @@ -0,0 +1,9 @@ +package org.folio.services.user; + +import io.vertx.core.Future; +import org.folio.util.user.UserEvent; + +public interface UserEventService { + + Future processEvent(UserEvent event, String tenantId); +} diff --git a/mod-audit-server/src/main/java/org/folio/services/user/impl/UserEventServiceImpl.java b/mod-audit-server/src/main/java/org/folio/services/user/impl/UserEventServiceImpl.java new file mode 100644 index 00000000..7a02295f --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/services/user/impl/UserEventServiceImpl.java @@ -0,0 +1,88 @@ +package org.folio.services.user.impl; + +import static org.folio.util.ErrorUtils.handleFailures; + +import io.vertx.core.Future; +import java.util.UUID; +import java.util.function.Function; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.folio.dao.user.UserAuditEntity; +import org.folio.dao.user.UserEventDao; +import org.folio.services.configuration.ConfigurationService; +import org.folio.services.configuration.Setting; +import org.folio.services.user.UserEventService; +import org.folio.util.user.UserEvent; +import org.folio.util.user.UserEventType; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Service; + +@Service +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +public class UserEventServiceImpl implements UserEventService { + + private static final Logger LOGGER = LogManager.getLogger(); + + private final Function eventToEntityMapper; + private final ConfigurationService configurationService; + private final UserEventDao userEventDao; + + public UserEventServiceImpl(Function eventToEntityMapper, + ConfigurationService configurationService, + UserEventDao userEventDao) { + this.eventToEntityMapper = eventToEntityMapper; + this.configurationService = configurationService; + this.userEventDao = userEventDao; + } + + @Override + public Future processEvent(UserEvent event, String tenantId) { + LOGGER.debug("processEvent:: Trying to process UserEvent with [tenantId: {}, eventId: {}, userId: {}]", + tenantId, event.getId(), event.getUserId()); + + return configurationService.getSetting(Setting.USER_RECORDS_ENABLED, tenantId) + .compose(setting -> { + if (!Boolean.TRUE.equals(setting.getValue())) { + LOGGER.debug("processEvent:: User audit is disabled for tenant [tenantId: {}]", tenantId); + return Future.succeededFuture(event.getId()); + } + return process(event, tenantId); + }) + .recover(throwable -> { + LOGGER.error("processEvent:: Could not process UserEvent for [tenantId: {}, eventId: {}, userId: {}]", + tenantId, event.getId(), event.getUserId(), throwable); + return handleFailures(throwable, event.getId()); + }); + } + + private Future process(UserEvent event, String tenantId) { + if (UserEventType.DELETED.equals(event.getType())) { + return deleteAll(event, tenantId); + } + return save(event, tenantId); + } + + private Future save(UserEvent event, String tenantId) { + var eventId = event.getId(); + LOGGER.debug("save:: Trying to save UserEvent with [tenantId: {}, eventId: {}, userId: {}]", + tenantId, eventId, event.getUserId()); + + var entity = eventToEntityMapper.apply(event); + if (UserEventType.UPDATED.equals(event.getType()) && entity.diff() == null) { + LOGGER.debug("save:: No diff calculated for UserEvent with [tenantId: {}, eventId: {}, userId: {}]", + tenantId, eventId, event.getUserId()); + return Future.succeededFuture(eventId); + } + + return userEventDao.save(entity, tenantId).map(eventId); + } + + private Future deleteAll(UserEvent event, String tenantId) { + var userId = UUID.fromString(event.getUserId()); + LOGGER.debug("deleteAll:: Trying to delete all user audit records with [tenantId: {}, userId: {}]", + tenantId, userId); + return userEventDao.deleteByUserId(userId, tenantId) + .map(event.getId()); + } +} diff --git a/mod-audit-server/src/main/java/org/folio/util/user/UserEvent.java b/mod-audit-server/src/main/java/org/folio/util/user/UserEvent.java new file mode 100644 index 00000000..7f44af9c --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/util/user/UserEvent.java @@ -0,0 +1,34 @@ +package org.folio.util.user; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSetter; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class UserEvent { + + private String id; + private UserEventType type; + private String tenant; + private Long timestamp; + private String userId; + private Map oldValue; + private Map newValue; + + @JsonSetter("data") + @SuppressWarnings("unchecked") + public void setData(Map data) { + if (data != null) { + this.oldValue = (Map) data.get("old"); + this.newValue = (Map) data.get("new"); + } + } +} diff --git a/mod-audit-server/src/main/java/org/folio/util/user/UserEventType.java b/mod-audit-server/src/main/java/org/folio/util/user/UserEventType.java new file mode 100644 index 00000000..c1c56f4e --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/util/user/UserEventType.java @@ -0,0 +1,38 @@ +package org.folio.util.user; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public enum UserEventType { + + CREATED("CREATED"), + UPDATED("UPDATED"), + DELETED("DELETED"), + UNKNOWN("UNKNOWN"); + + private final String value; + + UserEventType(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @Override + public String toString() { + return String.valueOf(value); + } + + @JsonCreator + public static UserEventType fromValue(String value) { + for (var eventType : UserEventType.values()) { + if (eventType.value.equals(value)) { + return eventType; + } + } + return UNKNOWN; + } +} diff --git a/mod-audit-server/src/main/java/org/folio/util/user/UserKafkaEvent.java b/mod-audit-server/src/main/java/org/folio/util/user/UserKafkaEvent.java new file mode 100644 index 00000000..57d8a62c --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/util/user/UserKafkaEvent.java @@ -0,0 +1,20 @@ +package org.folio.util.user; + +public enum UserKafkaEvent { + USER("users"); + + private static final String TOPIC_GROUP = "users"; + private final String topicName; + + UserKafkaEvent(String value) { + this.topicName = value; + } + + public String getTopicName() { + return TOPIC_GROUP + "." + topicName; + } + + public String getTopicPattern() { + return TOPIC_GROUP + "\\." + topicName; + } +} diff --git a/mod-audit-server/src/main/java/org/folio/util/user/UserUtils.java b/mod-audit-server/src/main/java/org/folio/util/user/UserUtils.java new file mode 100644 index 00000000..94e6fe36 --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/util/user/UserUtils.java @@ -0,0 +1,18 @@ +package org.folio.util.user; + +import lombok.experimental.UtilityClass; +import org.folio.util.KafkaUtils; +import org.folio.util.PayloadUtils; + +@UtilityClass +public class UserUtils { + + public static String extractPerformedBy(UserEvent event) { + var payload = event.getNewValue() != null ? event.getNewValue() : event.getOldValue(); + return PayloadUtils.extractPerformedByUserId(payload); + } + + public static String formatUserTopicPattern(String env, UserKafkaEvent eventType) { + return KafkaUtils.formatTopicPattern(env, eventType.getTopicPattern()); + } +} diff --git a/mod-audit-server/src/main/java/org/folio/verticle/user/UserConsumersVerticle.java b/mod-audit-server/src/main/java/org/folio/verticle/user/UserConsumersVerticle.java new file mode 100644 index 00000000..870e073f --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/verticle/user/UserConsumersVerticle.java @@ -0,0 +1,41 @@ +package org.folio.verticle.user; + +import static org.folio.util.user.UserUtils.formatUserTopicPattern; + +import java.util.List; +import org.folio.kafka.AsyncRecordHandler; +import org.folio.kafka.KafkaConfig; +import org.folio.kafka.SubscriptionDefinition; +import org.folio.util.user.UserKafkaEvent; +import org.folio.verticle.AbstractConsumersVerticle; +import org.folio.verticle.user.consumers.UserEventHandler; +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.stereotype.Component; + +@Component +public class UserConsumersVerticle extends AbstractConsumersVerticle { + + private final ObjectFactory recordHandlerProvider; + + public UserConsumersVerticle(ObjectFactory recordHandlerProvider) { + this.recordHandlerProvider = recordHandlerProvider; + } + + @Override + protected SubscriptionDefinition subscriptionDefinition(String event, KafkaConfig kafkaConfig) { + return SubscriptionDefinition.builder() + .eventType(event) + .subscriptionPattern(formatUserTopicPattern(kafkaConfig.getEnvId(), UserKafkaEvent.USER)) + .build(); + } + + @Override + public List getEvents() { + return List.of(UserKafkaEvent.USER.getTopicName()); + } + + @Override + public AsyncRecordHandler getHandler() { + return recordHandlerProvider.getObject(); + } +} diff --git a/mod-audit-server/src/main/java/org/folio/verticle/user/consumers/UserEventHandler.java b/mod-audit-server/src/main/java/org/folio/verticle/user/consumers/UserEventHandler.java new file mode 100644 index 00000000..ba4ffa83 --- /dev/null +++ b/mod-audit-server/src/main/java/org/folio/verticle/user/consumers/UserEventHandler.java @@ -0,0 +1,68 @@ +package org.folio.verticle.user.consumers; + +import static org.folio.util.user.UserEventType.UNKNOWN; + +import io.vertx.core.Future; +import io.vertx.core.Promise; +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonObject; +import io.vertx.kafka.client.consumer.KafkaConsumerRecord; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.folio.kafka.AsyncRecordHandler; +import org.folio.kafka.KafkaHeaderUtils; +import org.folio.kafka.exception.DuplicateEventException; +import org.folio.rest.util.OkapiConnectionParams; +import org.folio.services.user.UserEventService; +import org.folio.util.user.UserEvent; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + +@Component +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +public class UserEventHandler implements AsyncRecordHandler { + + private static final Logger LOGGER = LogManager.getLogger(); + + private final UserEventService userEventService; + private final Vertx vertx; + + public UserEventHandler(Vertx vertx, UserEventService userEventService) { + this.vertx = vertx; + this.userEventService = userEventService; + } + + @Override + public Future handle(KafkaConsumerRecord kafkaConsumerRecord) { + var result = Promise.promise(); + var kafkaHeaders = kafkaConsumerRecord.headers(); + var okapiConnectionParams = new OkapiConnectionParams(KafkaHeaderUtils.kafkaHeadersToMap(kafkaHeaders), vertx); + var event = new JsonObject(kafkaConsumerRecord.value()).mapTo(UserEvent.class); + event.setUserId(kafkaConsumerRecord.key()); + + if (UNKNOWN == event.getType()) { + LOGGER.debug("handle:: Event type not supported [eventId: {}, userId: {}]", + event.getId(), event.getUserId()); + result.complete(event.getId()); + return result.future(); + } + + LOGGER.info("handle:: Starting processing of User event with id: {} for user id: {}", event.getId(), event.getUserId()); + userEventService.processEvent(event, okapiConnectionParams.getTenantId()) + .onSuccess(ar -> { + LOGGER.info("handle:: User event with id: {} has been processed for user id: {}", event.getId(), event.getUserId()); + result.complete(event.getId()); + }) + .onFailure(e -> { + if (e instanceof DuplicateEventException) { + LOGGER.info("handle:: Duplicate User event with id: {} for user id: {} received, skipped processing", event.getId(), event.getUserId()); + result.complete(event.getId()); + } else { + LOGGER.error("Processing of User event with id: {} for user id: {} has been failed", event.getId(), event.getUserId(), e); + result.fail(e); + } + }); + return result.future(); + } +} diff --git a/mod-audit-server/src/main/resources/templates/db_scripts/schema.json b/mod-audit-server/src/main/resources/templates/db_scripts/schema.json index ca335af2..853ba256 100644 --- a/mod-audit-server/src/main/resources/templates/db_scripts/schema.json +++ b/mod-audit-server/src/main/resources/templates/db_scripts/schema.json @@ -144,6 +144,11 @@ "run": "after", "snippetPath": "acquisition/update_to_orders_storage_14.0.ftl", "fromModuleVersion": "mod-audit-3.0.0" + }, + { + "run": "after", + "snippetPath": "user/create_user_audit_table.sql", + "fromModuleVersion": "mod-audit-3.0.0" } ] } diff --git a/mod-audit-server/src/main/resources/templates/db_scripts/user/create_user_audit_table.sql b/mod-audit-server/src/main/resources/templates/db_scripts/user/create_user_audit_table.sql new file mode 100644 index 00000000..d3e258fe --- /dev/null +++ b/mod-audit-server/src/main/resources/templates/db_scripts/user/create_user_audit_table.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS user_audit ( + event_id UUID PRIMARY KEY, + event_date TIMESTAMP NOT NULL, + user_id UUID NOT NULL, + action VARCHAR NOT NULL, + performed_by UUID, + diff JSONB +); +CREATE INDEX IF NOT EXISTS idx_user_audit_user_id_event_date ON user_audit USING BTREE (user_id, event_date DESC); +CREATE INDEX IF NOT EXISTS idx_user_audit_event_date ON user_audit USING BTREE (event_date); diff --git a/mod-audit-server/src/test/java/org/folio/dao/user/impl/UserEventDaoImplTest.java b/mod-audit-server/src/test/java/org/folio/dao/user/impl/UserEventDaoImplTest.java new file mode 100644 index 00000000..4cfc2192 --- /dev/null +++ b/mod-audit-server/src/test/java/org/folio/dao/user/impl/UserEventDaoImplTest.java @@ -0,0 +1,96 @@ +package org.folio.dao.user.impl; + +import static org.folio.utils.EntityUtils.TENANT_ID; +import static org.folio.utils.EntityUtils.createUserAuditEntity; +import static org.folio.utils.MockUtils.mockPostgresExecutionSuccess; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import io.vertx.core.Future; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; +import io.vertx.sqlclient.Tuple; +import org.folio.rest.persist.PostgresClient; +import org.folio.util.PostgresClientFactory; +import org.folio.utils.UnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@UnitTest +@ExtendWith({VertxExtension.class, MockitoExtension.class}) +class UserEventDaoImplTest { + + @Mock + PostgresClientFactory postgresClientFactory; + @Mock + PostgresClient postgresClient; + @InjectMocks + UserEventDaoImpl userEventDao; + + @BeforeEach + void setUp() { + lenient().when(postgresClientFactory.createInstance(TENANT_ID)).thenReturn(postgresClient); + mockPostgresExecutionSuccess(2).when(postgresClient).execute(anyString(), any(Tuple.class), any()); + } + + @Test + void shouldSaveSuccessfully(VertxTestContext ctx) { + var entity = createUserAuditEntity(); + + userEventDao.save(entity, TENANT_ID) + .onComplete(ctx.succeeding(result -> ctx.completeNow())); + + verify(postgresClientFactory, times(1)).createInstance(TENANT_ID); + } + + @Test + void shouldHandleExceptionOnSave(VertxTestContext ctx) { + var entity = createUserAuditEntity(); + + mockPostgresExecutionSuccess(2) + .doThrow(new IllegalStateException("Error")) + .when(postgresClient).execute(anyString(), any(Tuple.class), any()); + + userEventDao.save(entity, TENANT_ID) + .onComplete(ctx.succeeding(result -> + userEventDao.save(entity, TENANT_ID) + .onComplete(re -> { + assertTrue(re.failed()); + assertInstanceOf(IllegalStateException.class, re.cause()); + assertEquals("Error", re.cause().getMessage()); + ctx.completeNow(); + }) + )); + verify(postgresClientFactory, times(2)).createInstance(TENANT_ID); + } + + @Test + void shouldDeleteByUserId(VertxTestContext ctx) { + var userId = createUserAuditEntity().userId(); + + doReturn(Future.succeededFuture()) + .when(postgresClient).execute(anyString(), any(Tuple.class)); + + userEventDao.deleteByUserId(userId, TENANT_ID) + .onComplete(ctx.succeeding(result -> { + verify(postgresClient, times(1)).execute(anyString(), any(Tuple.class)); + ctx.completeNow(); + })); + } + + @Test + void shouldReturnCorrectTableName() { + assertEquals("user_audit", userEventDao.tableName()); + } +} diff --git a/mod-audit-server/src/test/java/org/folio/mapper/user/UserEventToEntityMapperTest.java b/mod-audit-server/src/test/java/org/folio/mapper/user/UserEventToEntityMapperTest.java new file mode 100644 index 00000000..5fac70f5 --- /dev/null +++ b/mod-audit-server/src/test/java/org/folio/mapper/user/UserEventToEntityMapperTest.java @@ -0,0 +1,94 @@ +package org.folio.mapper.user; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.sql.Timestamp; +import java.util.Map; +import java.util.UUID; +import org.folio.services.diff.user.UserDiffCalculator; +import org.folio.util.user.UserEvent; +import org.folio.util.user.UserEventType; +import org.folio.utils.UnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +@UnitTest +class UserEventToEntityMapperTest { + + private UserEventToEntityMapper mapper; + + @BeforeEach + void setUp() { + mapper = new UserEventToEntityMapper(new UserDiffCalculator()); + } + + @Test + void shouldMapUpdatedEventWithDiff() { + var event = createUserEvent(UserEventType.UPDATED); + event.setOldValue(Map.of("username", "oldUser", "id", "123")); + event.setNewValue(Map.of("username", "newUser", "id", "123", + "metadata", Map.of("updatedByUserId", UUID.randomUUID().toString()))); + + var result = mapper.apply(event); + + assertEquals(UUID.fromString(event.getId()), result.eventId()); + assertEquals(new Timestamp(event.getTimestamp()), result.eventDate()); + assertEquals(UUID.fromString(event.getUserId()), result.userId()); + assertEquals(UserEventType.UPDATED.name(), result.action()); + assertNotNull(result.diff()); + assertNotNull(result.performedBy()); + } + + @EnumSource(value = UserEventType.class, names = {"CREATED", "DELETED"}) + @ParameterizedTest + void shouldMapCreateAndDeleteWithNullDiff(UserEventType eventType) { + var event = createUserEvent(eventType); + + var result = mapper.apply(event); + + assertEquals(UUID.fromString(event.getId()), result.eventId()); + assertEquals(new Timestamp(event.getTimestamp()), result.eventDate()); + assertEquals(UUID.fromString(event.getUserId()), result.userId()); + assertEquals(eventType.name(), result.action()); + assertNull(result.diff()); + } + + @Test + void shouldMapUpdateEventWithNullDiffIfNoChanges() { + var event = createUserEvent(UserEventType.UPDATED); + var sameData = Map.of("username", "sameUser", "id", "123"); + event.setOldValue(sameData); + event.setNewValue(sameData); + + var result = mapper.apply(event); + + assertEquals(UserEventType.UPDATED.name(), result.action()); + assertNull(result.diff()); + } + + @Test + void shouldHandleNullPerformedBy() { + var event = createUserEvent(UserEventType.CREATED); + event.setNewValue(Map.of("username", "testuser")); + + var result = mapper.apply(event); + + assertNull(result.performedBy()); + } + + private UserEvent createUserEvent(UserEventType type) { + return UserEvent.builder() + .id(UUID.randomUUID().toString()) + .type(type) + .tenant("diku") + .timestamp(System.currentTimeMillis()) + .userId(UUID.randomUUID().toString()) + .newValue(Map.of("key", "value")) + .oldValue(Map.of("key", "oldValue")) + .build(); + } +} diff --git a/mod-audit-server/src/test/java/org/folio/services/diff/DiffCalculatorTest.java b/mod-audit-server/src/test/java/org/folio/services/diff/DiffCalculatorTest.java index 6ec29ac8..3b33d99e 100644 --- a/mod-audit-server/src/test/java/org/folio/services/diff/DiffCalculatorTest.java +++ b/mod-audit-server/src/test/java/org/folio/services/diff/DiffCalculatorTest.java @@ -283,6 +283,18 @@ void shouldDetectFieldChangesInInnerObjectCollectionAndInnerObject() { ); } + @Test + void shouldIgnoreAdditionalProperties() { + var oldInstance = getMap(new Instance().withId("1").withAdditionalProperty("junk", "old")); + var newInstance = getMap(new Instance().withId("1").withAdditionalProperty("junk", "new")); + + var diff = diffCalculator.calculateDiff(oldInstance, newInstance); + + assertThat(diff) + .as("Changes to additionalProperties should be ignored by default") + .isNull(); + } + private static Map getMap(Instance obj) { return new JsonObject(Json.encode(obj)).getMap(); } diff --git a/mod-audit-server/src/test/java/org/folio/services/diff/user/UserDiffCalculatorTest.java b/mod-audit-server/src/test/java/org/folio/services/diff/user/UserDiffCalculatorTest.java new file mode 100644 index 00000000..d10e6007 --- /dev/null +++ b/mod-audit-server/src/test/java/org/folio/services/diff/user/UserDiffCalculatorTest.java @@ -0,0 +1,161 @@ +package org.folio.services.diff.user; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.vertx.core.json.Json; +import io.vertx.core.json.JsonObject; +import java.util.Map; +import org.folio.domain.diff.FieldChangeDto; +import org.folio.rest.external.CustomFields; +import org.folio.rest.external.Personal__1; +import org.folio.rest.external.User; +import org.folio.utils.UnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@UnitTest +class UserDiffCalculatorTest { + + private UserDiffCalculator userDiffCalculator; + + @BeforeEach + void setUp() { + userDiffCalculator = new UserDiffCalculator(); + } + + @Test + void shouldDetectUsernameChange() { + var oldUser = getMap(new User().withId("1").withUsername("oldUser")); + var newUser = getMap(new User().withId("1").withUsername("newUser")); + + var diff = userDiffCalculator.calculateDiff(oldUser, newUser); + + assertThat(diff.getFieldChanges()) + .hasSize(1) + .containsExactly(FieldChangeDto.modified("username", "username", "oldUser", "newUser")); + } + + @Test + void shouldDetectPersonalInfoChange() { + var oldUser = getMap(new User().withId("1").withPersonal(new Personal__1().withFirstName("John"))); + var newUser = getMap(new User().withId("1").withPersonal(new Personal__1().withFirstName("Jane"))); + + var diff = userDiffCalculator.calculateDiff(oldUser, newUser); + + assertThat(diff.getFieldChanges()) + .hasSize(1) + .containsExactly(FieldChangeDto.modified("firstName", "personal.firstName", "John", "Jane")); + } + + @Test + void shouldHandleNullNestedObjects() { + var oldUser = getMap(new User().withId("1")); + var newUser = getMap(new User().withId("1").withUsername("newUser")); + + var diff = userDiffCalculator.calculateDiff(oldUser, newUser); + + assertThat(diff.getFieldChanges()) + .hasSize(1) + .containsExactly(FieldChangeDto.added("username", "username", "newUser")); + } + + @Test + void shouldReturnNullWhenNoDifference() { + var userData = getMap(new User().withId("1").withUsername("sameUser")); + + var diff = userDiffCalculator.calculateDiff(userData, userData); + + assertThat(diff).isNull(); + } + + @Test + void shouldDetectCustomFieldChanges() { + var oldUser = getMap(new User().withId("1") + .withCustomFields(new CustomFields().withAdditionalProperty("reasonForLife", "42"))); + var newUser = getMap(new User().withId("1") + .withCustomFields(new CustomFields().withAdditionalProperty("reasonForLife", "meaning"))); + + var diff = userDiffCalculator.calculateDiff(oldUser, newUser); + + assertThat(diff.getFieldChanges()) + .hasSize(1) + .containsExactly(FieldChangeDto.modified("reasonForLife", "customFields.reasonForLife", "42", "meaning")); + } + + @Test + void shouldDetectAddedCustomField() { + var oldUser = getMap(new User().withId("1")); + var newUser = getMap(new User().withId("1") + .withCustomFields(new CustomFields().withAdditionalProperty("reasonForLife", "42"))); + + var diff = userDiffCalculator.calculateDiff(oldUser, newUser); + + assertThat(diff.getFieldChanges()) + .hasSize(1) + .containsExactly(FieldChangeDto.added("reasonForLife", "customFields.reasonForLife", "42")); + } + + @Test + void shouldDetectRemovedCustomField() { + var oldUser = getMap(new User().withId("1") + .withCustomFields(new CustomFields().withAdditionalProperty("reasonForLife", "42"))); + var newUser = getMap(new User().withId("1")); + + var diff = userDiffCalculator.calculateDiff(oldUser, newUser); + + assertThat(diff.getFieldChanges()) + .hasSize(1) + .containsExactly(FieldChangeDto.removed("reasonForLife", "customFields.reasonForLife", "42")); + } + + @Test + void shouldDetectMultipleCustomFieldChanges() { + var oldUser = getMap(new User().withId("1") + .withCustomFields(new CustomFields() + .withAdditionalProperty("reasonForLife", "42") + .withAdditionalProperty("toBeRemoved", "gone"))); + var newUser = getMap(new User().withId("1") + .withCustomFields(new CustomFields() + .withAdditionalProperty("reasonForLife", "meaning") + .withAdditionalProperty("brandNew", "hello"))); + + var diff = userDiffCalculator.calculateDiff(oldUser, newUser); + + assertThat(diff.getFieldChanges()) + .hasSize(3) + .containsExactlyInAnyOrder( + FieldChangeDto.modified("reasonForLife", "customFields.reasonForLife", "42", "meaning"), + FieldChangeDto.removed("toBeRemoved", "customFields.toBeRemoved", "gone"), + FieldChangeDto.added("brandNew", "customFields.brandNew", "hello")); + } + + @Test + void shouldDetectCustomFieldAndRegularFieldChanges() { + var oldUser = getMap(new User().withId("1").withUsername("oldUser") + .withCustomFields(new CustomFields().withAdditionalProperty("reasonForLife", "42"))); + var newUser = getMap(new User().withId("1").withUsername("newUser") + .withCustomFields(new CustomFields().withAdditionalProperty("reasonForLife", "meaning"))); + + var diff = userDiffCalculator.calculateDiff(oldUser, newUser); + + assertThat(diff.getFieldChanges()) + .hasSize(2) + .containsExactlyInAnyOrder( + FieldChangeDto.modified("username", "username", "oldUser", "newUser"), + FieldChangeDto.modified("reasonForLife", "customFields.reasonForLife", "42", "meaning")); + } + + @Test + void shouldIgnoreAdditionalPropertiesOnUser() { + var oldUser = getMap(new User().withId("1").withAdditionalProperty("junk", "old")); + var newUser = getMap(new User().withId("1").withAdditionalProperty("junk", "new")); + + var diff = userDiffCalculator.calculateDiff(oldUser, newUser); + + assertThat(diff).isNull(); + } + + private static Map getMap(User obj) { + return new JsonObject(Json.encode(obj)).getMap(); + } +} diff --git a/mod-audit-server/src/test/java/org/folio/services/user/impl/UserEventServiceImplTest.java b/mod-audit-server/src/test/java/org/folio/services/user/impl/UserEventServiceImplTest.java new file mode 100644 index 00000000..222b4633 --- /dev/null +++ b/mod-audit-server/src/test/java/org/folio/services/user/impl/UserEventServiceImplTest.java @@ -0,0 +1,150 @@ +package org.folio.services.user.impl; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.vertx.core.Future; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; +import io.vertx.sqlclient.Row; +import io.vertx.sqlclient.RowSet; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; +import org.folio.dao.user.UserAuditEntity; +import org.folio.dao.user.UserEventDao; +import org.folio.kafka.exception.DuplicateEventException; +import org.folio.mapper.user.UserEventToEntityMapper; +import org.folio.rest.jaxrs.model.Setting; +import org.folio.services.configuration.ConfigurationService; +import org.folio.services.user.UserEventService; +import org.folio.util.user.UserEvent; +import org.folio.util.user.UserEventType; +import org.folio.utils.UnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@UnitTest +@ExtendWith({VertxExtension.class, MockitoExtension.class}) +class UserEventServiceImplTest { + + private static final String TENANT_ID = "testTenant"; + + @Mock + private RowSet rowSet; + @Mock + private UserEventToEntityMapper eventToEntityMapper; + @Mock + private ConfigurationService configurationService; + @Mock + private UserEventDao userEventDao; + + private UserEventService eventService; + + @BeforeEach + void setUp() { + eventService = new UserEventServiceImpl(eventToEntityMapper, configurationService, userEventDao); + } + + @Test + void shouldSaveCreatedEventSuccessfully(VertxTestContext ctx) { + var event = createUserEvent(UserEventType.CREATED); + mockAuditEnabled(true); + when(eventToEntityMapper.apply(event)).thenReturn( + new UserAuditEntity(UUID.randomUUID(), Timestamp.from(Instant.now()), + UUID.randomUUID(), UserEventType.CREATED.name(), null, null)); + when(userEventDao.save(any(), anyString())).thenReturn(Future.succeededFuture(rowSet)); + + eventService.processEvent(event, TENANT_ID) + .onComplete(ctx.succeeding(r -> { + verify(userEventDao, times(1)).save(any(), anyString()); + ctx.completeNow(); + })); + } + + @Test + void shouldNotProcessWhenAuditDisabled(VertxTestContext ctx) { + var event = createUserEvent(UserEventType.CREATED); + mockAuditEnabled(false); + + eventService.processEvent(event, TENANT_ID) + .onComplete(ctx.succeeding(r -> { + verify(userEventDao, never()).save(any(), anyString()); + verify(userEventDao, never()).deleteByUserId(any(), anyString()); + ctx.completeNow(); + })); + } + + @Test + void shouldDeleteAllRecordsOnDeleteEvent(VertxTestContext ctx) { + var event = createUserEvent(UserEventType.DELETED); + mockAuditEnabled(true); + when(userEventDao.deleteByUserId(any(UUID.class), anyString())).thenReturn(Future.succeededFuture()); + + eventService.processEvent(event, TENANT_ID) + .onComplete(ctx.succeeding(r -> { + verify(userEventDao, times(1)).deleteByUserId(any(UUID.class), anyString()); + verify(userEventDao, never()).save(any(), anyString()); + ctx.completeNow(); + })); + } + + @Test + void shouldNotSaveUpdateEventWhenDiffIsNull(VertxTestContext ctx) { + var event = createUserEvent(UserEventType.UPDATED); + mockAuditEnabled(true); + when(eventToEntityMapper.apply(event)).thenReturn( + new UserAuditEntity(UUID.randomUUID(), Timestamp.from(Instant.now()), + UUID.randomUUID(), UserEventType.UPDATED.name(), null, null)); + + eventService.processEvent(event, TENANT_ID) + .onComplete(ctx.succeeding(r -> { + verify(userEventDao, never()).save(any(), anyString()); + ctx.completeNow(); + })); + } + + @Test + void shouldHandleDuplicateEvent(VertxTestContext ctx) { + var event = createUserEvent(UserEventType.CREATED); + mockAuditEnabled(true); + when(eventToEntityMapper.apply(event)).thenReturn( + new UserAuditEntity(UUID.randomUUID(), Timestamp.from(Instant.now()), + UUID.randomUUID(), UserEventType.CREATED.name(), null, null)); + when(userEventDao.save(any(), anyString())).thenReturn( + Future.failedFuture(new io.vertx.pgclient.PgException("duplicate", null, "23505", null))); + + eventService.processEvent(event, TENANT_ID) + .onComplete(ctx.failing(cause -> { + assertInstanceOf(DuplicateEventException.class, cause); + ctx.completeNow(); + })); + } + + private UserEvent createUserEvent(UserEventType type) { + return UserEvent.builder() + .id(UUID.randomUUID().toString()) + .type(type) + .tenant(TENANT_ID) + .timestamp(System.currentTimeMillis()) + .userId(UUID.randomUUID().toString()) + .newValue(Map.of("key", "value")) + .oldValue(Map.of("key", "oldValue")) + .build(); + } + + private void mockAuditEnabled(boolean value) { + when(configurationService.getSetting(any(), eq(TENANT_ID))) + .thenReturn(Future.succeededFuture(new Setting().withValue(value))); + } +} diff --git a/mod-audit-server/src/test/java/org/folio/util/user/UserEventTest.java b/mod-audit-server/src/test/java/org/folio/util/user/UserEventTest.java new file mode 100644 index 00000000..02b77907 --- /dev/null +++ b/mod-audit-server/src/test/java/org/folio/util/user/UserEventTest.java @@ -0,0 +1,58 @@ +package org.folio.util.user; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Map; +import org.folio.utils.UnitTest; +import org.junit.jupiter.api.Test; + +@UnitTest +class UserEventTest { + + @Test + void shouldExtractOldAndNewFromData() { + var oldValue = Map.of("username", "oldUser"); + var newValue = Map.of("username", "newUser"); + var data = Map.of("old", oldValue, "new", newValue); + + var event = new UserEvent(); + event.setData(data); + + assertEquals(oldValue, event.getOldValue()); + assertEquals(newValue, event.getNewValue()); + } + + @Test + void shouldHandleNullData() { + var event = new UserEvent(); + event.setData(null); + + assertNull(event.getOldValue()); + assertNull(event.getNewValue()); + } + + @Test + void shouldHandleMissingOldInData() { + var newValue = Map.of("username", "newUser"); + var data = Map.of("new", newValue); + + var event = new UserEvent(); + event.setData(data); + + assertNull(event.getOldValue()); + assertEquals(newValue, event.getNewValue()); + } + + @Test + void shouldHandleMissingNewInData() { + var oldValue = Map.of("username", "oldUser"); + var data = Map.of("old", oldValue); + + var event = new UserEvent(); + event.setData(data); + + assertEquals(oldValue, event.getOldValue()); + assertNull(event.getNewValue()); + } +} diff --git a/mod-audit-server/src/test/java/org/folio/util/user/UserUtilsTest.java b/mod-audit-server/src/test/java/org/folio/util/user/UserUtilsTest.java new file mode 100644 index 00000000..ef95ab9f --- /dev/null +++ b/mod-audit-server/src/test/java/org/folio/util/user/UserUtilsTest.java @@ -0,0 +1,76 @@ +package org.folio.util.user; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Map; +import org.folio.utils.UnitTest; +import org.junit.jupiter.api.Test; + +@UnitTest +class UserUtilsTest { + + private static final String USER_ID = "550e8400-e29b-41d4-a716-446655440000"; + + @Test + void shouldExtractPerformedByFromUpdatedByUserId() { + var event = UserEvent.builder() + .newValue(Map.of("metadata", Map.of("updatedByUserId", USER_ID))) + .build(); + + assertEquals(USER_ID, UserUtils.extractPerformedBy(event)); + } + + @Test + void shouldExtractPerformedByFromCreatedByUserId() { + var event = UserEvent.builder() + .newValue(Map.of("metadata", Map.of("createdByUserId", USER_ID))) + .build(); + + assertEquals(USER_ID, UserUtils.extractPerformedBy(event)); + } + + @Test + void shouldPreferUpdatedByOverCreatedBy() { + var updatedBy = "660e8400-e29b-41d4-a716-446655440001"; + var event = UserEvent.builder() + .newValue(Map.of("metadata", Map.of("updatedByUserId", updatedBy, "createdByUserId", USER_ID))) + .build(); + + assertEquals(updatedBy, UserUtils.extractPerformedBy(event)); + } + + @Test + void shouldReturnNullWhenNoMetadata() { + var event = UserEvent.builder() + .newValue(Map.of("username", "testuser")) + .build(); + + assertNull(UserUtils.extractPerformedBy(event)); + } + + @Test + void shouldReturnNullWhenNoPayload() { + var event = UserEvent.builder().build(); + + assertNull(UserUtils.extractPerformedBy(event)); + } + + @Test + void shouldFallbackToOldValueWhenNewValueIsNull() { + var event = UserEvent.builder() + .oldValue(Map.of("metadata", Map.of("createdByUserId", USER_ID))) + .build(); + + assertEquals(USER_ID, UserUtils.extractPerformedBy(event)); + } + + @Test + void shouldFormatUserTopicPattern() { + var pattern = UserUtils.formatUserTopicPattern("folio", UserKafkaEvent.USER); + + assertNotNull(pattern); + assertEquals("(folio\\.)(.*\\.)users\\.users", pattern); + } +} diff --git a/mod-audit-server/src/test/java/org/folio/utils/EntityUtils.java b/mod-audit-server/src/test/java/org/folio/utils/EntityUtils.java index f7d6f427..dac22311 100644 --- a/mod-audit-server/src/test/java/org/folio/utils/EntityUtils.java +++ b/mod-audit-server/src/test/java/org/folio/utils/EntityUtils.java @@ -4,6 +4,7 @@ import org.folio.dao.configuration.SettingEntity; import org.folio.dao.configuration.SettingValueType; import org.folio.dao.inventory.InventoryAuditEntity; +import org.folio.dao.user.UserAuditEntity; import org.folio.dao.marc.MarcAuditEntity; import org.folio.domain.diff.ChangeRecordDto; import org.folio.domain.diff.ChangeType; @@ -17,6 +18,8 @@ import org.folio.util.inventory.InventoryEvent; import org.folio.util.inventory.InventoryEventType; import org.folio.util.inventory.InventoryResourceType; +import org.folio.util.user.UserEvent; +import org.folio.util.user.UserEventType; import org.folio.util.marc.EventMetadata; import org.folio.util.marc.MarcEventPayload; import org.folio.util.marc.Record; @@ -315,6 +318,26 @@ public static SourceRecordDomainEvent sourceRecordDomainEventWithNoDiff() { return event; } + public static UserAuditEntity createUserAuditEntity() { + var changeRecordDto = new ChangeRecordDto(); + changeRecordDto.setFieldChanges(List.of(new FieldChangeDto(ChangeType.MODIFIED, "username", "username", "old", "new"))); + + return new UserAuditEntity(UUID.randomUUID(), Timestamp.from(Instant.now()), UUID.randomUUID(), "UPDATED", + UUID.randomUUID(), changeRecordDto); + } + + public static UserEvent createUserEvent(String eventId, UserEventType type) { + return UserEvent.builder() + .id(eventId) + .type(type) + .tenant(TENANT_ID) + .timestamp(System.currentTimeMillis()) + .userId(UUID.randomUUID().toString()) + .newValue(Map.of("key", "newValue")) + .oldValue(Map.of("key", "oldValue")) + .build(); + } + public static MarcAuditEntity createMarcAuditEntity() { return new MarcAuditEntity( UUID.randomUUID().toString(), diff --git a/mod-audit-server/src/test/java/org/folio/verticle/user/consumers/UserEventHandlerTest.java b/mod-audit-server/src/test/java/org/folio/verticle/user/consumers/UserEventHandlerTest.java new file mode 100644 index 00000000..8d284a54 --- /dev/null +++ b/mod-audit-server/src/test/java/org/folio/verticle/user/consumers/UserEventHandlerTest.java @@ -0,0 +1,132 @@ +package org.folio.verticle.user.consumers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.json.Json; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; +import io.vertx.kafka.client.consumer.KafkaConsumerRecord; +import io.vertx.kafka.client.consumer.impl.KafkaConsumerRecordImpl; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.UUID; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.header.internals.RecordHeader; +import org.folio.kafka.exception.DuplicateEventException; +import org.folio.rest.util.OkapiConnectionParams; +import org.folio.services.user.UserEventService; +import org.folio.util.user.UserEvent; +import org.folio.util.user.UserEventType; +import org.folio.utils.UnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +@UnitTest +@ExtendWith({VertxExtension.class, MockitoExtension.class}) +class UserEventHandlerTest { + + private static final String TENANT_ID = "diku"; + private static final String TOKEN = "token"; + + @Spy + private Vertx vertx = Vertx.vertx(); + + @Mock + private UserEventService userEventService; + + private UserEventHandler userEventHandler; + + @BeforeEach + void setUp() { + userEventHandler = new UserEventHandler(vertx, userEventService); + } + + @Test + void shouldHandleSupportedEvent(VertxTestContext ctx) { + var event = createUserEvent(UserEventType.CREATED); + var kafkaRecord = buildKafkaConsumerRecord(event); + + when(userEventService.processEvent(any(), any())).thenReturn(Future.succeededFuture()); + + userEventHandler.handle(kafkaRecord) + .onComplete(ctx.succeeding(id -> { + assertEquals(event.getId(), id); + verify(userEventService, times(1)).processEvent(any(), any()); + ctx.completeNow(); + })); + } + + @Test + void shouldHandleDuplicateEvent(VertxTestContext ctx) { + var event = createUserEvent(UserEventType.CREATED); + var kafkaRecord = buildKafkaConsumerRecord(event); + + when(userEventService.processEvent(any(), any())) + .thenReturn(Future.failedFuture(new DuplicateEventException("Duplicate event"))); + + userEventHandler.handle(kafkaRecord) + .onComplete(ctx.succeeding(id -> { + assertEquals(event.getId(), id); + verify(userEventService, times(1)).processEvent(any(), any()); + ctx.completeNow(); + })); + } + + @Test + void shouldHandleUnsupportedEvent(VertxTestContext ctx) { + var event = createUserEvent(UserEventType.UNKNOWN); + var kafkaRecord = buildKafkaConsumerRecord(event); + + userEventHandler.handle(kafkaRecord) + .onComplete(ctx.succeeding(id -> { + assertEquals(event.getId(), id); + verify(userEventService, never()).processEvent(any(), any()); + ctx.completeNow(); + })); + } + + @Test + void shouldFailOnProcessEventError(VertxTestContext ctx) { + var event = createUserEvent(UserEventType.UPDATED); + var kafkaRecord = buildKafkaConsumerRecord(event); + + when(userEventService.processEvent(any(), any())) + .thenReturn(Future.failedFuture(new RuntimeException("Error"))); + + userEventHandler.handle(kafkaRecord) + .onComplete(ctx.failing(cause -> { + verify(userEventService, times(1)).processEvent(any(), any()); + ctx.completeNow(); + })); + } + + private UserEvent createUserEvent(UserEventType type) { + return UserEvent.builder() + .id(UUID.randomUUID().toString()) + .type(type) + .tenant(TENANT_ID) + .timestamp(System.currentTimeMillis()) + .newValue(Map.of("key", "value")) + .build(); + } + + private KafkaConsumerRecord buildKafkaConsumerRecord(UserEvent event) { + var userId = UUID.randomUUID().toString(); + var consumerRecord = new ConsumerRecord<>("folio.diku.users.users", 0, 0, userId, Json.encode(event)); + consumerRecord.headers().add(new RecordHeader(OkapiConnectionParams.OKAPI_TENANT_HEADER, TENANT_ID.getBytes(StandardCharsets.UTF_8))); + consumerRecord.headers().add(new RecordHeader("x-okapi-url", "http://localhost:8080".getBytes(StandardCharsets.UTF_8))); + consumerRecord.headers().add(new RecordHeader("x-okapi-token", TOKEN.getBytes(StandardCharsets.UTF_8))); + return new KafkaConsumerRecordImpl<>(consumerRecord); + } +}