diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index b15987671b..678d45ebe8 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -76,6 +76,24 @@ } ] }, + { + "id": "request-anonymization", + "version": "0.1", + "handlers": [ + { + "methods": [ + "POST" + ], + "pathPattern": "/request-anonymization/{requestId}", + "permissionsRequired": [ + "circulation.requests.anonymize.item.post" + ], + "modulePermissions": [ + "modperms.circulation.requests.anonymize.item" + ] + } + ] + }, { "id": "request-move", "version": "0.7", @@ -1509,6 +1527,11 @@ "displayName": "circulation - anonymize loans", "description": "anonymize loans" }, + { + "permissionName": "circulation.requests.anonymize.item.post", + "displayName": "circulation - anonymize request", + "description": "anonymize request" + }, { "permissionName": "circulation.loans.item.delete", "displayName": "circulation - delete individual loan", @@ -2305,6 +2328,21 @@ ], "visible": false }, + { + "permissionName": "modperms.circulation.requests.anonymize.item", + "displayName": "module permissions for single request anonymization", + "description": "Permissions needed to anonymize a single closed request", + "subPermissions": [ + "circulation.request-anonymization.item.post", + "circulation-storage.requests.item.get", + "inventory-storage.items.item.get", + "inventory-storage.holdings-records.item.get", + "inventory-storage.instances.item.get", + "service-points.item.get", + "pubsub.publish.post" + ], + "visible": false + }, { "permissionName": "modperms.circulation.requests.collection.get", "displayName": "module permissions for one op", diff --git a/ramls/request-anonymization.raml b/ramls/request-anonymization.raml new file mode 100644 index 0000000000..52ecd8c182 --- /dev/null +++ b/ramls/request-anonymization.raml @@ -0,0 +1,66 @@ +#%RAML 1.0 +title: Circulation +version: v0.1 +protocols: [ HTTP, HTTPS ] +baseUri: http://localhost:9130 + +documentation: + - title: Request Anonymization API + content: Request Anonymization API + +types: + anonymize-request-response: !include schema/anonymize-single-request-response.json + errors: !include raml-util/schemas/errors.schema + +traits: + validate: !include raml-util/traits/validation.raml + +/request-anonymization: + /{requestId}: + uriParameters: + requestId: + type: string + description: Request UUID to anonymize + example: 9fd9b9d8-1b1a-4a7a-9f54-5e7c5f5e0b2e + pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' + post: + is: [ validate ] + description: Anonymize a single closed request by its requestId. + responses: + 200: + description: The request has been successfully anonymized. + body: + application/json: + type: anonymize-request-response + example: + requestId: 9fd9b9d8-1b1a-4a7a-9f54-5e7c5f5e0b2e + anonymized: true + + 404: + description: Request with the given requestId was not found. + body: + application/json: + type: errors + example: + errors: + - message: Request not found + + 422: + description: Validation error — request is not closed or another rule prevents anonymization. + body: + application/json: + type: errors + example: + errors: + - message: requestNotClosed + + 500: + description: Internal server error. + body: + application/json: + type: errors + example: + errors: + - message: Internal server error + + diff --git a/ramls/schema/anonymize-single-request-response.json b/ramls/schema/anonymize-single-request-response.json new file mode 100644 index 0000000000..ea8cdcda2d --- /dev/null +++ b/ramls/schema/anonymize-single-request-response.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Single Request Anonymization Response", + "type": "object", + "properties": { + "requestId": { + "type": "string", + "format": "UUID", + "description": "UUID of the request that was processed." + }, + "anonymized": { + "type": "boolean", + "description": "True if the request is anonymized after this call (PII removed or already absent)." + } + }, + "required": ["requestId", "anonymized"], + "additionalProperties": false +} diff --git a/src/main/java/org/folio/circulation/CirculationVerticle.java b/src/main/java/org/folio/circulation/CirculationVerticle.java index f0606dbb8c..132a92a573 100644 --- a/src/main/java/org/folio/circulation/CirculationVerticle.java +++ b/src/main/java/org/folio/circulation/CirculationVerticle.java @@ -56,6 +56,7 @@ import org.folio.circulation.resources.foruseatlocation.PickupByBarcodeResource; import org.folio.circulation.support.logging.LogHelper; import org.folio.circulation.support.logging.Logging; +import org.folio.circulation.resources.RequestAnonymizationResource; import io.vertx.core.AbstractVerticle; import io.vertx.core.Promise; @@ -145,6 +146,7 @@ public void start(Promise startFuture) { new ScheduledDigitalRemindersProcessingResource(client).register(router); new DueDateNotRealTimeScheduledNoticeProcessingResource(client).register(router); new RequestScheduledNoticeProcessingResource(client).register(router); + new RequestAnonymizationResource(client).register(router); new FeeFineScheduledNoticeProcessingResource(client).register(router); new FeeFineNotRealTimeScheduledNoticeProcessingResource(client).register(router); new OverdueFineScheduledNoticeProcessingResource(client).register(router); diff --git a/src/main/java/org/folio/circulation/domain/representations/logs/LogEventType.java b/src/main/java/org/folio/circulation/domain/representations/logs/LogEventType.java index c78301c543..98b09179f7 100644 --- a/src/main/java/org/folio/circulation/domain/representations/logs/LogEventType.java +++ b/src/main/java/org/folio/circulation/domain/representations/logs/LogEventType.java @@ -11,7 +11,8 @@ public enum LogEventType { REQUEST_CREATED_THROUGH_OVERRIDE("REQUEST_CREATED_THROUGH_OVERRIDE_EVENT"), REQUEST_UPDATED("REQUEST_UPDATED_EVENT"), REQUEST_MOVED("REQUEST_MOVED_EVENT"), - REQUEST_REORDERED("REQUEST_REORDERED_EVENT"); + REQUEST_REORDERED("REQUEST_REORDERED_EVENT"), + REQUEST_ANONYMIZED("REQUEST_ANONYMIZED_EVENT"); private final String value; diff --git a/src/main/java/org/folio/circulation/infrastructure/storage/requests/RequestRepository.java b/src/main/java/org/folio/circulation/infrastructure/storage/requests/RequestRepository.java index 9d9a74edef..6d564b3c4a 100644 --- a/src/main/java/org/folio/circulation/infrastructure/storage/requests/RequestRepository.java +++ b/src/main/java/org/folio/circulation/infrastructure/storage/requests/RequestRepository.java @@ -20,7 +20,9 @@ import java.lang.invoke.MethodHandles; import java.util.Collection; +import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.function.DoubleUnaryOperator; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; diff --git a/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java b/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java new file mode 100644 index 0000000000..c271886275 --- /dev/null +++ b/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java @@ -0,0 +1,43 @@ +package org.folio.circulation.resources; + +import io.vertx.core.json.JsonObject; +import org.folio.circulation.services.EventPublisher; +import org.folio.circulation.services.RequestAnonymizationService; +import org.folio.circulation.support.Clients; +import org.folio.circulation.support.RouteRegistration; +import org.folio.circulation.support.http.server.JsonHttpResponse; +import org.folio.circulation.support.http.server.WebContext; + +import io.vertx.core.http.HttpClient; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; + +public class RequestAnonymizationResource extends Resource { + public RequestAnonymizationResource(HttpClient client) { + super(client); + } + + @Override + public void register(Router router) { + new RouteRegistration("/request-anonymization/:requestId", router) + .create(this::anonymizeRequest); + } + + public void anonymizeRequest(RoutingContext routingContext) { + final WebContext context = new WebContext(routingContext); + final Clients clients = Clients.create(context, client); + + final String requestId = routingContext.request().getParam("requestId"); + + final var eventPublisher = new EventPublisher(clients); + final var requestAnonymizationService = new RequestAnonymizationService(clients, eventPublisher); + + requestAnonymizationService.anonymizeSingle(requestId) + .thenApply(r -> r.map(id -> + JsonHttpResponse.ok(new JsonObject() + .put("requestId", id) + .put("anonymized", true)) + )) + .thenAccept(context::writeResultToHttpResponse); + } +} diff --git a/src/main/java/org/folio/circulation/services/EventPublisher.java b/src/main/java/org/folio/circulation/services/EventPublisher.java index 551a01a677..ec3c546679 100644 --- a/src/main/java/org/folio/circulation/services/EventPublisher.java +++ b/src/main/java/org/folio/circulation/services/EventPublisher.java @@ -35,6 +35,8 @@ import static org.folio.circulation.support.utils.DateFormatUtil.formatDateTimeOptional; import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.util.Optional; import java.util.concurrent.CompletableFuture; import org.apache.logging.log4j.LogManager; @@ -450,6 +452,26 @@ private CompletableFuture> publishDueDateChangedEvent(Loan loan, Us return completedFuture(succeeded(null)); } + public CompletableFuture> publishRequestAnonymizedLog(Request req) { + final Item item = req.getItem(); + final JsonObject linkToIds = new JsonObject() + .put("requestId", req.getId()); + final JsonObject items = new JsonObject() + .put("itemBarcode", item != null ? item.getBarcode() : null) + .put("itemId", item != null ? item.getItemId() : null) + .put("instanceId", item != null ? item.getInstanceId(): req.getInstanceId()) + .put("holdingsId", item != null ? item.getHoldingsRecordId() : req.getHoldingsRecordId()); + final JsonObject context = new JsonObject() + .put("object", "Request") + .put("action", "anonymizeRequest") + .put("date", ZonedDateTime.now(ZoneOffset.UTC).toInstant().toString()) + .put("userBarcode", "-") + .put("linkToIds", linkToIds) + .put("items", items); + + return publishLogRecord(context, LogEventType.REQUEST_ANONYMIZED); + } + private String getLoanActionCommentLog(Loan loan) { return format(ACTION_COMMENT_TEMPLATE, loan.getActionComment()); } diff --git a/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java b/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java new file mode 100644 index 0000000000..5541787b4b --- /dev/null +++ b/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java @@ -0,0 +1,119 @@ +package org.folio.circulation.services; + +import static org.folio.circulation.support.results.Result.failed; +import static org.folio.circulation.support.results.Result.succeeded; +import static org.folio.circulation.support.results.ResultBinding.flatMapResult; +import static org.folio.circulation.support.results.ResultBinding.mapResult; + +import java.util.EnumSet; +import java.util.Set; +import java.util.concurrent.CompletableFuture; + +import org.folio.circulation.domain.Request; +import org.folio.circulation.domain.RequestFulfillmentPreference; +import org.folio.circulation.domain.RequestStatus; +import org.folio.circulation.support.Clients; +import org.folio.circulation.support.ValidationErrorFailure; +import org.folio.circulation.support.http.server.ValidationError; +import org.folio.circulation.support.results.Result; +import org.folio.circulation.infrastructure.storage.inventory.ItemRepository; +import org.folio.circulation.infrastructure.storage.loans.LoanRepository; +import org.folio.circulation.infrastructure.storage.users.UserRepository; +import org.folio.circulation.infrastructure.storage.requests.RequestRepository; +import org.folio.util.UuidUtil; +import io.vertx.core.json.JsonObject; + +public class RequestAnonymizationService { + private static final Set ALLOWED_STATUSES = EnumSet.of( + RequestStatus.CLOSED_FILLED, + RequestStatus.CLOSED_CANCELLED, + RequestStatus.CLOSED_PICKUP_EXPIRED, + RequestStatus.CLOSED_UNFILLED + ); + + private static final Set OPEN_STATUSES = EnumSet.of( + RequestStatus.OPEN_AWAITING_PICKUP, + RequestStatus.OPEN_IN_TRANSIT, + RequestStatus.OPEN_NOT_YET_FILLED + ); + + private final RequestRepository requestRepository; + private final EventPublisher eventPublisher; + + public RequestAnonymizationService(Clients clients, EventPublisher eventPublisher) { + ItemRepository itemRepository = new ItemRepository(clients); + UserRepository userRepository = new UserRepository(clients); + LoanRepository loanRepository = new LoanRepository(clients, itemRepository, userRepository); + + this.requestRepository = RequestRepository.using(clients, itemRepository, userRepository, loanRepository); + + this.eventPublisher = eventPublisher; + } + + public RequestAnonymizationService(RequestRepository requestRepository, + EventPublisher eventPublisher) { + + this.requestRepository = requestRepository; + this.eventPublisher = eventPublisher; + } + + public CompletableFuture> anonymizeSingle(String requestId) { + if (!UuidUtil.isUuid(requestId)) { + return CompletableFuture.completedFuture( + ValidationErrorFailure.failedValidation("invalidRequestId", "requestId", requestId) + ); + } + + return fetchRequest(requestId) + .thenApply(flatMapResult(req -> validateStatus(req, requestId))) + .thenApply(mapResult(this::scrubPii)) + .thenCompose(r -> r.after(requestRepository::update)) + .thenCompose(r -> r.after(this::publishLog)) + .thenApply(mapResult(updated -> requestId)); + } + + private CompletableFuture> fetchRequest(String requestId) { + return requestRepository.getById(requestId); + } + + private Result validateStatus(Request request, String id) { + final RequestStatus status = request.getStatus(); + + if (ALLOWED_STATUSES.contains(status)) { + return succeeded(request); + } + if (OPEN_STATUSES.contains(status)) { + return failed(new ValidationErrorFailure((new ValidationError("requestNotClosed", "requestId", id)))); + } + return failed(new ValidationErrorFailure((new ValidationError("requestNotEligibleForAnonymization", "requestId", id)))); + } + + private Request scrubPii(Request req) { + final JsonObject rep = req.asJson(); + + final boolean hadRequester = rep.containsKey("requester") || rep.containsKey("requesterId"); + final boolean hadProxy = rep.containsKey("proxy") || rep.containsKey("proxyUserId"); + final boolean isDelivery = req.getfulfillmentPreference() == RequestFulfillmentPreference.DELIVERY; + final boolean hadDelivery = isDelivery && (rep.containsKey("deliveryAddress") || rep.containsKey("deliveryAddressTypeId")); + + if (!hadRequester && !hadProxy && (!isDelivery || !hadDelivery)) { + return req; + } + + rep.putNull("requesterId"); + rep.putNull("proxyUserId"); + rep.remove("requester"); + rep.remove("proxy"); + + if (isDelivery) { + rep.remove("deliveryAddress"); + rep.remove("deliveryAddressTypeId"); + } + + return Request.from(rep); + } + + private CompletableFuture> publishLog(Request req) { + return eventPublisher.publishRequestAnonymizedLog(req); + } +} diff --git a/src/test/java/api/requests/RequestAnonymizationApiTests.java b/src/test/java/api/requests/RequestAnonymizationApiTests.java new file mode 100644 index 0000000000..5acc8c08cd --- /dev/null +++ b/src/test/java/api/requests/RequestAnonymizationApiTests.java @@ -0,0 +1,62 @@ +package api.requests; + +import static api.support.http.InterfaceUrls.circulationRequestAnonymizationUrl; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.util.UUID; + +import org.folio.circulation.support.http.client.Response; +import org.junit.jupiter.api.Test; + +import api.support.APITests; +import api.support.builders.RequestBuilder; +import api.support.http.IndividualResource; +import io.vertx.core.json.JsonObject; + +class RequestAnonymizationApiTests extends APITests { + + @Test + void anonymizeSingleRequestRemovesPIIAndReturns200() { + String requestId = createClosedDeliveryRequest(); + + Response response = restAssuredClient.post( + circulationRequestAnonymizationUrl(requestId), + "anonymize-request-" + requestId + ); + + assertEquals(200, response.getStatusCode()); + + Response updated = + requestsStorageClient.getById(UUID.fromString(requestId)); + JsonObject stored = updated.getJson(); + + assertTrue(stored.containsKey("requesterId")); + assertNull(stored.getValue("requesterId")); + assertTrue(stored.containsKey("proxyUserId")); + assertNull(stored.getValue("proxyUserId")); + + assertFalse(stored.containsKey("requester")); + assertFalse(stored.containsKey("proxy")); + assertFalse(stored.containsKey("deliveryAddressTypeId")); + } + + private String createClosedDeliveryRequest() { + var user = usersFixture.james(); + var item = itemsFixture.basedUponSmallAngryPlanet(); + + IndividualResource request = requestsStorageClient.create( + new RequestBuilder() + .forItem(item) + .by(user) + .fulfilled() + .withFulfillmentPreference("Delivery") + .withDeliveryAddressType(UUID.randomUUID()) + .create() + ); + + return request.getId().toString(); + } +} diff --git a/src/test/java/api/requests/RequestAnonymizationResourceTests.java b/src/test/java/api/requests/RequestAnonymizationResourceTests.java new file mode 100644 index 0000000000..5a7f5bcc54 --- /dev/null +++ b/src/test/java/api/requests/RequestAnonymizationResourceTests.java @@ -0,0 +1,40 @@ +package api.requests; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.folio.circulation.resources.RequestAnonymizationResource; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import api.support.APITests; +import io.vertx.core.http.HttpClient; +import io.vertx.core.http.impl.HttpClientInternal; +import io.vertx.ext.web.Route; +import io.vertx.ext.web.Router; + +@Nested +class RequestAnonymizationResourceTests extends APITests { + + @Test + void registerDoesNotThrowAndRegistersRoute() { + HttpClientInternal internalClient = mock(HttpClientInternal.class); + HttpClient httpClient = internalClient; + + RequestAnonymizationResource resource = new RequestAnonymizationResource(httpClient); + + Router router = mock(Router.class); + Route route = mock(Route.class); + + when(router.post(anyString())).thenReturn(route); + when(route.handler(any())).thenReturn(route); + + assertDoesNotThrow(() -> resource.register(router)); + + verify(router).post("/request-anonymization/:requestId"); + } +} diff --git a/src/test/java/api/requests/RequestAnonymizationServiceTest.java b/src/test/java/api/requests/RequestAnonymizationServiceTest.java new file mode 100644 index 0000000000..c5ab219727 --- /dev/null +++ b/src/test/java/api/requests/RequestAnonymizationServiceTest.java @@ -0,0 +1,166 @@ +package api.requests; + +import static java.util.concurrent.CompletableFuture.completedFuture; +import static org.folio.circulation.support.results.Result.failed; +import static org.junit.Assert.assertNull; +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.UUID; + +import org.folio.circulation.domain.*; +import org.folio.circulation.infrastructure.storage.requests.RequestRepository; +import org.folio.circulation.services.EventPublisher; +import org.folio.circulation.services.RequestAnonymizationService; +import org.folio.circulation.support.Clients; +import org.folio.circulation.support.RecordNotFoundFailure; +import org.folio.circulation.support.results.Result; +import org.junit.Assert; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import api.support.APITests; +import io.vertx.core.json.JsonObject; + + +class RequestAnonymizationServiceTest extends APITests { + + private Clients clients; + private RequestRepository requestRepository; + private EventPublisher eventPublisher; + private RequestAnonymizationService service; + + @BeforeEach + void setUp() { + clients = mock(Clients.class); + requestRepository = mock(RequestRepository.class); + eventPublisher = mock(EventPublisher.class); + + when(clients.requestsStorage()).thenReturn(null); + + service = new RequestAnonymizationService(requestRepository, eventPublisher); + } + + private static Request closedFilledRequest(String id) { + JsonObject json = new JsonObject() + .put("id", id) + .put("status", RequestStatus.CLOSED_FILLED.getValue()) + .put("requesterId", "r1") + .put("proxyUserId", "p1") + .put("requester", new io.vertx.core.json.JsonObject().put("barcode", "rb")) + .put("proxy", new io.vertx.core.json.JsonObject().put("barcode", "pb")) + .put("fulfillmentPreference", RequestFulfillmentPreference.HOLD_SHELF.getValue()); + return Request.from(json); + } + + private static Request closedDeliveryRequest(String id) { + var json = new io.vertx.core.json.JsonObject() + .put("id", id) + .put("status", RequestStatus.CLOSED_CANCELLED.getValue()) + .put("requesterId", "r1") + .put("proxyUserId", "p1") + .put("requester", new io.vertx.core.json.JsonObject()) + .put("proxy", new io.vertx.core.json.JsonObject()) + .put("fulfillmentPreference", RequestFulfillmentPreference.DELIVERY.getValue()) + .put("deliveryAddress", new io.vertx.core.json.JsonObject().put("x", 1)) + .put("deliveryAddressTypeId", "addrType"); + return Request.from(json); + } + + @Test + void anonymizeSingleHappyPathRemovesPIIUpdatesRepoAndLogs() { + String id = UUID.randomUUID().toString(); + var req = closedFilledRequest(id); + + when(requestRepository.getById(id)).thenReturn(completedFuture(Result.succeeded(req))); + when(requestRepository.update(org.mockito.ArgumentMatchers.any())) + .thenAnswer(inv -> completedFuture(Result.succeeded(inv.getArgument(0)))); + when(eventPublisher.publishRequestAnonymizedLog(org.mockito.ArgumentMatchers.any())) + .thenReturn(completedFuture(Result.succeeded(null))); + + var out = service.anonymizeSingle(id).join(); + + assertTrue(out.succeeded()); + assertEquals(id, out.value()); + + ArgumentCaptor updatedCap = ArgumentCaptor.forClass(Request.class); + verify(requestRepository).update(updatedCap.capture()); + var updatedJson = updatedCap.getValue().asJson(); + Assert.assertTrue(updatedJson.containsKey("requesterId")); + assertNull(updatedJson.getValue("requesterId")); + Assert.assertTrue(updatedJson.containsKey("proxyUserId")); + assertNull(updatedJson.getValue("proxyUserId")); + assertFalse(updatedJson.containsKey("requester")); + assertFalse(updatedJson.containsKey("proxy")); + + verify(eventPublisher).publishRequestAnonymizedLog(any()); + } + + @Test + void anonymizeSingleDeliveryRemovesDeliveryFields() { + String id = UUID.randomUUID().toString(); + var req = closedDeliveryRequest(id); + + when(requestRepository.getById(id)).thenReturn(completedFuture(Result.succeeded(req))); + when(requestRepository.update((Request) any())) + .thenAnswer(inv -> completedFuture(Result.succeeded(inv.getArgument(0)))); + when(eventPublisher.publishRequestAnonymizedLog(any())) + .thenReturn(completedFuture(Result.succeeded(null))); + + var out = service.anonymizeSingle(id).join(); + assertTrue(out.succeeded()); + + ArgumentCaptor updatedCap = ArgumentCaptor.forClass(Request.class); + verify(requestRepository).update(updatedCap.capture()); + var updatedJson = updatedCap.getValue().asJson(); + assertFalse(updatedJson.containsKey("deliveryAddress")); + assertFalse(updatedJson.containsKey("deliveryAddressTypeId")); + } + + @Test + void anonymizeSingleReturns404WhenRequestNotFound() { + String id = UUID.randomUUID().toString(); + when(requestRepository.getById(id)) + .thenReturn(completedFuture( + failed(new RecordNotFoundFailure("Request", id)) + )); + + var out = service.anonymizeSingle(id).join(); + + assertFalse(out.succeeded()); + assertTrue(out.cause().toString().contains("cannot be found")); + verify(requestRepository, never()).update((Request) any()); + verify(eventPublisher, never()).publishRequestAnonymizedLog(any()); + } + + @Test +void anonymizeSingleReturns422WhenRequestIsOpen() { + String id = UUID.randomUUID().toString(); + var json = new io.vertx.core.json.JsonObject() + .put("id", id) + .put("status", RequestStatus.OPEN_NOT_YET_FILLED.getValue()); + var req = Request.from(json); + + when(requestRepository.getById(id)).thenReturn(completedFuture(Result.succeeded(req))); + + var out = service.anonymizeSingle(id).join(); + + assertFalse(out.succeeded()); + assertTrue(out.cause().toString().contains("requestNotClosed")); + verify(requestRepository, never()).update((Request) any()); + verify(eventPublisher, never()).publishRequestAnonymizedLog(any()); + } + + @Test + void anonymizeSingleReturns422WhenIdIsNotUuid() { + var out = service.anonymizeSingle("not-a-uuid").join(); + assertFalse(out.succeeded()); + assertTrue(out.cause().toString().contains("invalidRequestId")); + } +} diff --git a/src/test/java/api/support/http/InterfaceUrls.java b/src/test/java/api/support/http/InterfaceUrls.java index 5abfcb039f..9b9b7deb20 100644 --- a/src/test/java/api/support/http/InterfaceUrls.java +++ b/src/test/java/api/support/http/InterfaceUrls.java @@ -361,4 +361,9 @@ public static URL circulationSettingsUrl(String subPath) { public static URL printEventsUrl(String subPath) { return circulationModuleUrl("/circulation" + subPath); } + + public static URL circulationRequestAnonymizationUrl(String requestId) { + return circulationModuleUrl("/request-anonymization/" + requestId); + } + } diff --git a/src/test/java/org/folio/circulation/domain/anonymization/AnonymizeRequestTests.java b/src/test/java/org/folio/circulation/domain/anonymization/AnonymizeRequestTests.java new file mode 100644 index 0000000000..ecd886e46f --- /dev/null +++ b/src/test/java/org/folio/circulation/domain/anonymization/AnonymizeRequestTests.java @@ -0,0 +1,99 @@ +package org.folio.circulation.domain.anonymization; + +import io.vertx.core.json.JsonObject; +import org.folio.circulation.domain.Request; +import org.folio.circulation.domain.RequestStatus; +import org.folio.circulation.domain.RequestFulfillmentPreference; +import org.folio.circulation.infrastructure.storage.requests.RequestRepository; +import org.folio.circulation.services.RequestAnonymizationService; +import org.folio.circulation.services.EventPublisher; +import org.folio.circulation.support.Clients; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.invocation.InvocationOnMock; + +import java.util.UUID; + +import static java.util.concurrent.CompletableFuture.completedFuture; +import static org.folio.circulation.support.results.Result.succeeded; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AnonymizeRequestTests { + + @Mock + RequestRepository requestRepository; + + @Mock + Clients client; + + @Mock + EventPublisher eventPublisher; + + RequestAnonymizationService service; + + @BeforeEach + void setUp() { + service = new RequestAnonymizationService(requestRepository, eventPublisher); + } + + @Test + void anonymizeSingleHappyPathRemovesPIIUpdatesRepoAndLogs() { + // given a closed request with PII + String id = UUID.randomUUID().toString(); + + JsonObject json = new JsonObject() + .put("id", id) + .put("status", RequestStatus.CLOSED_FILLED.getValue()) + .put("fulfillmentPreference", + RequestFulfillmentPreference.DELIVERY.getValue()) + .put("requesterId", "r-1") + .put("proxyUserId", "p-1") + .put("requester", new JsonObject().put("barcode", "rb")) + .put("proxy", new JsonObject().put("barcode", "pb")) + .put("deliveryAddressTypeId", "addr-type"); + + Request request = Request.from(json); + + when(requestRepository.getById(id)) + .thenReturn(completedFuture(succeeded(request))); + + when(requestRepository.update(any(Request.class))) + .thenAnswer((InvocationOnMock invocation) -> + completedFuture(succeeded(invocation.getArgument(0))) + ); + + when(eventPublisher.publishRequestAnonymizedLog(any(Request.class))) + .thenReturn(completedFuture(succeeded(null))); + + var result = service.anonymizeSingle(id).join(); + + assertTrue(result.succeeded()); + assertThat(result.value(), is(id)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Request.class); + verify(requestRepository).update(captor.capture()); + + JsonObject stored = captor.getValue().asJson(); + + assertTrue(stored.containsKey("requesterId")); + assertNull(stored.getValue("requesterId")); + assertTrue(stored.containsKey("proxyUserId")); + assertNull(stored.getValue("proxyUserId")); + assertFalse(stored.containsKey("requester")); + assertFalse(stored.containsKey("proxy")); + assertFalse(stored.containsKey("deliveryAddressTypeId")); + } +}