Skip to content
Merged
1 change: 1 addition & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* [MODAUD-271](https://folio-org.atlassian.net/browse/MODAUD-271) - Fix cancellation reason deserialization with consortium source field
* [MODAUD-257](https://folio-org.atlassian.net/browse/MODAUD-257) - Replace EOL net.mguenther.kafka:kafka-junit with testcontainers KafkaContainer
* [MODAUD-297](https://folio-org.atlassian.net/browse/MODAUD-297) - Consume user domain events and store audit history
* [MODAUD-299](https://folio-org.atlassian.net/browse/MODAUD-299) - Delete user audit records on disable

## 2.11.1 2025-04-15
* [MODAUD-250](https://folio-org.atlassian.net/browse/MODAUD-250) - Version history of "MARC" records is not tracked
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import io.vertx.sqlclient.Tuple;
import java.util.List;
import java.util.function.Function;
import org.folio.rest.persist.Conn;
import org.folio.util.DbUtils;
import org.folio.util.PostgresClientFactory;
import org.springframework.stereotype.Repository;
Expand Down Expand Up @@ -56,11 +57,10 @@ public Future<List<SettingEntity>> getAllByGroupId(String groupId, String tenant
return promise.future().map(this::mapToSettingList);
}

public Future<Boolean> exists(String settingId, String tenantId) {
var promise = Promise.<RowSet<Row>>promise();
public Future<Boolean> exists(String settingId, Conn conn, String tenantId) {
var query = prepareSql(EXIST_BY_ID_SQL, tenantId);
pgClientFactory.createInstance(tenantId).select(query, Tuple.of(settingId), promise);
return promise.future().map(rowSet -> rowSet.iterator().hasNext());
return conn.execute(query, Tuple.of(settingId))
.map(rowSet -> rowSet.iterator().hasNext());
}

public Future<SettingEntity> getById(String settingId, String tenantId) {
Expand All @@ -70,13 +70,11 @@ public Future<SettingEntity> getById(String settingId, String tenantId) {
return promise.future().map(rowSet -> settingMapper().apply(rowSet.iterator().next()));
}

public Future<Void> update(String settingId, SettingEntity entity, String tenantId) {
var promise = Promise.<RowSet<Row>>promise();
public Future<Void> update(String settingId, SettingEntity entity, Conn conn, String tenantId) {
var query = prepareSql(UPDATE_SQL, tenantId).replace(TYPE_CAST_PLACEHOLDER, getTypeCast(entity.getType()));
var params = Tuple.of(entity.getKey(), entity.getValue(), entity.getType().value(), entity.getDescription(),
entity.getUpdatedByUserId(), entity.getUpdatedDate(), settingId);
pgClientFactory.createInstance(tenantId).execute(query, params, promise);
return promise.future().mapEmpty();
return conn.execute(query, params).mapEmpty();
}

private String prepareSql(String sql, String tenantId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.sql.Timestamp;
import java.util.List;
import java.util.UUID;
import org.folio.rest.persist.Conn;

public interface UserEventDao {

Expand Down Expand Up @@ -48,6 +49,8 @@ public interface UserEventDao {
*/
Future<Void> deleteByUserId(UUID userId, String tenantId);

Future<Void> deleteAll(Conn conn, String tenantId);

/**
* Returns audit table name
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import org.folio.dao.user.UserAuditEntity;
import org.folio.dao.user.UserEventDao;
import org.folio.domain.diff.ChangeRecordDto;
import org.folio.rest.persist.Conn;
import org.folio.util.PostgresClientFactory;
import org.springframework.stereotype.Repository;

Expand All @@ -45,6 +46,8 @@ public class UserEventDaoImpl implements UserEventDao {
WHERE user_id = $1
""";

private static final String DELETE_ALL_SQL = "DELETE FROM %s";

private static final String SELECT_SQL = """
SELECT * FROM %s
WHERE user_id = $1 %s
Expand Down Expand Up @@ -104,6 +107,14 @@ public Future<Void> deleteByUserId(UUID userId, String tenantId) {
.mapEmpty();
}

@Override
public Future<Void> deleteAll(Conn conn, String tenantId) {
LOGGER.debug("deleteAll:: Deleting all user audit records with [tenantId: {}]", tenantId);
var table = formatDBTableName(tenantId, tableName());
var query = DELETE_ALL_SQL.formatted(table);
return conn.execute(query).mapEmpty();
}

@Override
public String tableName() {
return USER_AUDIT_TABLE;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import io.vertx.core.Future;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.UUID;
import javax.ws.rs.NotFoundException;
import lombok.RequiredArgsConstructor;
Expand All @@ -14,6 +15,8 @@
import org.folio.rest.jaxrs.model.Setting;
import org.folio.rest.jaxrs.model.SettingCollection;
import org.folio.rest.jaxrs.model.SettingGroupCollection;
import org.folio.rest.persist.Conn;
import org.folio.util.PostgresClientFactory;
import org.springframework.stereotype.Service;

@Service
Expand All @@ -24,6 +27,8 @@ public class ConfigurationService {
private final SettingGroupDao settingGroupDao;
private final SettingMappers settingMappers;
private final SettingValidationService validationService;
private final List<SettingChangeHandler> changeHandlers;
private final PostgresClientFactory pgClientFactory;

public Future<SettingGroupCollection> getAllSettingGroups(String tenantId) {
return settingGroupDao.getAll(tenantId)
Expand All @@ -47,10 +52,12 @@ public Future<Void> updateSetting(String groupId, String settingKey, Setting set
entity.setUpdatedDate(LocalDateTime.now(ZoneOffset.UTC));
entity.setUpdatedByUserId(userId == null ? null : UUID.fromString(userId));
var settingId = entity.getId();
return settingDao.exists(settingId, tenantId)
.compose(exists -> failSettingIfNotExist(groupId, settingKey, exists))
.compose(o -> settingDao.update(settingId, entity, tenantId))
.mapEmpty();
return pgClientFactory.createInstance(tenantId).withTrans(conn ->
settingDao.exists(settingId, conn, tenantId)
.compose(exists -> failSettingIfNotExist(groupId, settingKey, exists))
.compose(ignored -> settingDao.update(settingId, entity, conn, tenantId))
.compose(ignored -> notifyHandlers(groupId, settingKey, entity.getValue(), conn, tenantId))
);
}

public Future<Setting> getSetting(org.folio.services.configuration.Setting setting, String tenantId) {
Expand All @@ -76,4 +83,15 @@ private Future<Void> failNotFound(boolean exists, String settingKey) {
? Future.succeededFuture(null)
: Future.failedFuture(new NotFoundException(settingKey));
}

private Future<Void> notifyHandlers(String groupId, String settingKey,
Object newValue, Conn conn, String tenantId) {
var result = Future.<Void>succeededFuture();
for (var handler : changeHandlers) {
if (handler.isResponsible(groupId, settingKey)) {
result = result.compose(ignored -> handler.onSettingChanged(newValue, conn, tenantId));
}
}
return result;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.folio.services.configuration;

import io.vertx.core.Future;
import org.folio.rest.persist.Conn;

/**
* Handler invoked when a configuration setting is updated.
*
* <p>Implementations are called within the active write transaction managed by
* {@link ConfigurationService#updateSetting}. The provided {@code conn} may be used for
* transactional DB operations that must be atomic with the setting update itself.
*
* <p>Implementations must not commit or roll back {@code conn}.
* Non-DB side effects (HTTP calls, Kafka messages, etc.) should not be performed via this callback.
*/
public interface SettingChangeHandler {

boolean isResponsible(String groupId, String settingKey);

Future<Void> onSettingChanged(Object newValue, Conn conn, String tenantId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package org.folio.services.user;

import io.vertx.core.Future;
import lombok.RequiredArgsConstructor;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.folio.dao.user.UserEventDao;
import org.folio.rest.persist.Conn;
import org.folio.services.configuration.SettingChangeHandler;
import org.folio.services.configuration.SettingGroup;
import org.folio.services.configuration.SettingKey;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class UserAuditPurgeHandler implements SettingChangeHandler {

private static final Logger LOGGER = LogManager.getLogger();

private final UserEventDao userEventDao;

@Override
public boolean isResponsible(String groupId, String settingKey) {
return SettingGroup.USER.getId().equals(groupId) && SettingKey.ENABLED.getValue().equals(settingKey);
}

@Override
public Future<Void> onSettingChanged(Object newValue, Conn conn, String tenantId) {
if (Boolean.FALSE.equals(newValue)) {
LOGGER.info("onSettingChanged:: User audit disabled, deleting all user audit records [tenantId: {}]", tenantId);
return userEventDao.deleteAll(conn, tenantId);
}
return Future.succeededFuture();
}
}
5 changes: 5 additions & 0 deletions mod-audit-server/src/test/java/org/folio/TestSuite.java
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
import org.folio.services.OrderLineAuditEventsServiceTest;
import org.folio.services.OrganizationAuditEventsServiceTest;
import org.folio.services.PieceAuditEventsServiceTest;
import org.folio.services.configuration.ConfigurationServiceTransactionTest;
import org.folio.services.marc.impl.MarcAuditServiceTest;
import org.folio.util.marc.MarcUtilTest;
import org.junit.jupiter.api.AfterAll;
Expand Down Expand Up @@ -284,6 +285,10 @@ class InventoryAuditApiNestedTest extends InventoryAuditApiTest {
class AuditDataCleanupApiNestedTest extends AuditDataCleanupApiTest {
}

@Nested
class ConfigurationServiceTransactionNestedTest extends ConfigurationServiceTransactionTest {
}

@Nested
class UserAuditApiNestedTest extends UserAuditApiTest {
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@
import static org.folio.utils.EntityUtils.createSettingEntity;
import static org.folio.utils.MockUtils.mockPostgresExecutionSuccess;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
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;
Expand All @@ -18,6 +22,7 @@
import java.time.LocalDateTime;
import java.util.UUID;
import java.util.stream.Stream;
import org.folio.rest.persist.Conn;
import org.folio.rest.persist.PostgresClient;
import org.folio.util.PostgresClientFactory;
import org.folio.utils.MockUtils;
Expand All @@ -42,19 +47,11 @@ class SettingDaoTest {
@Mock
private PostgresClient postgresClient;
@InjectMocks
private SettingDao instanceEventDao;

private static Stream<Arguments> updateTestData() {
return Stream.of(
Arguments.of(1, SettingValueType.INTEGER, "integer"),
Arguments.of("string", SettingValueType.STRING, "text"),
Arguments.of(true, SettingValueType.BOOLEAN, "boolean")
);
}
private SettingDao settingDao;

@BeforeEach
public void setUp() {
when(postgresClientFactory.createInstance(TENANT_ID)).thenReturn(postgresClient);
lenient().when(postgresClientFactory.createInstance(TENANT_ID)).thenReturn(postgresClient);
}

@Test
Expand All @@ -66,27 +63,30 @@ void getAllByGroupId_positive() {
mockPostgresExecutionSuccess(2).when(postgresClient).select(eq(query), captor.capture(), any());

// when
instanceEventDao.getAllByGroupId(groupId, TENANT_ID);
settingDao.getAllByGroupId(groupId, TENANT_ID);

// then
assertEquals(groupId, captor.getValue().getString(0));
verify(postgresClient).select(eq(query), any(Tuple.class), any());
}

@SuppressWarnings("unchecked")
@Test
void exists_positive() {
// given
var settingId = "settingId";
var query = "SELECT 1 FROM diku_mod_audit.setting WHERE id = $1";
var captor = ArgumentCaptor.forClass(Tuple.class);
mockPostgresExecutionSuccess(2).when(postgresClient).select(eq(query), captor.capture(), any());
var conn = mock(Conn.class);
var rowSet = mock(RowSet.class);
when(rowSet.iterator()).thenReturn(MockUtils.mockRowIterator(mock(Row.class)));
when(conn.execute(anyString(), any(Tuple.class))).thenReturn(Future.succeededFuture(rowSet));

// when
instanceEventDao.exists(settingId, TENANT_ID);
var result = settingDao.exists(settingId, conn, TENANT_ID);

// then
assertEquals(settingId, captor.getValue().getString(0));
verify(postgresClient).select(eq(query), any(Tuple.class), any());
assertTrue(result.succeeded());
assertTrue(result.result());
verify(conn).execute(anyString(), any(Tuple.class));
}

@Test
Expand All @@ -101,17 +101,26 @@ void getById_positive(VertxTestContext ctx) {
.when(postgresClient).select(eq(query), captor.capture(), any());

// when
instanceEventDao.getById(settingId, TENANT_ID)
settingDao.getById(settingId, TENANT_ID)
.onComplete(ctx.succeeding(result -> {
assertEquals(settingId, captor.getValue().getString(0));
assertEquals(10, result.getValue());
ctx.completeNow();
}));
}

private static Stream<Arguments> updateTestData() {
return Stream.of(
Arguments.of(1, SettingValueType.INTEGER, "integer"),
Arguments.of("string", SettingValueType.STRING, "text"),
Arguments.of(true, SettingValueType.BOOLEAN, "boolean")
);
}

@SuppressWarnings("unchecked")
@ParameterizedTest
@MethodSource("updateTestData")
void update_positive(Object value, SettingValueType type, String typeValue) {
void update_withConn_bindsCorrectParameters(Object value, SettingValueType type, String typeValue) {
// given
var entity = new SettingEntity("group.key", "key", value, type, "description", "group",
LocalDateTime.now(), UUID.randomUUID(), LocalDateTime.now(), UUID.randomUUID());
Expand All @@ -124,13 +133,16 @@ void update_positive(Object value, SettingValueType type, String typeValue) {
updated_by = $5,
updated_date = $6
WHERE id = $7""".formatted(typeValue);
var conn = mock(Conn.class);
var captor = ArgumentCaptor.forClass(Tuple.class);
mockPostgresExecutionSuccess(2).when(postgresClient).execute(eq(query), captor.capture(), any());
when(conn.execute(eq(query), captor.capture()))
.thenReturn(Future.succeededFuture(mock(RowSet.class)));

// when
instanceEventDao.update(entity.getId(), entity, TENANT_ID);
var result = settingDao.update(entity.getId(), entity, conn, TENANT_ID);

// then
assertTrue(result.succeeded());
var captorValue = captor.getValue();
assertEquals(entity.getKey(), captorValue.getString(0));
assertEquals(entity.getValue(), captorValue.getValue(1));
Expand Down
Loading
Loading