diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/DeterministicIdempotencyKeyGenerator.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/DeterministicIdempotencyKeyGenerator.java new file mode 100644 index 00000000000..6e5d80ac749 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/DeterministicIdempotencyKeyGenerator.java @@ -0,0 +1,112 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.commands.service; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class DeterministicIdempotencyKeyGenerator { + + // Plain ObjectMapper for canonicalization — must NOT use the application ObjectMapper + // which has custom serializers/deserializers that cause failures on certain JSON payloads + private static final ObjectMapper CANONICAL_MAPPER; + + static { + JsonFactory factory = new JsonFactory(); + factory.enable(JsonParser.Feature.ALLOW_SINGLE_QUOTES); + CANONICAL_MAPPER = new ObjectMapper(factory); + } + + public String generate(String json, String context) { + + if (json == null || json.isBlank()) { + // Shouldn't reach here after resolver guard, but defensive fallback + return java.util.UUID.randomUUID().toString(); + } + + String canonical = toCanonicalString(json); + String window = currentTimeWindow(); + return hash(canonical + ":" + context + ":" + window); + } + + private String toCanonicalString(String json) { + try { + JsonNode node = CANONICAL_MAPPER.readTree(json); + JsonNode canonical = canonicalize(node); + return CANONICAL_MAPPER.writeValueAsString(canonical); + } catch (Exception e) { + throw new RuntimeException("Failed to canonicalize JSON", e); + } + } + + private JsonNode canonicalize(JsonNode node) { + if (node.isObject()) { + ObjectNode sorted = CANONICAL_MAPPER.createObjectNode(); + + List fieldNames = new ArrayList<>(); + node.fieldNames().forEachRemaining(fieldNames::add); + Collections.sort(fieldNames); + + for (String field : fieldNames) { + sorted.set(field, canonicalize(node.get(field))); // recursion to resolve nested obj + } + + return sorted; + } + + if (node.isArray()) { + ArrayNode arrayNode = CANONICAL_MAPPER.createArrayNode(); + for (JsonNode element : node) { + arrayNode.add(canonicalize(element)); // recursion inside array + } + return arrayNode; + } + + return node; // primitives + null + } + + private String hash(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashed = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(hashed); + } catch (Exception e) { + throw new RuntimeException("Hashing failed", e); + } + } + + private String currentTimeWindow() { + Instant now = Instant.now(); + long window = now.getEpochSecond() / (5 * 60); + return String.valueOf(window); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/IdempotencyKeyResolver.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/IdempotencyKeyResolver.java index 3787b83c570..c8a983bedef 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/IdempotencyKeyResolver.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/IdempotencyKeyResolver.java @@ -30,10 +30,73 @@ public class IdempotencyKeyResolver { private final FineractRequestContextHolder fineractRequestContextHolder; - private final IdempotencyKeyGenerator idempotencyKeyGenerator; + private final IdempotencyKeyGenerator randomKeyGenerator; + + private final DeterministicIdempotencyKeyGenerator deterministicGenerator; + + public record ResolvedKey(String key, boolean isDeterministic) { + } + + public ResolvedKey resolveWithMeta(CommandWrapper wrapper) { + // 1. Explicit key from wrapper (client-provided header) + if (wrapper.getIdempotencyKey() != null) { + return new ResolvedKey(wrapper.getIdempotencyKey(), false); + } + // 2. Internal retry — key already stored in request context + Optional attributeKey = getAttribute(); + if (attributeKey.isPresent()) { + return new ResolvedKey(attributeKey.get(), false); + } + // 3. No JSON body — cannot hash, use random key + if (wrapper.getJson() == null || wrapper.getJson().isBlank()) { + return new ResolvedKey(randomKeyGenerator.create(), false); + } + // 4. No clientId and no entityId — system-level operation (e.g. global + // config update, business date change). These have no per-caller scope + // so the same payload from different scenarios within the same 5-minute + // window would collide. Fall back to random key to avoid false cache hits. + if (wrapper.getClientId() == null && wrapper.getEntityId() == null && wrapper.getJobName() == null) { + return new ResolvedKey(randomKeyGenerator.create(), false); + } + // 5. Global configuration updates — same configId + same payload (e.g. + // enabled=true) collides across scenarios within the same 5-minute window + // since entityId is the configId not a client-scoped resource. + String href = wrapper.getHref() != null ? wrapper.getHref() : ""; + if (href.startsWith("/configurations/")) { + return new ResolvedKey(randomKeyGenerator.create(), false); + } + + // 6. Job commands — always use random key since jobs must run every invocation + // even with the same payload (e.g. same loan IDs for COB across different business dates) + if (wrapper.getJobName() != null && !wrapper.getJobName().isBlank()) { + return new ResolvedKey(randomKeyGenerator.create(), false); + } + + // 7. Account transfers — the ONLY operation where deterministic idempotency + // is genuinely needed. A network timeout during a transfer means the client + // cannot know if money was moved. Retrying with a random key would create + // a duplicate transfer. Deterministic key ensures the retry returns the + // cached result instead of moving money twice. + String entityName = wrapper.getEntityName() != null ? wrapper.getEntityName().toUpperCase() : ""; + String actionName = wrapper.getActionName() != null ? wrapper.getActionName().toUpperCase() : ""; + boolean isAccountTransfer = actionName.equals("CREATE") && entityName.equals("ACCOUNTTRANSFER"); + if (!isAccountTransfer) { + return new ResolvedKey(randomKeyGenerator.create(), false); + } + + // 8. Account transfer — generate deterministic key to prevent duplicate transfers + String deterministicKey = deterministicGenerator.generate(wrapper.getJson(), buildContext(wrapper)); + fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.IDEMPOTENCY_KEY_ATTRIBUTE, deterministicKey); + return new ResolvedKey(deterministicKey, true); + } public String resolve(CommandWrapper wrapper) { - return Optional.ofNullable(wrapper.getIdempotencyKey()).orElseGet(() -> getAttribute().orElseGet(idempotencyKeyGenerator::create)); + return resolveWithMeta(wrapper).key(); + } + + private String buildContext(CommandWrapper wrapper) { + return wrapper.getActionName() + ":" + wrapper.getEntityName() + ":" + wrapper.getHref() + ":" + wrapper.getClientId() + ":" + + wrapper.getJobName(); } private Optional getAttribute() { diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/SynchronousCommandProcessingService.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/SynchronousCommandProcessingService.java index a13835b6266..87b5b3c29b9 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/SynchronousCommandProcessingService.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/SynchronousCommandProcessingService.java @@ -109,6 +109,7 @@ public CommandProcessingResult executeCommand(final CommandWrapper wrapper, fina CommandSource commandSource = null; String idempotencyKey; + boolean isDeterministicKey = false; if (isRetry) { commandSource = commandSourceService.getCommandSource(commandId); idempotencyKey = commandSource.getIdempotencyKey(); @@ -116,9 +117,12 @@ public CommandProcessingResult executeCommand(final CommandWrapper wrapper, fina commandSource = commandSourceService.getCommandSource(commandId); idempotencyKey = commandSource.getIdempotencyKey(); } else { - idempotencyKey = idempotencyKeyResolver.resolve(wrapper); + IdempotencyKeyResolver.ResolvedKey resolved = idempotencyKeyResolver.resolveWithMeta(wrapper); + idempotencyKey = resolved.key(); + isDeterministicKey = resolved.isDeterministic(); + // idempotencyKey = idempotencyKeyResolver.resolve(wrapper); } - exceptionWhenTheRequestAlreadyProcessed(wrapper, idempotencyKey, isRetry); + exceptionWhenTheRequestAlreadyProcessed(wrapper, idempotencyKey, isRetry, isDeterministicKey); AppUser user = context.authenticatedUser(wrapper); if (commandSource == null) { @@ -218,7 +222,8 @@ private void publishHookErrorEvent(CommandWrapper wrapper, JsonCommand command, } } - private void exceptionWhenTheRequestAlreadyProcessed(CommandWrapper wrapper, String idempotencyKey, boolean retry) { + private void exceptionWhenTheRequestAlreadyProcessed(CommandWrapper wrapper, String idempotencyKey, boolean retry, + boolean isDeterministicKey) { CommandSource command = commandSourceService.findCommandSource(wrapper, idempotencyKey); if (command == null) { return; @@ -234,7 +239,7 @@ private void exceptionWhenTheRequestAlreadyProcessed(CommandWrapper wrapper, Str } case PROCESSED -> throw new IdempotentCommandProcessSucceedException(wrapper, idempotencyKey, command); case ERROR -> { - if (!retry) { + if (!retry && !isDeterministicKey) { throw new IdempotentCommandProcessFailedException(wrapper, idempotencyKey, command); } } diff --git a/fineract-core/src/test/java/org/apache/fineract/commands/service/DeterministicIdempotencyKeyGeneratorTest.java b/fineract-core/src/test/java/org/apache/fineract/commands/service/DeterministicIdempotencyKeyGeneratorTest.java new file mode 100644 index 00000000000..824707d6dfc --- /dev/null +++ b/fineract-core/src/test/java/org/apache/fineract/commands/service/DeterministicIdempotencyKeyGeneratorTest.java @@ -0,0 +1,78 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.commands.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class DeterministicIdempotencyKeyGeneratorTest { + + private final DeterministicIdempotencyKeyGenerator underTest = new DeterministicIdempotencyKeyGenerator(); + + @Test + void shouldGenerateSameKeyForSameInputAndContext() { + String json = "{\"b\":2,\"a\":1}"; + String context = "action:entity:/endpoint:client1"; + + String key1 = underTest.generate(json, context); + String key2 = underTest.generate("{\"a\":1,\"b\":2}", context); + + assertEquals(key1, key2); + } + + @Test + void shouldGenerateDifferentKeysForDifferentContext() { + String json = "{\"a\":1}"; + + String key1 = underTest.generate(json, "context1"); + String key2 = underTest.generate(json, "context2"); + + assertNotEquals(key1, key2); + } + + @Test + void shouldGenerateDifferentKeysForDifferentPayload() { + String context = "same-context"; + + String key1 = underTest.generate("{\"a\":1}", context); + String key2 = underTest.generate("{\"a\":2}", context); + + assertNotEquals(key1, key2); + } + + @Test + void shouldGenerateSameKeyWithinSameTimeWindow() { + String json = "{\"a\":1}"; + String context = "ctx"; + + String key1 = underTest.generate(json, context); + String key2 = underTest.generate(json, context); + + assertEquals(key1, key2); + } + + @Test + void shouldFailForInvalidJson() { + RuntimeException exception = assertThrows(RuntimeException.class, () -> underTest.generate("{invalid-json", "test-context")); + assertEquals("Failed to canonicalize JSON", exception.getMessage()); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/commands/service/IdempotencyKeyResolverTest.java b/fineract-provider/src/test/java/org/apache/fineract/commands/service/IdempotencyKeyResolverTest.java index 4b13d8a9081..8b523e2209d 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/commands/service/IdempotencyKeyResolverTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/commands/service/IdempotencyKeyResolverTest.java @@ -18,6 +18,11 @@ */ package org.apache.fineract.commands.service; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import java.util.HashMap; @@ -36,7 +41,10 @@ public class IdempotencyKeyResolverTest { @Mock - private IdempotencyKeyGenerator idempotencyKeyGenerator; + private DeterministicIdempotencyKeyGenerator deterministicIdempotencyKeyGenerator; + + @Mock + private IdempotencyKeyGenerator randomKeyGenerator; @InjectMocks private IdempotencyKeyResolver underTest; @@ -48,6 +56,7 @@ public class IdempotencyKeyResolverTest { public void setup() { MockitoAnnotations.openMocks(this); BatchRequestContextHolder.setRequestAttributes(new HashMap<>()); + when(randomKeyGenerator.create()).thenReturn("random-key"); } @AfterEach @@ -62,15 +71,7 @@ public void testIPKResolveFromRequest() { CommandWrapper wrapper = CommandWrapper.wrap("act", "ent", 1L, 1L); String resolvedIdk = underTest.resolve(wrapper); Assertions.assertEquals(idk, resolvedIdk); - } - - @Test - public void testIPKResolveFromGenerate() { - String idk = "idk"; - when(idempotencyKeyGenerator.create()).thenReturn(idk); - CommandWrapper wrapper = CommandWrapper.wrap("act", "ent", 1L, 1L); - String resolvedIdk = underTest.resolve(wrapper); - Assertions.assertEquals(idk, resolvedIdk); + verifyNoInteractions(deterministicIdempotencyKeyGenerator); } @Test @@ -80,5 +81,185 @@ public void testIPKResolveFromWrapper() { null, null, null, idk, null, null); String resolvedIdk = underTest.resolve(wrapper); Assertions.assertEquals(idk, resolvedIdk); + verifyNoInteractions(deterministicIdempotencyKeyGenerator); + } + + @Test + void shouldUseHeaderIdempotencyKeyWhenPresent() { + CommandWrapper wrapper = mock(CommandWrapper.class); + when(wrapper.getIdempotencyKey()).thenReturn("header-key"); + + String result = underTest.resolve(wrapper); + + Assertions.assertEquals("header-key", result); + verify(deterministicIdempotencyKeyGenerator, never()).generate(anyString(), anyString()); + } + + @Test + void shouldFallbackToRandomWhenJsonMissing() { + CommandWrapper wrapper = mock(CommandWrapper.class); + when(wrapper.getIdempotencyKey()).thenReturn(null); + when(wrapper.getJson()).thenReturn(null); + when(wrapper.getClientId()).thenReturn(1L); + when(wrapper.getEntityId()).thenReturn(1L); + + String result = underTest.resolve(wrapper); + + Assertions.assertEquals("random-key", result); + verifyNoInteractions(deterministicIdempotencyKeyGenerator); + } + + @Test + void shouldFallbackToRandomWhenNoClientAndEntity() { + CommandWrapper wrapper = mock(CommandWrapper.class); + when(wrapper.getIdempotencyKey()).thenReturn(null); + when(wrapper.getJson()).thenReturn("{\"a\":1}"); + when(wrapper.getClientId()).thenReturn(null); + when(wrapper.getEntityId()).thenReturn(null); + when(wrapper.getJobName()).thenReturn(null); + + String result = underTest.resolve(wrapper); + + Assertions.assertEquals("random-key", result); + verifyNoInteractions(deterministicIdempotencyKeyGenerator); + } + + @Test + void shouldFallbackToRandomForConfigurationsEndpoint() { + CommandWrapper wrapper = mock(CommandWrapper.class); + when(wrapper.getIdempotencyKey()).thenReturn(null); + when(wrapper.getJson()).thenReturn("{\"a\":1}"); + when(wrapper.getClientId()).thenReturn(1L); + when(wrapper.getEntityId()).thenReturn(1L); + when(wrapper.getHref()).thenReturn("/configurations/123"); + + String result = underTest.resolve(wrapper); + + Assertions.assertEquals("random-key", result); + verifyNoInteractions(deterministicIdempotencyKeyGenerator); + } + + @Test + void shouldFallbackToRandomForJobCommands() { + CommandWrapper wrapper = mock(CommandWrapper.class); + when(wrapper.getIdempotencyKey()).thenReturn(null); + when(wrapper.getJson()).thenReturn("{\"a\":1}"); + when(wrapper.getClientId()).thenReturn(null); + when(wrapper.getEntityId()).thenReturn(null); + when(wrapper.getJobName()).thenReturn("LOAN_CLOSE_OF_BUSINESS"); + + IdempotencyKeyResolver.ResolvedKey result = underTest.resolveWithMeta(wrapper); + + Assertions.assertEquals("random-key", result.key()); + Assertions.assertFalse(result.isDeterministic()); + verifyNoInteractions(deterministicIdempotencyKeyGenerator); + } + + @Test + void shouldFallbackToRandomForNonTransferCreate() { + CommandWrapper wrapper = mock(CommandWrapper.class); + when(wrapper.getIdempotencyKey()).thenReturn(null); + when(wrapper.getJson()).thenReturn("{\"name\":\"Test\"}"); + when(wrapper.getClientId()).thenReturn(1L); + when(wrapper.getEntityId()).thenReturn(null); + when(wrapper.getActionName()).thenReturn("CREATE"); + when(wrapper.getEntityName()).thenReturn("CLIENT"); + when(wrapper.getHref()).thenReturn("/clients"); + when(wrapper.getJobName()).thenReturn(null); + + IdempotencyKeyResolver.ResolvedKey result = underTest.resolveWithMeta(wrapper); + + Assertions.assertEquals("random-key", result.key()); + Assertions.assertFalse(result.isDeterministic()); + verifyNoInteractions(deterministicIdempotencyKeyGenerator); + } + + @Test + void shouldFallbackToRandomForNonCreateOperation() { + CommandWrapper wrapper = mock(CommandWrapper.class); + when(wrapper.getIdempotencyKey()).thenReturn(null); + when(wrapper.getJson()).thenReturn("{\"a\":1}"); + when(wrapper.getClientId()).thenReturn(1L); + when(wrapper.getEntityId()).thenReturn(1L); + when(wrapper.getActionName()).thenReturn("UPDATE"); + when(wrapper.getEntityName()).thenReturn("ACCOUNTTRANSFER"); + when(wrapper.getHref()).thenReturn("/accounttransfers/1"); + when(wrapper.getJobName()).thenReturn(null); + + IdempotencyKeyResolver.ResolvedKey result = underTest.resolveWithMeta(wrapper); + + Assertions.assertEquals("random-key", result.key()); + Assertions.assertFalse(result.isDeterministic()); + verifyNoInteractions(deterministicIdempotencyKeyGenerator); + } + + @Test + void shouldMarkDeterministicFalseForRandomFallback() { + CommandWrapper wrapper = mock(CommandWrapper.class); + when(wrapper.getIdempotencyKey()).thenReturn(null); + when(wrapper.getJson()).thenReturn(null); + + IdempotencyKeyResolver.ResolvedKey result = underTest.resolveWithMeta(wrapper); + + Assertions.assertFalse(result.isDeterministic()); + } + + @Test + void shouldUseDeterministicKeyForAccountTransfer() { + CommandWrapper wrapper = mock(CommandWrapper.class); + when(wrapper.getIdempotencyKey()).thenReturn(null); + when(wrapper.getJson()).thenReturn("{\"fromAccountId\":\"1\",\"toAccountId\":\"2\",\"transferAmount\":\"500\"}"); + when(wrapper.getClientId()).thenReturn(1L); + when(wrapper.getEntityId()).thenReturn(null); + when(wrapper.getActionName()).thenReturn("CREATE"); + when(wrapper.getEntityName()).thenReturn("ACCOUNTTRANSFER"); + when(wrapper.getHref()).thenReturn("/accounttransfers"); + when(wrapper.getJobName()).thenReturn(null); + when(deterministicIdempotencyKeyGenerator.generate(anyString(), anyString())).thenReturn("transfer-det-key"); + + IdempotencyKeyResolver.ResolvedKey result = underTest.resolveWithMeta(wrapper); + + Assertions.assertEquals("transfer-det-key", result.key()); + Assertions.assertTrue(result.isDeterministic()); + verify(deterministicIdempotencyKeyGenerator).generate(anyString(), anyString()); + } + + @Test + void shouldMarkDeterministicTrueForAccountTransfer() { + CommandWrapper wrapper = mock(CommandWrapper.class); + when(wrapper.getIdempotencyKey()).thenReturn(null); + when(wrapper.getJson()).thenReturn("{\"fromAccountId\":\"1\",\"toAccountId\":\"2\",\"transferAmount\":\"100\"}"); + when(wrapper.getClientId()).thenReturn(1L); + when(wrapper.getEntityId()).thenReturn(null); + when(wrapper.getActionName()).thenReturn("CREATE"); + when(wrapper.getEntityName()).thenReturn("ACCOUNTTRANSFER"); + when(wrapper.getHref()).thenReturn("/accounttransfers"); + when(wrapper.getJobName()).thenReturn(null); + when(deterministicIdempotencyKeyGenerator.generate(anyString(), anyString())).thenReturn("det-key"); + + IdempotencyKeyResolver.ResolvedKey result = underTest.resolveWithMeta(wrapper); + + Assertions.assertEquals("det-key", result.key()); + Assertions.assertTrue(result.isDeterministic()); + } + + @Test + void shouldReturnSameDeterministicKeyForRetryOfSameTransfer() { + CommandWrapper wrapper = mock(CommandWrapper.class); + when(wrapper.getIdempotencyKey()).thenReturn(null); + when(wrapper.getJson()).thenReturn("{\"fromAccountId\":\"1\",\"toAccountId\":\"2\",\"transferAmount\":\"500\"}"); + when(wrapper.getClientId()).thenReturn(1L); + when(wrapper.getEntityId()).thenReturn(null); + when(wrapper.getActionName()).thenReturn("CREATE"); + when(wrapper.getEntityName()).thenReturn("ACCOUNTTRANSFER"); + when(wrapper.getHref()).thenReturn("/accounttransfers"); + when(wrapper.getJobName()).thenReturn(null); + when(deterministicIdempotencyKeyGenerator.generate(anyString(), anyString())).thenReturn("same-key"); + + String key1 = underTest.resolve(wrapper); + fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.IDEMPOTENCY_KEY_ATTRIBUTE, null); + String key2 = underTest.resolve(wrapper); + + Assertions.assertEquals(key1, key2); } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/IdempotencyTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/IdempotencyTest.java index 70e7534975d..8c5c7ecab3e 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/IdempotencyTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/IdempotencyTest.java @@ -154,6 +154,62 @@ public void shouldTheSecondRequestWithSameIdempotencyKeyWillFailureToo() { assertEquals((Map) body1.jsonPath().get(""), response2.getBody().jsonPath().get("")); } + @Test + public void shouldReuseDeterministicIdempotencyKeyWhenHeaderMissing() { + ResponseSpecification updateResponseSpec = new ResponseSpecBuilder().expectStatusCode(204).build(); + JobBusinessStepConfigData originalStepConfig = IdempotencyHelper.getConfiguredBusinessStepsByJobName(requestSpec, responseSpec, + LOAN_JOB_NAME); + + try { + String requestBody = "{\"businessSteps\":[{\"stepName\":\"APPLY_CHARGE_TO_OVERDUE_LOANS\",\"order\":1}]}"; + + // First request → generate deterministic key from payload + Response response = IdempotencyHelper.updateBusinessStepOrderWithoutIdempotencyKey(requestSpec, updateResponseSpec, + LOAN_JOB_NAME, requestBody); + + // Second request → same payload → same deterministic key + Response responseSecond = IdempotencyHelper.updateBusinessStepOrderWithoutIdempotencyKey(requestSpec, updateResponseSpec, + LOAN_JOB_NAME, requestBody); + + assertEquals(response.getBody().asString(), responseSecond.getBody().asString()); + + } finally { + restoreOriginalStepConfig(updateResponseSpec, originalStepConfig); + } + } + + @Test + public void shouldReuseDeterministicIdempotencyKeyForReorderedJsonWhenHeaderMissing() { + ResponseSpecification updateResponseSpec = new ResponseSpecBuilder().expectStatusCode(204).build(); + JobBusinessStepConfigData originalStepConfig = IdempotencyHelper.getConfiguredBusinessStepsByJobName(requestSpec, responseSpec, + LOAN_JOB_NAME); + + try { + String firstRequestBody = "{\"businessSteps\":[{\"stepName\":\"APPLY_CHARGE_TO_OVERDUE_LOANS\",\"order\":1}," + + "{\"stepName\":\"LOAN_DELINQUENCY_CLASSIFICATION\",\"order\":2}]}"; + String secondRequestBody = "{\"businessSteps\":[{\"order\":1,\"stepName\":\"APPLY_CHARGE_TO_OVERDUE_LOANS\"}," + + "{\"order\":2,\"stepName\":\"LOAN_DELINQUENCY_CLASSIFICATION\"}]}"; + + // Generate deterministic keys based on payload + context + Response response = IdempotencyHelper.updateBusinessStepOrderWithoutIdempotencyKey(requestSpec, updateResponseSpec, + LOAN_JOB_NAME, firstRequestBody); + + Response responseSecond = IdempotencyHelper.updateBusinessStepOrderWithoutIdempotencyKey(requestSpec, updateResponseSpec, + LOAN_JOB_NAME, secondRequestBody); + + // Keys should be same because deterministic generator hashes JSON (ignoring property order if implemented) + assertEquals(response.getBody().asString(), responseSecond.getBody().asString()); + + } finally { + restoreOriginalStepConfig(updateResponseSpec, originalStepConfig); + } + } + + private void restoreOriginalStepConfig(ResponseSpecification updateResponseSpec, JobBusinessStepConfigData originalStepConfig) { + IdempotencyHelper.updateBusinessStepOrder(requestSpec, updateResponseSpec, LOAN_JOB_NAME, + IdempotencyHelper.toJsonString(originalStepConfig.getBusinessSteps()), UUID.randomUUID().toString()); + } + private BusinessStep getBusinessSteps(Long order, String stepName) { BusinessStep businessStep = new BusinessStep(); businessStep.setStepName(stepName); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/IdempotencyHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/IdempotencyHelper.java index 8fa0277e359..f0c5acee166 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/IdempotencyHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/IdempotencyHelper.java @@ -94,9 +94,26 @@ public static JobBusinessStepDetail getAvailableBusinessStepsByJobName(final Req @Deprecated(forRemoval = true) public static Response updateBusinessStepOrder(final RequestSpecification requestSpec, final ResponseSpecification responseSpec, String jobName, String jsonBodyToSend, String idempotencyKey) { + return updateBusinessStepOrder(requestSpec, responseSpec, jobName, jsonBodyToSend, true, idempotencyKey); + } + + @Deprecated(forRemoval = true) + public static Response updateBusinessStepOrderWithoutIdempotencyKey(final RequestSpecification requestSpec, + final ResponseSpecification responseSpec, String jobName, String jsonBodyToSend) { + return updateBusinessStepOrder(requestSpec, responseSpec, jobName, jsonBodyToSend, false, null); + } + + @Deprecated(forRemoval = true) + private static Response updateBusinessStepOrder(final RequestSpecification requestSpec, final ResponseSpecification responseSpec, + String jobName, String jsonBodyToSend, boolean useIdempotencyKey, String idempotencyKey) { Response response = Utils.performServerPutRaw(requestSpec, responseSpec, - BUSINESS_STEPS_API_URL_START + jobName + BUSINESS_STEPS_API_URL_END, - request -> request.header("Idempotency-Key", idempotencyKey).body(jsonBodyToSend)); + BUSINESS_STEPS_API_URL_START + jobName + BUSINESS_STEPS_API_URL_END, request -> { + RequestSpecification mappedRequest = request.body(jsonBodyToSend); + if (useIdempotencyKey) { + mappedRequest = mappedRequest.header("Idempotency-Key", idempotencyKey); + } + return mappedRequest; + }); log.info("BusinessStepConfigurationHelper Response: {}", response.getBody().asString()); return response; } @@ -107,9 +124,27 @@ public static Response updateBusinessStepOrder(final RequestSpecification reques @Deprecated(forRemoval = true) public static Response updateBusinessStepOrderWithError(final RequestSpecification requestSpec, final ResponseSpecification responseSpec, String jobName, String jsonBodyToSend, String idempotencyKey) { + return updateBusinessStepOrderWithError(requestSpec, responseSpec, jobName, jsonBodyToSend, true, idempotencyKey); + } + + @Deprecated(forRemoval = true) + public static Response updateBusinessStepOrderWithErrorWithoutIdempotencyKey(final RequestSpecification requestSpec, + final ResponseSpecification responseSpec, String jobName, String jsonBodyToSend) { + return updateBusinessStepOrderWithError(requestSpec, responseSpec, jobName, jsonBodyToSend, false, null); + } + + @Deprecated(forRemoval = true) + private static Response updateBusinessStepOrderWithError(final RequestSpecification requestSpec, + final ResponseSpecification responseSpec, String jobName, String jsonBodyToSend, boolean useIdempotencyKey, + String idempotencyKey) { String url = BUSINESS_STEPS_API_URL_START + jobName + BUSINESS_STEPS_API_URL_END; - return Utils.performServerPutRaw(requestSpec, responseSpec, url, - request -> request.header("Idempotency-Key", idempotencyKey).body(jsonBodyToSend)); + return Utils.performServerPutRaw(requestSpec, responseSpec, url, request -> { + RequestSpecification mappedRequest = request.body(jsonBodyToSend); + if (useIdempotencyKey) { + mappedRequest = mappedRequest.header("Idempotency-Key", idempotencyKey); + } + return mappedRequest; + }); } private static final class BusinessStepWrapper {