From f0333b23576579e8b061e29a0dbb5ad44d3f21e5 Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Tue, 15 Sep 2026 16:17:13 +0300 Subject: [PATCH 1/4] fix(security): validate analysis payload and validation datasets Require analysis.algorithm and analysis.inputdata before using them, so a malformed request is a 400 instead of a 500. Authorize validation_datasets as well as datasets on both the synchronous and persisted execution paths. --- .../java/hbp/mip/algorithm/AnalysisAPI.java | 8 ++ .../hbp/mip/experiment/ExperimentService.java | 21 ++++- .../hbp/mip/algorithm/AnalysisAPITest.java | 71 ++++++++++++++++ ...xperimentServiceRequestValidationTest.java | 85 +++++++++++++++++++ 4 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 src/test/java/hbp/mip/algorithm/AnalysisAPITest.java create mode 100644 src/test/java/hbp/mip/experiment/ExperimentServiceRequestValidationTest.java diff --git a/src/main/java/hbp/mip/algorithm/AnalysisAPI.java b/src/main/java/hbp/mip/algorithm/AnalysisAPI.java index 210fe386..4e0bb6d1 100644 --- a/src/main/java/hbp/mip/algorithm/AnalysisAPI.java +++ b/src/main/java/hbp/mip/algorithm/AnalysisAPI.java @@ -44,6 +44,14 @@ public ResponseEntity runAnalysis( authentication, analysisRequest.inputdata().datasets(), logger); + + var validationDatasets = analysisRequest.inputdata().validation_datasets(); + if (validationDatasets != null && !validationDatasets.isEmpty()) { + claimUtils.validateAccessRightsOnDatasets( + authentication, + validationDatasets, + logger); + } } AnalysisService.AnalysisResultDTO result = analysisService.runAnalysis(analysisRequest, logger); return ResponseEntity.status(result.code()).body(result.body()); diff --git a/src/main/java/hbp/mip/experiment/ExperimentService.java b/src/main/java/hbp/mip/experiment/ExperimentService.java index ec2273a5..17179557 100644 --- a/src/main/java/hbp/mip/experiment/ExperimentService.java +++ b/src/main/java/hbp/mip/experiment/ExperimentService.java @@ -152,7 +152,14 @@ public ExperimentDTO createExperiment(Authentication authentication, ExperimentE } private void requireAnalysisPayload(ExperimentExecutionDTO experimentExecutionDTO, Logger logger) { - if (experimentExecutionDTO.analysis() == null) { + AnalysisRequestDTO analysis = experimentExecutionDTO.analysis(); + boolean missingPayload = analysis == null + || analysis.algorithm() == null + || analysis.algorithm().name() == null + || analysis.algorithm().name().isBlank() + || analysis.inputdata() == null; + + if (missingPayload) { String errorMessage = "Missing required analysis payload."; logger.warn(errorMessage); throw new BadRequestException(errorMessage); @@ -161,9 +168,15 @@ private void requireAnalysisPayload(ExperimentExecutionDTO experimentExecutionDT private void validateDatasetAccess(Authentication authentication, ExperimentExecutionDTO experimentExecutionDTO, Logger logger) { - if (authenticationIsEnabled) { - claimUtils.validateAccessRightsOnDatasets(authentication, - experimentExecutionDTO.analysis().inputdata().datasets(), logger); + if (!authenticationIsEnabled) { + return; + } + + AnalysisRequestDTO.AnalysisInputDataDTO inputdata = experimentExecutionDTO.analysis().inputdata(); + claimUtils.validateAccessRightsOnDatasets(authentication, inputdata.datasets(), logger); + + if (inputdata.validation_datasets() != null && !inputdata.validation_datasets().isEmpty()) { + claimUtils.validateAccessRightsOnDatasets(authentication, inputdata.validation_datasets(), logger); } } diff --git a/src/test/java/hbp/mip/algorithm/AnalysisAPITest.java b/src/test/java/hbp/mip/algorithm/AnalysisAPITest.java new file mode 100644 index 00000000..a1046ac4 --- /dev/null +++ b/src/test/java/hbp/mip/algorithm/AnalysisAPITest.java @@ -0,0 +1,71 @@ +package hbp.mip.algorithm; + +import hbp.mip.user.ActiveUserService; +import hbp.mip.user.UserDTO; +import hbp.mip.utils.ClaimUtils; +import hbp.mip.utils.Logger; +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; +import org.springframework.security.core.Authentication; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AnalysisAPITest { + + @Mock + private ActiveUserService activeUserService; + + @Mock + private AnalysisService analysisService; + + @Mock + private ClaimUtils claimUtils; + + @Mock + private Authentication authentication; + + private AnalysisAPI analysisAPI; + + @BeforeEach + void setUp() { + analysisAPI = new AnalysisAPI(activeUserService, analysisService, claimUtils); + ReflectionTestUtils.setField(analysisAPI, "authenticationIsEnabled", true); + when(activeUserService.getActiveUser(authentication)) + .thenReturn(new UserDTO("user", "User", "user@example.org", "subject", true)); + } + + @Test + void runAnalysis_validatesValidationDatasetsToo() { + var request = new AnalysisRequestDTO( + null, + new AnalysisRequestDTO.AnalysisInputDataDTO( + "dm:1", + List.of("ds1"), + List.of("dsv"), + null, + List.of("age")), + null, + new AnalysisRequestDTO.AnalysisAlgorithmDTO("histogram", null, List.of("age"), Map.of()), + null); + when(analysisService.runAnalysis(eq(request), any(Logger.class))) + .thenReturn(new AnalysisService.AnalysisResultDTO(200, Map.of("ok", true))); + + var response = analysisAPI.runAnalysis(authentication, request); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + verify(claimUtils).validateAccessRightsOnDatasets(eq(authentication), eq(List.of("ds1")), any(Logger.class)); + verify(claimUtils).validateAccessRightsOnDatasets(eq(authentication), eq(List.of("dsv")), any(Logger.class)); + } +} diff --git a/src/test/java/hbp/mip/experiment/ExperimentServiceRequestValidationTest.java b/src/test/java/hbp/mip/experiment/ExperimentServiceRequestValidationTest.java new file mode 100644 index 00000000..8837b041 --- /dev/null +++ b/src/test/java/hbp/mip/experiment/ExperimentServiceRequestValidationTest.java @@ -0,0 +1,85 @@ +package hbp.mip.experiment; + +import hbp.mip.algorithm.AnalysisRequestDTO; +import hbp.mip.algorithm.AnalysisService; +import hbp.mip.user.ActiveUserService; +import hbp.mip.utils.ClaimUtils; +import hbp.mip.utils.Exceptions.BadRequestException; +import hbp.mip.utils.Logger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.Authentication; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ExperimentServiceRequestValidationTest { + + @Mock + private ActiveUserService activeUserService; + + @Mock + private ClaimUtils claimUtils; + + @Mock + private ExperimentRepository experimentRepository; + + @Mock + private AnalysisService analysisService; + + @Mock + private Authentication authentication; + + private final Logger logger = new Logger("user", "test"); + + @Test + void createExperiment_rejectsAnalysisWithoutAlgorithm() { + var service = service(false); + var execution = new ExperimentExecutionDTO("test", new AnalysisRequestDTO(null, null, null, null, null)); + + assertThatThrownBy(() -> service.createExperiment(authentication, execution, logger)) + .isInstanceOf(BadRequestException.class); + verifyNoInteractions(experimentRepository, analysisService); + } + + @Test + void runTransientExperiment_validatesDatasetAndValidationDatasetLists() { + var service = service(true); + var analysis = new AnalysisRequestDTO( + null, + new AnalysisRequestDTO.AnalysisInputDataDTO( + "dm:1", + List.of("ds1"), + List.of("dsv"), + null, + List.of("age")), + null, + new AnalysisRequestDTO.AnalysisAlgorithmDTO("histogram", null, List.of("age"), Map.of()), + null); + var execution = new ExperimentExecutionDTO("test", analysis); + + when(analysisService.runAnalysis(any(UUID.class), eq(analysis), any(Logger.class))) + .thenReturn(new AnalysisService.AnalysisResultDTO(200, Map.of("ok", true))); + + service.runTransientExperiment(authentication, execution, logger); + + verify(claimUtils).validateAccessRightsOnDatasets(eq(authentication), eq(List.of("ds1")), any(Logger.class)); + verify(claimUtils).validateAccessRightsOnDatasets(eq(authentication), eq(List.of("dsv")), any(Logger.class)); + } + + private ExperimentService service(boolean authenticationEnabled) { + return new ExperimentService(activeUserService, claimUtils, experimentRepository, analysisService, + authenticationEnabled, "9.0.0"); + } +} From d5c5feacbc2a2855905316849e0ff84cafd1f111 Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Tue, 15 Sep 2026 16:17:21 +0300 Subject: [PATCH 2/4] fix(experiment): migrate legacy algorithm payloads Convert experiment.algorithm rows written in the old AlgorithmExecutionDTO shape into AnalysisRequestDTO before the new reader sees them, so existing experiments no longer fail GET /experiments with a Gson type error. --- .../V3__MigrateLegacyAlgorithmPayloads.sql | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 src/main/resources/db/migration/V3__MigrateLegacyAlgorithmPayloads.sql diff --git a/src/main/resources/db/migration/V3__MigrateLegacyAlgorithmPayloads.sql b/src/main/resources/db/migration/V3__MigrateLegacyAlgorithmPayloads.sql new file mode 100644 index 00000000..d98b7337 --- /dev/null +++ b/src/main/resources/db/migration/V3__MigrateLegacyAlgorithmPayloads.sql @@ -0,0 +1,74 @@ +-- One-time conversion of experiment.algorithm payloads written in the old +-- AlgorithmExecutionDTO shape (top-level name/inputdata/parameters/preprocessing map) +-- into the AnalysisRequestDTO shape (top-level algorithm plus preprocessing list). +-- Rows already in the new shape are left untouched, and malformed/unrelated JSON is skipped. +DO $$ +DECLARE + experiment_row RECORD; + legacy_json JSON; + legacy JSONB; + preprocessing JSONB; + migrated JSONB; +BEGIN + FOR experiment_row IN + SELECT uuid, algorithm + FROM experiment + WHERE algorithm IS NOT NULL AND btrim(algorithm) <> '' + LOOP + BEGIN + legacy_json := experiment_row.algorithm::json; + EXCEPTION WHEN OTHERS THEN + CONTINUE; + END; + + legacy := legacy_json::jsonb; + + -- The new shape always carries the top-level algorithm object. + IF legacy ? 'algorithm' THEN + CONTINUE; + END IF; + + -- The old AlgorithmExecutionDTO always had a top-level name. + IF NOT (legacy ? 'name') THEN + CONTINUE; + END IF; + + IF legacy->'preprocessing' IS NULL OR legacy->'preprocessing' = 'null'::jsonb THEN + preprocessing := NULL; + ELSIF jsonb_typeof(legacy->'preprocessing') = 'object' THEN + -- jsonb has no key order; json_each over the original json text preserves it. + SELECT COALESCE( + jsonb_agg(jsonb_build_object('name', key, 'parameters', value::jsonb)), + '[]'::jsonb) + INTO preprocessing + FROM json_each(legacy_json->'preprocessing'); + ELSIF jsonb_typeof(legacy->'preprocessing') = 'array' THEN + preprocessing := legacy->'preprocessing'; + ELSE + preprocessing := NULL; + END IF; + + migrated := jsonb_build_object( + 'request_id', NULL, + 'inputdata', jsonb_build_object( + 'data_model', legacy->'inputdata'->'data_model', + 'datasets', legacy->'inputdata'->'datasets', + 'validation_datasets', legacy->'inputdata'->'validation_datasets', + 'filters', legacy->'inputdata'->'filters', + 'variables', NULL + ), + 'preprocessing', preprocessing, + 'algorithm', jsonb_build_object( + 'name', legacy->'name', + 'x', legacy->'inputdata'->'x', + 'y', legacy->'inputdata'->'y', + 'parameters', legacy->'parameters' + ), + 'flags', NULL + ); + + UPDATE experiment + SET algorithm = migrated::text + WHERE uuid = experiment_row.uuid; + END LOOP; +END $$; From 2a98b064ace7bd1161a545cf2cfe9dbd014c6ea9 Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Tue, 15 Sep 2026 16:27:47 +0300 Subject: [PATCH 3/4] fix(algorithm): fail on Exaflow errors and keep the default parameter key Check the HTTP status before parsing specification responses, log the specific failure instead of re-wrapping it, and avoid an NPE when an error response has no body. Emit the Exaflow parameter key "default" through Jackson as well as Gson. --- .../algorithm/ParameterSpecificationDTO.java | 5 +++- .../mip/algorithm/SpecificationsService.java | 11 ++++++-- src/main/java/hbp/mip/utils/HTTPUtil.java | 27 +++++++++---------- .../AlgorithmSpecificationDTOTest.java | 3 +++ 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/main/java/hbp/mip/algorithm/ParameterSpecificationDTO.java b/src/main/java/hbp/mip/algorithm/ParameterSpecificationDTO.java index 6d7a71d0..ea8383e5 100644 --- a/src/main/java/hbp/mip/algorithm/ParameterSpecificationDTO.java +++ b/src/main/java/hbp/mip/algorithm/ParameterSpecificationDTO.java @@ -1,5 +1,6 @@ package hbp.mip.algorithm; +import com.fasterxml.jackson.annotation.JsonProperty; import com.google.gson.annotations.SerializedName; import java.util.List; @@ -10,7 +11,9 @@ public record ParameterSpecificationDTO( List types, Boolean required, Boolean multiple, - @SerializedName("default") Object default_value, + @SerializedName("default") + @JsonProperty("default") + Object default_value, ParameterEnumSpecificationDTO enums, ParameterEnumSpecificationDTO dict_keys_enums, ParameterEnumSpecificationDTO dict_values_enums, diff --git a/src/main/java/hbp/mip/algorithm/SpecificationsService.java b/src/main/java/hbp/mip/algorithm/SpecificationsService.java index ff469f35..fe8d7dc6 100644 --- a/src/main/java/hbp/mip/algorithm/SpecificationsService.java +++ b/src/main/java/hbp/mip/algorithm/SpecificationsService.java @@ -1,5 +1,6 @@ package hbp.mip.algorithm; +import com.google.gson.JsonSyntaxException; import com.google.gson.reflect.TypeToken; import hbp.mip.utils.Exceptions.InternalServerError; import hbp.mip.utils.HTTPUtil; @@ -8,6 +9,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import java.io.IOException; import java.lang.reflect.Type; import java.util.List; @@ -44,13 +46,18 @@ public List getAlgorithmSpecifications(Logger logger) private T fetch(String url, Type typeOfT, String what, Logger logger) { StringBuilder response = new StringBuilder(); try { - HTTPUtil.sendGet(url, response); + int responseCode = HTTPUtil.sendGet(url, response); + if (responseCode != 200) { + throw new InternalServerError( + "Exaflow " + what + " endpoint responded with status " + responseCode + "."); + } + T specification = JsonConverters.convertJsonStringToObject(response.toString(), typeOfT); if (specification == null || (specification instanceof List list && list.isEmpty())) { throw new InternalServerError("Exaflow " + what + " response was empty."); } return specification; - } catch (Exception e) { + } catch (IOException | JsonSyntaxException e) { logger.error("Could not fetch exaflow " + what + ": " + e.getMessage()); throw new InternalServerError("Could not fetch exaflow " + what + "."); } diff --git a/src/main/java/hbp/mip/utils/HTTPUtil.java b/src/main/java/hbp/mip/utils/HTTPUtil.java index 5306edd3..24567fef 100644 --- a/src/main/java/hbp/mip/utils/HTTPUtil.java +++ b/src/main/java/hbp/mip/utils/HTTPUtil.java @@ -3,6 +3,7 @@ import java.io.BufferedReader; import java.io.DataOutputStream; import java.io.IOException; +import java.io.InputStream; import java.io.InputStreamReader; import java.net.HttpURLConnection; import java.net.URL; @@ -15,8 +16,8 @@ private HTTPUtil() { throw new IllegalAccessError("HTTPUtil class"); } - public static void sendGet(String url, StringBuilder resp) throws IOException { - sendHTTP(url, "", resp, "GET"); + public static int sendGet(String url, StringBuilder resp) throws IOException { + return sendHTTP(url, "", resp, "GET"); } public static int sendPost(String url, String query, StringBuilder resp) throws IOException { @@ -45,20 +46,18 @@ private static int sendHTTP(String url, String query, StringBuilder resp, String int respCode = con.getResponseCode(); - BufferedReader in; - if (respCode == 200) { - in = new BufferedReader(new InputStreamReader(con.getInputStream())); - } else { - in = new BufferedReader(new InputStreamReader(con.getErrorStream())); - } - String inputLine; - StringBuilder response = new StringBuilder(); + InputStream stream = respCode == 200 ? con.getInputStream() : con.getErrorStream(); + if (stream != null) { + BufferedReader in = new BufferedReader(new InputStreamReader(stream)); + String inputLine; + StringBuilder response = new StringBuilder(); - while ((inputLine = in.readLine()) != null) { - response.append(inputLine); + while ((inputLine = in.readLine()) != null) { + response.append(inputLine); + } + in.close(); + resp.append(response); } - in.close(); - resp.append(response); return respCode; } diff --git a/src/test/java/hbp/mip/algorithm/AlgorithmSpecificationDTOTest.java b/src/test/java/hbp/mip/algorithm/AlgorithmSpecificationDTOTest.java index 9affcd9a..bd10f132 100644 --- a/src/test/java/hbp/mip/algorithm/AlgorithmSpecificationDTOTest.java +++ b/src/test/java/hbp/mip/algorithm/AlgorithmSpecificationDTOTest.java @@ -38,6 +38,7 @@ void deserializesHeadAlgorithmSpecificationShape() throws Exception { "types": ["dict"], "required": false, "multiple": false, + "default": 5, "min": 0, "max": 10, "dict_keys_enums": { @@ -68,12 +69,14 @@ void deserializesHeadAlgorithmSpecificationShape() throws Exception { assertThat(algorithm.y().min_count()).isEqualTo(1); assertThat(algorithm.y().max_count()).isEqualTo(1); assertThat(algorithm.requires_validation_datasets()).isFalse(); + assertThat(folds.default_value()).isEqualTo(5.0); assertThat(folds.min()).isEqualTo(0.0); assertThat(folds.max()).isEqualTo(10.0); JsonNode serialized = objectMapper.readTree(objectMapper.writeValueAsString(algorithm)); assertThat(serialized.at("/documentation").asText()).isEqualTo("Long algorithm documentation."); assertThat(serialized.at("/parameters/folds/dict_values_type").asText()).isEqualTo("real"); + assertThat(serialized.at("/parameters/folds/default").asDouble()).isEqualTo(5.0); assertThat(serialized.at("/requires_validation_datasets").asBoolean()).isFalse(); } From 617d46a698a1c3942175a97ce115bc32d807deb8 Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Tue, 15 Sep 2026 16:28:01 +0300 Subject: [PATCH 4/4] fix(folder): take the write lock for folder renames and deletes Rename and delete were the only mutations reading the folder without findByIdForUpdate, leaving them exposed to a concurrent delete/set update race. Use the same locked read as the other write paths. --- src/main/java/hbp/mip/folder/ExperimentFolderService.java | 6 +++--- .../java/hbp/mip/folder/ExperimentFolderServiceTest.java | 8 +++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/main/java/hbp/mip/folder/ExperimentFolderService.java b/src/main/java/hbp/mip/folder/ExperimentFolderService.java index cce11c64..09bb4097 100644 --- a/src/main/java/hbp/mip/folder/ExperimentFolderService.java +++ b/src/main/java/hbp/mip/folder/ExperimentFolderService.java @@ -99,7 +99,7 @@ public ExperimentFolderDTO createFolder(Authentication authentication, CreateExp public ExperimentFolderDTO renameFolder(Authentication authentication, String folderId, RenameExperimentFolderDTO request, Logger logger) { var user = activeUserService.getActiveUser(authentication); - ExperimentFolderDAO folder = ownedFolder(folderId, user.username(), logger); + ExperimentFolderDAO folder = ownedFolderForUpdate(folderId, user.username(), logger); String name = requireName(request == null ? null : request.name(), "Folder name", logger); rejectDuplicateFolderName(name, folderRepository.findOwnedFolders(user.username()), folder, logger); @@ -115,7 +115,7 @@ public ExperimentFolderDTO renameFolder(Authentication authentication, String fo @Transactional public void deleteFolder(Authentication authentication, String folderId, Logger logger) { var user = activeUserService.getActiveUser(authentication); - ExperimentFolderDAO folder = ownedFolder(folderId, user.username(), logger); + ExperimentFolderDAO folder = ownedFolderForUpdate(folderId, user.username(), logger); folderRepository.delete(folder); logger.info("Experiment folder deleted. Id: " + folder.getId()); @@ -206,7 +206,7 @@ public ExperimentFolderDTO createSet(Authentication authentication, String folde public ExperimentFolderDTO renameSet(Authentication authentication, String folderId, String setId, RenameExperimentSetDTO request, Logger logger) { var user = activeUserService.getActiveUser(authentication); - ExperimentFolderDAO folder = ownedFolder(folderId, user.username(), logger); + ExperimentFolderDAO folder = ownedFolderForUpdate(folderId, user.username(), logger); ExperimentSetDAO set = ownedSet(folder, setId, logger); String name = requireName(request == null ? null : request.name(), "Set name", logger); diff --git a/src/test/java/hbp/mip/folder/ExperimentFolderServiceTest.java b/src/test/java/hbp/mip/folder/ExperimentFolderServiceTest.java index 4f410402..e503887d 100644 --- a/src/test/java/hbp/mip/folder/ExperimentFolderServiceTest.java +++ b/src/test/java/hbp/mip/folder/ExperimentFolderServiceTest.java @@ -572,7 +572,12 @@ void memberAndSetWritesTakeTheFolderRowLock() { ExperimentDAO first = experiment(); ExperimentDAO second = experiment(); givenReadableExperiment(first, second); + givenOwnedFolders(folder); + service.renameFolder(authentication, folderId.toString(), + new RenameExperimentFolderDTO("Renamed set"), logger); + service.renameSet(authentication, folderId.toString(), set.getId().toString(), + new RenameExperimentSetDTO("Renamed arm"), logger); service.addExperiment(authentication, folderId.toString(), new AddExperimentFoldersMemberDTO(first.getUuid().toString()), logger); service.updateSetMembership(authentication, folderId.toString(), first.getUuid().toString(), @@ -583,8 +588,9 @@ void memberAndSetWritesTakeTheFolderRowLock() { new CreateExperimentSetDTO("Arm B", second.getUuid().toString()), logger); service.removeExperiment(authentication, folderId.toString(), second.getUuid().toString(), logger); service.deleteSet(authentication, folderId.toString(), set.getId().toString(), logger); + service.deleteFolder(authentication, folderId.toString(), logger); - verify(folderRepository, times(6)).findByIdForUpdate(folderId); + verify(folderRepository, times(9)).findByIdForUpdate(folderId); verify(folderRepository, never()).findById(folderId); }