diff --git a/api-gateway-iam/api-gateway-iam/src/integration-test/java/com/stablecoin/payments/gateway/iam/AbstractIntegrationTest.java b/api-gateway-iam/api-gateway-iam/src/integration-test/java/com/stablecoin/payments/gateway/iam/AbstractIntegrationTest.java index 45a86661..713a004e 100644 --- a/api-gateway-iam/api-gateway-iam/src/integration-test/java/com/stablecoin/payments/gateway/iam/AbstractIntegrationTest.java +++ b/api-gateway-iam/api-gateway-iam/src/integration-test/java/com/stablecoin/payments/gateway/iam/AbstractIntegrationTest.java @@ -36,6 +36,11 @@ public abstract class AbstractIntegrationTest { POSTGRES.start(); KAFKA.start(); REDIS.start(); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + REDIS.stop(); + KAFKA.stop(); + POSTGRES.stop(); + })); } @Autowired @@ -50,6 +55,7 @@ void cleanDatabase() { oauth_clients, rate_limit_events, gateway_audit_log, + gatewayiam_idempotency_keys, merchants CASCADE """); diff --git a/api-gateway-iam/api-gateway-iam/src/integration-test/java/com/stablecoin/payments/gateway/iam/application/config/IdempotencyKeyFilterIT.java b/api-gateway-iam/api-gateway-iam/src/integration-test/java/com/stablecoin/payments/gateway/iam/application/config/IdempotencyKeyFilterIT.java new file mode 100644 index 00000000..a4bbfb1d --- /dev/null +++ b/api-gateway-iam/api-gateway-iam/src/integration-test/java/com/stablecoin/payments/gateway/iam/application/config/IdempotencyKeyFilterIT.java @@ -0,0 +1,143 @@ +package com.stablecoin.payments.gateway.iam.application.config; + +import com.stablecoin.payments.gateway.iam.AbstractIntegrationTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.MockMvc; + +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DisplayName("IdempotencyKeyFilter IT") +class IdempotencyKeyFilterIT extends AbstractIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private static final String MERCHANT_ENDPOINT = "/v1/merchants"; + + @Test + @DisplayName("should persist idempotency key after successful mutation") + void shouldPersistIdempotencyKey_afterSuccessfulMutation() throws Exception { + var idempotencyKey = UUID.randomUUID().toString(); + var externalId = UUID.randomUUID(); + + mockMvc.perform(post(MERCHANT_ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Idempotency-Key", idempotencyKey) + .content(""" + { + "externalId": "%s", + "name": "Idempotency Test Corp", + "country": "US" + } + """.formatted(externalId))) + .andExpect(status().isCreated()); + + var count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM gatewayiam_idempotency_keys WHERE idempotency_key = ?", + Integer.class, idempotencyKey); + + assertThat(count).isEqualTo(1); + } + + @Test + @DisplayName("should replay response on duplicate request with same key and body") + void shouldReplayResponse_onDuplicateRequest() throws Exception { + var idempotencyKey = UUID.randomUUID().toString(); + var externalId = UUID.randomUUID(); + var requestBody = """ + { + "externalId": "%s", + "name": "Replay Test Corp", + "country": "US" + } + """.formatted(externalId); + + // First request + var firstResponse = mockMvc.perform(post(MERCHANT_ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Idempotency-Key", idempotencyKey) + .content(requestBody)) + .andExpect(status().isCreated()) + .andReturn(); + + // Second request — same key, same body + var secondResponse = mockMvc.perform(post(MERCHANT_ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Idempotency-Key", idempotencyKey) + .content(requestBody)) + .andExpect(status().isCreated()) + .andExpect(header().string("Idempotency-Replay", "true")) + .andReturn(); + + assertThat(secondResponse.getResponse().getContentAsString()) + .isEqualTo(firstResponse.getResponse().getContentAsString()); + } + + @Test + @DisplayName("should return 422 when same key but different body") + void shouldReturn422_whenSameKeyDifferentBody() throws Exception { + var idempotencyKey = UUID.randomUUID().toString(); + + // First request + mockMvc.perform(post(MERCHANT_ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Idempotency-Key", idempotencyKey) + .content(""" + { + "externalId": "%s", + "name": "First Corp", + "country": "US" + } + """.formatted(UUID.randomUUID()))) + .andExpect(status().isCreated()); + + // Second request — same key, different body + mockMvc.perform(post(MERCHANT_ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Idempotency-Key", idempotencyKey) + .content(""" + { + "externalId": "%s", + "name": "Different Corp", + "country": "GB" + } + """.formatted(UUID.randomUUID()))) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @DisplayName("should delete expired keys when cleanup job runs") + void shouldDeleteExpiredKeys_whenCleanupJobRuns() throws Exception { + // Insert an expired idempotency key directly + jdbcTemplate.update( + "INSERT INTO gatewayiam_idempotency_keys" + + " (idempotency_key, request_method, request_path, request_hash, response_body, status_code, expires_at)" + + " VALUES (?, ?, ?, ?, ?, ?, ?)", + "expired-key", "POST", "/v1/merchants", "somehash", "{}", 200, + Timestamp.from(Instant.now().minus(1, ChronoUnit.HOURS))); + + var cleanupJob = new com.stablecoin.payments.gateway.iam.application.job.IdempotencyCleanupJob(jdbcTemplate); + cleanupJob.cleanExpiredKeys(); + + var count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM gatewayiam_idempotency_keys WHERE idempotency_key = ?", + Integer.class, "expired-key"); + + assertThat(count).isEqualTo(0); + } +} diff --git a/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/config/IdempotencyKeyFilter.java b/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/config/IdempotencyKeyFilter.java index 6101a477..0bcd88b8 100644 --- a/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/config/IdempotencyKeyFilter.java +++ b/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/config/IdempotencyKeyFilter.java @@ -1,22 +1,43 @@ package com.stablecoin.payments.gateway.iam.application.config; import jakarta.servlet.FilterChain; +import jakarta.servlet.ReadListener; import jakarta.servlet.ServletException; +import jakarta.servlet.ServletInputStream; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.core.annotation.Order; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingResponseWrapper; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.HexFormat; import java.util.Set; /** * Enforces presence of {@code Idempotency-Key} header on state-mutating endpoints - * (POST, PATCH, DELETE) — excluding auth, JWKS, and actuator endpoints. + * (POST, PATCH, DELETE) -- excluding auth, JWKS, and actuator endpoints. + * Uses INSERT-first reservation pattern to prevent TOCTOU races: + * 1. Try INSERT with status_code=0 (reservation) + * 2. If INSERT succeeds, proceed with request and UPDATE with real response + * 3. If INSERT fails (duplicate), re-read stored record for replay or conflict */ @Slf4j @Component @@ -24,31 +45,80 @@ public class IdempotencyKeyFilter extends OncePerRequestFilter { public static final String IDEMPOTENCY_KEY_HEADER = "Idempotency-Key"; + public static final String IDEMPOTENCY_REPLAY_HEADER = "Idempotency-Replay"; - private static final Set MUTATING_METHODS = Set.of("POST", "PATCH", "DELETE"); + private static final String TABLE_NAME = "gatewayiam_idempotency_keys"; + private static final Set MUTATING_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE"); private static final Set EXEMPT_PREFIXES = Set.of( "/v1/auth/", "/.well-known/", "/actuator/" ); + private final JdbcTemplate jdbcTemplate; + private final long ttlHours; + + public IdempotencyKeyFilter(JdbcTemplate jdbcTemplate, + @Value("${app.idempotency.ttl-hours:24}") long ttlHours) { + this.jdbcTemplate = jdbcTemplate; + this.ttlHours = ttlHours; + } + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { - if (requiresIdempotencyKey(request)) { - var key = request.getHeader(IDEMPOTENCY_KEY_HEADER); - if (key == null || key.isBlank()) { - log.info("Missing Idempotency-Key header for {} {}", request.getMethod(), request.getRequestURI()); - response.setStatus(HttpStatus.BAD_REQUEST.value()); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.getWriter().write( - "{\"code\":\"GW-0001\",\"status\":\"Bad Request\"," + - "\"message\":\"Idempotency-Key header is required for mutating requests\",\"errors\":{}}"); + if (!requiresIdempotencyKey(request)) { + chain.doFilter(request, response); + return; + } + + var key = request.getHeader(IDEMPOTENCY_KEY_HEADER); + if (key == null || key.isBlank()) { + log.info("Missing Idempotency-Key header for {} {}", request.getMethod(), request.getRequestURI()); + response.setStatus(HttpStatus.BAD_REQUEST.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write( + "{\"code\":\"GW-0001\",\"status\":\"Bad Request\"," + + "\"message\":\"Idempotency-Key header is required for mutating requests\"," + + "\"errors\":{}}"); + return; + } + + var method = request.getMethod(); + var path = request.getRequestURI(); + var bodyBytes = request.getInputStream().readAllBytes(); + var requestHash = computeSha256(bodyBytes); + + var reserved = reserveIdempotencyKey(key, method, path, requestHash); + if (reserved) { + var replayableRequest = new CachedBodyRequestWrapper(request, bodyBytes); + var wrappedResponse = new ContentCachingResponseWrapper(response); + try { + chain.doFilter(replayableRequest, wrappedResponse); + finalizeIdempotencyKey(key, method, path, wrappedResponse); + wrappedResponse.copyBodyToResponse(); + } catch (Exception e) { + deleteReservation(key, method, path); + throw e; + } + return; + } + + var existing = lookupIdempotencyKey(key, method, path); + if (existing == null) { + writeConflictError(response); + return; + } + if (existing.requestHash().equals(requestHash)) { + if (existing.statusCode() == 0) { + writeConflictError(response); return; } + replayResponse(response, existing); + return; } - chain.doFilter(request, response); + writeHashMismatchError(response); } private boolean requiresIdempotencyKey(HttpServletRequest request) { @@ -60,4 +130,175 @@ private boolean requiresIdempotencyKey(HttpServletRequest request) { var path = contextPath.isEmpty() ? uri : uri.substring(contextPath.length()); return EXEMPT_PREFIXES.stream().noneMatch(path::startsWith); } + + boolean reserveIdempotencyKey(String key, String method, String path, String requestHash) { + try { + jdbcTemplate.update( + "INSERT INTO " + TABLE_NAME + + " (idempotency_key, request_method, request_path, request_hash," + + " response_body, status_code, expires_at)" + + " VALUES (?, ?, ?, ?, '', 0, ?)", + key, method, path, requestHash, + Timestamp.from(Instant.now().plus(ttlHours, ChronoUnit.HOURS))); + return true; + } catch (DataIntegrityViolationException e) { + int deleted = jdbcTemplate.update( + "DELETE FROM " + TABLE_NAME + + " WHERE idempotency_key = ? AND request_method = ? AND request_path = ?" + + " AND expires_at <= NOW()", + key, method, path); + if (deleted > 0) { + try { + jdbcTemplate.update( + "INSERT INTO " + TABLE_NAME + + " (idempotency_key, request_method, request_path, request_hash," + + " response_body, status_code, expires_at)" + + " VALUES (?, ?, ?, ?, '', 0, ?)", + key, method, path, requestHash, + Timestamp.from(Instant.now().plus(ttlHours, ChronoUnit.HOURS))); + return true; + } catch (DataIntegrityViolationException retryEx) { + return false; + } + } + return false; + } + } + + IdempotencyRecord lookupIdempotencyKey(String key, String method, String path) { + var results = jdbcTemplate.query( + "SELECT idempotency_key, request_hash, response_body, status_code FROM " + TABLE_NAME + + " WHERE idempotency_key = ? AND request_method = ? AND request_path = ?" + + " AND expires_at > NOW()", + (rs, rowNum) -> new IdempotencyRecord( + rs.getString("idempotency_key"), + rs.getString("request_hash"), + rs.getString("response_body"), + rs.getInt("status_code")), + key, method, path); + return results.isEmpty() ? null : results.getFirst(); + } + + void finalizeIdempotencyKey(String key, String method, String path, + ContentCachingResponseWrapper wrappedResponse) { + var statusCode = wrappedResponse.getStatus(); + if (statusCode < 200 || statusCode >= 300) { + deleteReservation(key, method, path); + return; + } + var responseBody = new String(wrappedResponse.getContentAsByteArray(), StandardCharsets.UTF_8); + var expiresAt = Timestamp.from(Instant.now().plus(ttlHours, ChronoUnit.HOURS)); + + jdbcTemplate.update( + "UPDATE " + TABLE_NAME + + " SET response_body = ?, status_code = ?, expires_at = ?" + + " WHERE idempotency_key = ? AND request_method = ? AND request_path = ?", + responseBody, statusCode, expiresAt, key, method, path); + } + + private void deleteReservation(String key, String method, String path) { + jdbcTemplate.update( + "DELETE FROM " + TABLE_NAME + + " WHERE idempotency_key = ? AND request_method = ? AND request_path = ?", + key, method, path); + } + + private void replayResponse(HttpServletResponse response, IdempotencyRecord record) + throws IOException { + log.info("Replaying idempotent response"); + response.setStatus(record.statusCode()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setHeader(IDEMPOTENCY_REPLAY_HEADER, "true"); + response.getWriter().write(record.responseBody()); + } + + private void writeHashMismatchError(HttpServletResponse response) throws IOException { + response.setStatus(HttpStatus.UNPROCESSABLE_ENTITY.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write( + "{\"code\":\"GW-0002\",\"status\":\"Unprocessable Entity\"," + + "\"message\":\"Idempotency-Key has already been used with a different request payload\"," + + "\"errors\":{}}"); + } + + private void writeConflictError(HttpServletResponse response) throws IOException { + response.setStatus(HttpStatus.CONFLICT.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write( + "{\"code\":\"GW-0003\",\"status\":\"Conflict\"," + + "\"message\":\"A request with this Idempotency-Key is already in progress\"," + + "\"errors\":{}}"); + } + + private String computeSha256(byte[] body) { + try { + var digest = MessageDigest.getInstance("SHA-256"); + var hash = digest.digest(body); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + record IdempotencyRecord(String idempotencyKey, String requestHash, String responseBody, int statusCode) {} + + /** + * Request wrapper that replays a cached body so downstream filters and + * controllers can read the request body after it has already been consumed. + */ + private static class CachedBodyRequestWrapper extends HttpServletRequestWrapper { + + private final byte[] body; + + CachedBodyRequestWrapper(HttpServletRequest request, byte[] body) { + super(request); + this.body = body; + } + + @Override + public ServletInputStream getInputStream() { + var byteStream = new ByteArrayInputStream(body); + return new ServletInputStream() { + @Override + public boolean isFinished() { + return byteStream.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener readListener) { + throw new UnsupportedOperationException(); + } + + @Override + public int read() { + return byteStream.read(); + } + + @Override + public int read(byte[] b, int off, int len) { + return byteStream.read(b, off, len); + } + }; + } + + @Override + public BufferedReader getReader() { + return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); + } + + @Override + public int getContentLength() { + return body.length; + } + + @Override + public long getContentLengthLong() { + return body.length; + } + } } diff --git a/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/job/IdempotencyCleanupJob.java b/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/job/IdempotencyCleanupJob.java new file mode 100644 index 00000000..b87202a0 --- /dev/null +++ b/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/job/IdempotencyCleanupJob.java @@ -0,0 +1,37 @@ +package com.stablecoin.payments.gateway.iam.application.job; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@ConditionalOnProperty(name = "app.idempotency.cleanup.enabled", havingValue = "true", matchIfMissing = true) +@RequiredArgsConstructor +public class IdempotencyCleanupJob { + + private final JdbcTemplate jdbcTemplate; + + @Scheduled(cron = "${app.idempotency.cleanup-cron:0 0 * * * *}") + public void cleanExpiredKeys() { + int totalDeleted = 0; + int deleted; + do { + deleted = jdbcTemplate.update( + "WITH expired AS (" + + "SELECT idempotency_key, request_method, request_path " + + "FROM gatewayiam_idempotency_keys WHERE expires_at < NOW() LIMIT 1000" + + ") DELETE FROM gatewayiam_idempotency_keys USING expired " + + "WHERE gatewayiam_idempotency_keys.idempotency_key = expired.idempotency_key " + + "AND gatewayiam_idempotency_keys.request_method = expired.request_method " + + "AND gatewayiam_idempotency_keys.request_path = expired.request_path"); + totalDeleted += deleted; + } while (deleted >= 1000); + if (totalDeleted > 0) { + log.info("Cleaned up {} expired idempotency keys", totalDeleted); + } + } +} diff --git a/api-gateway-iam/api-gateway-iam/src/main/resources/application-integration-test.yml b/api-gateway-iam/api-gateway-iam/src/main/resources/application-integration-test.yml index f3b324bf..7d54d3e0 100644 --- a/api-gateway-iam/api-gateway-iam/src/main/resources/application-integration-test.yml +++ b/api-gateway-iam/api-gateway-iam/src/main/resources/application-integration-test.yml @@ -1,6 +1,10 @@ app: security: enabled: false + idempotency: + ttl-hours: 24 + cleanup: + enabled: false spring: jpa: diff --git a/api-gateway-iam/api-gateway-iam/src/main/resources/application.yml b/api-gateway-iam/api-gateway-iam/src/main/resources/application.yml index 85b72635..43e40088 100644 --- a/api-gateway-iam/api-gateway-iam/src/main/resources/application.yml +++ b/api-gateway-iam/api-gateway-iam/src/main/resources/application.yml @@ -54,6 +54,13 @@ spring: brokers: ${KAFKA_BROKERS:localhost:9092} auto-create-topics: false +app: + idempotency: + ttl-hours: 24 + cleanup-cron: "0 0 * * * *" + cleanup: + enabled: true + api-gateway-iam: jwt: private-key-base64: ${JWT_PRIVATE_KEY_BASE64:} diff --git a/api-gateway-iam/api-gateway-iam/src/main/resources/db/migration/V8__create_idempotency_keys_table.sql b/api-gateway-iam/api-gateway-iam/src/main/resources/db/migration/V8__create_idempotency_keys_table.sql new file mode 100644 index 00000000..e2fdcd1f --- /dev/null +++ b/api-gateway-iam/api-gateway-iam/src/main/resources/db/migration/V8__create_idempotency_keys_table.sql @@ -0,0 +1,10 @@ +CREATE TABLE gatewayiam_idempotency_keys ( + idempotency_key VARCHAR(255) PRIMARY KEY, + request_hash VARCHAR(64) NOT NULL, + response_body TEXT NOT NULL DEFAULT '', + status_code INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX idx_gatewayiam_idempotency_expires ON gatewayiam_idempotency_keys (expires_at); diff --git a/api-gateway-iam/api-gateway-iam/src/main/resources/db/migration/V9__scope_idempotency_keys.sql b/api-gateway-iam/api-gateway-iam/src/main/resources/db/migration/V9__scope_idempotency_keys.sql new file mode 100644 index 00000000..d5d21ef1 --- /dev/null +++ b/api-gateway-iam/api-gateway-iam/src/main/resources/db/migration/V9__scope_idempotency_keys.sql @@ -0,0 +1,6 @@ +ALTER TABLE gatewayiam_idempotency_keys DROP CONSTRAINT gatewayiam_idempotency_keys_pkey; +ALTER TABLE gatewayiam_idempotency_keys ADD COLUMN request_method VARCHAR(10) NOT NULL DEFAULT 'POST'; +ALTER TABLE gatewayiam_idempotency_keys ADD COLUMN request_path VARCHAR(512) NOT NULL DEFAULT ''; +ALTER TABLE gatewayiam_idempotency_keys ADD PRIMARY KEY (idempotency_key, request_method, request_path); +ALTER TABLE gatewayiam_idempotency_keys ALTER COLUMN request_method DROP DEFAULT; +ALTER TABLE gatewayiam_idempotency_keys ALTER COLUMN request_path DROP DEFAULT; diff --git a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/config/IdempotencyKeyFilterTest.java b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/config/IdempotencyKeyFilterTest.java new file mode 100644 index 00000000..725e9e68 --- /dev/null +++ b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/config/IdempotencyKeyFilterTest.java @@ -0,0 +1,193 @@ +package com.stablecoin.payments.gateway.iam.application.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.jdbc.core.JdbcTemplate; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; + +@ExtendWith(MockitoExtension.class) +@DisplayName("IdempotencyKeyFilter") +class IdempotencyKeyFilterTest { + + @Mock + private JdbcTemplate jdbcTemplate; + + @Mock + private FilterChain filterChain; + + private IdempotencyKeyFilter filter; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + private static final String REQUEST_BODY = "{\"name\":\"test\"}"; + private static final String REQUEST_HASH = computeHash(REQUEST_BODY.getBytes(StandardCharsets.UTF_8)); + + @BeforeEach + void setUp() { + filter = spy(new IdempotencyKeyFilter(jdbcTemplate, 24)); + request = new MockHttpServletRequest("POST", "/v1/merchants"); + response = new MockHttpServletResponse(); + } + + @Nested + @DisplayName("Missing Idempotency-Key") + class MissingKey { + + @Test + @DisplayName("should return 400 when Idempotency-Key header is missing") + void shouldReturn400WhenIdempotencyKeyMissing() throws ServletException, IOException { + filter.doFilterInternal(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(400); + assertThat(response.getContentAsString()).contains("GW-0001"); + } + } + + @Nested + @DisplayName("Replay stored response") + class ReplayResponse { + + @Test + @DisplayName("should replay stored response when reservation fails and hash matches") + void shouldReplayStoredResponse_whenSameKeyAndSameHash() throws ServletException, IOException { + request.addHeader("Idempotency-Key", "key-123"); + request.setContent(REQUEST_BODY.getBytes(StandardCharsets.UTF_8)); + + doReturn(false).when(filter).reserveIdempotencyKey("key-123", "POST", "/v1/merchants", REQUEST_HASH); + doReturn(new IdempotencyKeyFilter.IdempotencyRecord("key-123", REQUEST_HASH, "{\"id\":\"abc\"}", 201)) + .when(filter).lookupIdempotencyKey("key-123", "POST", "/v1/merchants"); + + filter.doFilterInternal(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(201); + assertThat(response.getContentAsString()).isEqualTo("{\"id\":\"abc\"}"); + assertThat(response.getHeader("Idempotency-Replay")).isEqualTo("true"); + then(filterChain).shouldHaveNoInteractions(); + } + } + + @Nested + @DisplayName("Hash mismatch") + class HashMismatch { + + @Test + @DisplayName("should return 422 when reservation fails and hash differs") + void shouldReturn422WhenSameKeyDifferentHash() throws ServletException, IOException { + request.addHeader("Idempotency-Key", "key-456"); + request.setContent(REQUEST_BODY.getBytes(StandardCharsets.UTF_8)); + + doReturn(false).when(filter).reserveIdempotencyKey("key-456", "POST", "/v1/merchants", REQUEST_HASH); + doReturn(new IdempotencyKeyFilter.IdempotencyRecord("key-456", "different-hash", "{}", 201)) + .when(filter).lookupIdempotencyKey("key-456", "POST", "/v1/merchants"); + + filter.doFilterInternal(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(422); + assertThat(response.getContentAsString()).contains("GW-0002"); + then(filterChain).shouldHaveNoInteractions(); + } + } + + @Nested + @DisplayName("Reservation succeeds — proceed and finalize") + class ProceedAndPersist { + + @Test + @DisplayName("should proceed with chain when reservation succeeds") + void shouldProceedAndFinalize_whenReservationSucceeds() throws ServletException, IOException { + request.addHeader("Idempotency-Key", "key-789"); + request.setContent(REQUEST_BODY.getBytes(StandardCharsets.UTF_8)); + + doReturn(true).when(filter).reserveIdempotencyKey("key-789", "POST", "/v1/merchants", REQUEST_HASH); + + filter.doFilterInternal(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()).doesNotContain("GW-0001", "GW-0002", "GW-0003"); + } + } + + @Nested + @DisplayName("PUT requests") + class PutRequests { + + @Test + @DisplayName("should enforce idempotency check for PUT requests") + void shouldEnforceIdempotencyCheckForPutRequests() throws ServletException, IOException { + request = new MockHttpServletRequest("PUT", "/v1/merchants"); + + filter.doFilterInternal(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(400); + assertThat(response.getContentAsString()).contains("GW-0001"); + } + } + + @Nested + @DisplayName("GET requests") + class GetRequests { + + @Test + @DisplayName("should skip idempotency check for GET requests") + void shouldSkipIdempotencyCheckForGetRequests() throws ServletException, IOException { + request = new MockHttpServletRequest("GET", "/v1/merchants"); + + filter.doFilterInternal(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(200); + then(filterChain).should().doFilter(request, response); + then(jdbcTemplate).shouldHaveNoInteractions(); + } + } + + @Nested + @DisplayName("Conflict — in-flight request") + class InFlightConflict { + + @Test + @DisplayName("should return 409 when reservation fails and stored record has status_code=0") + void shouldReturn409WhenRequestInFlight() throws ServletException, IOException { + request.addHeader("Idempotency-Key", "key-inflight"); + request.setContent(REQUEST_BODY.getBytes(StandardCharsets.UTF_8)); + + doReturn(false).when(filter).reserveIdempotencyKey("key-inflight", "POST", "/v1/merchants", REQUEST_HASH); + doReturn(new IdempotencyKeyFilter.IdempotencyRecord("key-inflight", REQUEST_HASH, "", 0)) + .when(filter).lookupIdempotencyKey("key-inflight", "POST", "/v1/merchants"); + + filter.doFilterInternal(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(409); + assertThat(response.getContentAsString()).contains("GW-0003"); + then(filterChain).shouldHaveNoInteractions(); + } + } + + private static String computeHash(byte[] body) { + try { + var digest = MessageDigest.getInstance("SHA-256"); + var hash = digest.digest(body); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } +} diff --git a/blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/AbstractIntegrationTest.java b/blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/AbstractIntegrationTest.java index 91c3aa1d..8b0f203e 100644 --- a/blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/AbstractIntegrationTest.java +++ b/blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/AbstractIntegrationTest.java @@ -30,6 +30,10 @@ public abstract class AbstractIntegrationTest { static { POSTGRES.start(); KAFKA.start(); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + KAFKA.stop(); + POSTGRES.stop(); + })); } @Autowired diff --git a/compliance-travel-rule/compliance-travel-rule/src/integration-test/java/com/stablecoin/payments/compliance/AbstractIntegrationTest.java b/compliance-travel-rule/compliance-travel-rule/src/integration-test/java/com/stablecoin/payments/compliance/AbstractIntegrationTest.java index aa453d00..9bc4b2c9 100644 --- a/compliance-travel-rule/compliance-travel-rule/src/integration-test/java/com/stablecoin/payments/compliance/AbstractIntegrationTest.java +++ b/compliance-travel-rule/compliance-travel-rule/src/integration-test/java/com/stablecoin/payments/compliance/AbstractIntegrationTest.java @@ -30,6 +30,10 @@ public abstract class AbstractIntegrationTest { static { POSTGRES.start(); KAFKA.start(); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + KAFKA.stop(); + POSTGRES.stop(); + })); } @Autowired diff --git a/fiat-off-ramp/fiat-off-ramp/src/integration-test/java/com/stablecoin/payments/offramp/AbstractIntegrationTest.java b/fiat-off-ramp/fiat-off-ramp/src/integration-test/java/com/stablecoin/payments/offramp/AbstractIntegrationTest.java index 3e3769dc..1de0c74f 100644 --- a/fiat-off-ramp/fiat-off-ramp/src/integration-test/java/com/stablecoin/payments/offramp/AbstractIntegrationTest.java +++ b/fiat-off-ramp/fiat-off-ramp/src/integration-test/java/com/stablecoin/payments/offramp/AbstractIntegrationTest.java @@ -30,6 +30,10 @@ public abstract class AbstractIntegrationTest { static { POSTGRES.start(); KAFKA.start(); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + KAFKA.stop(); + POSTGRES.stop(); + })); } @Autowired diff --git a/fiat-on-ramp/fiat-on-ramp/src/integration-test/java/com/stablecoin/payments/onramp/AbstractIntegrationTest.java b/fiat-on-ramp/fiat-on-ramp/src/integration-test/java/com/stablecoin/payments/onramp/AbstractIntegrationTest.java index eb9a44a7..6a9d7ddd 100644 --- a/fiat-on-ramp/fiat-on-ramp/src/integration-test/java/com/stablecoin/payments/onramp/AbstractIntegrationTest.java +++ b/fiat-on-ramp/fiat-on-ramp/src/integration-test/java/com/stablecoin/payments/onramp/AbstractIntegrationTest.java @@ -30,6 +30,10 @@ public abstract class AbstractIntegrationTest { static { POSTGRES.start(); KAFKA.start(); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + KAFKA.stop(); + POSTGRES.stop(); + })); } @Autowired diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/integration-test/java/com/stablecoin/payments/fx/AbstractIntegrationTest.java b/fx-liquidity-engine/fx-liquidity-engine/src/integration-test/java/com/stablecoin/payments/fx/AbstractIntegrationTest.java index 688af1b6..a0cd5ae2 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/integration-test/java/com/stablecoin/payments/fx/AbstractIntegrationTest.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/integration-test/java/com/stablecoin/payments/fx/AbstractIntegrationTest.java @@ -32,6 +32,10 @@ public abstract class AbstractIntegrationTest { static { POSTGRES.start(); KAFKA.start(); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + KAFKA.stop(); + POSTGRES.stop(); + })); } @Autowired diff --git a/ledger-accounting/ledger-accounting/src/integration-test/java/com/stablecoin/payments/ledger/AbstractIntegrationTest.java b/ledger-accounting/ledger-accounting/src/integration-test/java/com/stablecoin/payments/ledger/AbstractIntegrationTest.java index 44d0e309..e6d32b53 100644 --- a/ledger-accounting/ledger-accounting/src/integration-test/java/com/stablecoin/payments/ledger/AbstractIntegrationTest.java +++ b/ledger-accounting/ledger-accounting/src/integration-test/java/com/stablecoin/payments/ledger/AbstractIntegrationTest.java @@ -30,6 +30,10 @@ public abstract class AbstractIntegrationTest { static { POSTGRES.start(); KAFKA.start(); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + KAFKA.stop(); + POSTGRES.stop(); + })); } @Autowired diff --git a/merchant-iam/merchant-iam/src/integration-test/java/com/stablecoin/payments/merchant/iam/AbstractIntegrationTest.java b/merchant-iam/merchant-iam/src/integration-test/java/com/stablecoin/payments/merchant/iam/AbstractIntegrationTest.java index fcd5765b..eb3453ce 100644 --- a/merchant-iam/merchant-iam/src/integration-test/java/com/stablecoin/payments/merchant/iam/AbstractIntegrationTest.java +++ b/merchant-iam/merchant-iam/src/integration-test/java/com/stablecoin/payments/merchant/iam/AbstractIntegrationTest.java @@ -50,6 +50,12 @@ public abstract class AbstractIntegrationTest { KAFKA.start(); MAILPIT.start(); REDIS.start(); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + REDIS.stop(); + MAILPIT.stop(); + KAFKA.stop(); + POSTGRES.stop(); + })); } @Autowired @@ -64,6 +70,7 @@ void cleanDatabase() { jdbcTemplate.execute("DELETE FROM role_permissions"); jdbcTemplate.execute("DELETE FROM roles"); jdbcTemplate.execute("DELETE FROM permission_audit_log"); + jdbcTemplate.execute("DELETE FROM merchantiam_idempotency_keys"); } /** diff --git a/merchant-iam/merchant-iam/src/integration-test/java/com/stablecoin/payments/merchant/iam/application/config/IdempotencyKeyFilterIT.java b/merchant-iam/merchant-iam/src/integration-test/java/com/stablecoin/payments/merchant/iam/application/config/IdempotencyKeyFilterIT.java new file mode 100644 index 00000000..d660c536 --- /dev/null +++ b/merchant-iam/merchant-iam/src/integration-test/java/com/stablecoin/payments/merchant/iam/application/config/IdempotencyKeyFilterIT.java @@ -0,0 +1,157 @@ +package com.stablecoin.payments.merchant.iam.application.config; + +import com.stablecoin.payments.merchant.iam.AbstractIntegrationTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.MockMvc; + +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DisplayName("IdempotencyKeyFilter IT") +class IdempotencyKeyFilterIT extends AbstractIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private UUID merchantId; + + @BeforeEach + void seedBuiltInRoles() { + jdbcTemplate.execute("DELETE FROM merchantiam_idempotency_keys"); + merchantId = UUID.randomUUID(); + + // Seed an ADMIN role so createRole controller can resolve caller + var adminRoleId = UUID.randomUUID(); + jdbcTemplate.update(""" + INSERT INTO roles (role_id, merchant_id, role_name, description, is_builtin, is_active, created_at, updated_at) + VALUES (?, ?, 'ADMIN', 'Administrator', true, true, NOW(), NOW()) + """, adminRoleId, merchantId); + + jdbcTemplate.update( + "INSERT INTO role_permissions (role_permission_id, role_id, permission, created_at) VALUES (?, ?, '*:*', NOW())", + UUID.randomUUID(), adminRoleId); + + jdbcTemplate.update(""" + INSERT INTO merchant_users (user_id, merchant_id, email, email_hash, full_name, status, + role_id, mfa_enabled, auth_provider, created_at, updated_at, activated_at, version) + VALUES (?, ?, ?, 'admin-hash-idem', 'Admin', 'ACTIVE', + ?, false, 'LOCAL', NOW(), NOW(), NOW(), 0) + """, UUID.fromString("00000000-0000-0000-0000-000000000001"), + merchantId, "admin-idem@acme.com".getBytes(), adminRoleId); + } + + @Test + @DisplayName("should persist idempotency key after successful mutation") + void shouldPersistIdempotencyKey_afterSuccessfulMutation() throws Exception { + var idempotencyKey = UUID.randomUUID().toString(); + + mockMvc.perform(withIdempotencyKey(idempotencyKey, + post("/v1/merchants/{merchantId}/roles", merchantId) + .contentType(MediaType.APPLICATION_JSON) + .content(roleRequestBody("FINANCE_OPS")))) + .andExpect(status().isCreated()); + + var count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM merchantiam_idempotency_keys WHERE idempotency_key = ?", + Integer.class, idempotencyKey); + + assertThat(count).isEqualTo(1); + } + + @Test + @DisplayName("should replay response on duplicate request with same key and body") + void shouldReplayResponse_onDuplicateRequest() throws Exception { + var idempotencyKey = UUID.randomUUID().toString(); + var requestBody = roleRequestBody("REPLAY_ROLE"); + + // First request + var firstResponse = mockMvc.perform(withIdempotencyKey(idempotencyKey, + post("/v1/merchants/{merchantId}/roles", merchantId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody))) + .andExpect(status().isCreated()) + .andReturn(); + + // Second request — same key, same body + var secondResponse = mockMvc.perform(withIdempotencyKey(idempotencyKey, + post("/v1/merchants/{merchantId}/roles", merchantId) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody))) + .andExpect(status().isCreated()) + .andExpect(header().string("Idempotency-Replay", "true")) + .andReturn(); + + assertThat(secondResponse.getResponse().getContentAsString()) + .isEqualTo(firstResponse.getResponse().getContentAsString()); + } + + @Test + @DisplayName("should return 422 when same key but different body") + void shouldReturn422_whenSameKeyDifferentBody() throws Exception { + var idempotencyKey = UUID.randomUUID().toString(); + + // First request + mockMvc.perform(withIdempotencyKey(idempotencyKey, + post("/v1/merchants/{merchantId}/roles", merchantId) + .contentType(MediaType.APPLICATION_JSON) + .content(roleRequestBody("FIRST_ROLE")))) + .andExpect(status().isCreated()); + + // Second request — same key, different body + mockMvc.perform(withIdempotencyKey(idempotencyKey, + post("/v1/merchants/{merchantId}/roles", merchantId) + .contentType(MediaType.APPLICATION_JSON) + .content(roleRequestBody("DIFFERENT_ROLE")))) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @DisplayName("should delete expired keys when cleanup job runs") + void shouldDeleteExpiredKeys_whenCleanupJobRuns() throws Exception { + jdbcTemplate.update( + "INSERT INTO merchantiam_idempotency_keys" + + " (idempotency_key, request_method, request_path, request_hash, response_body, status_code, expires_at)" + + " VALUES (?, ?, ?, ?, ?, ?, ?)", + "expired-key", "POST", "/v1/merchants/test/roles", "somehash", "{}", 200, + Timestamp.from(Instant.now().minus(1, ChronoUnit.HOURS))); + + var cleanupJob = new com.stablecoin.payments.merchant.iam.application.job.IdempotencyCleanupJob(jdbcTemplate); + cleanupJob.cleanExpiredKeys(); + + var count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM merchantiam_idempotency_keys WHERE idempotency_key = ?", + Integer.class, "expired-key"); + + assertThat(count).isEqualTo(0); + } + + private static org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder withIdempotencyKey( + String key, org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder builder) { + return builder.header("Idempotency-Key", key); + } + + private String roleRequestBody(String roleName) { + return """ + { + "roleName": "%s", + "description": "Test role", + "permissions": ["payments:read"] + } + """.formatted(roleName); + } +} diff --git a/merchant-iam/merchant-iam/src/integration-test/resources/application-integration-test.yml b/merchant-iam/merchant-iam/src/integration-test/resources/application-integration-test.yml index 7db031c2..b7e9968c 100644 --- a/merchant-iam/merchant-iam/src/integration-test/resources/application-integration-test.yml +++ b/merchant-iam/merchant-iam/src/integration-test/resources/application-integration-test.yml @@ -22,6 +22,12 @@ spring: binder: brokers: localhost:9092 +app: + idempotency: + ttl-hours: 24 + cleanup: + enabled: false + logging: level: com.stablecoin.payments: DEBUG diff --git a/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/application/config/IdempotencyKeyFilter.java b/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/application/config/IdempotencyKeyFilter.java index d0abfc1f..8cbdc230 100644 --- a/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/application/config/IdempotencyKeyFilter.java +++ b/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/application/config/IdempotencyKeyFilter.java @@ -1,23 +1,44 @@ package com.stablecoin.payments.merchant.iam.application.config; import jakarta.servlet.FilterChain; +import jakarta.servlet.ReadListener; import jakarta.servlet.ServletException; +import jakarta.servlet.ServletInputStream; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.core.annotation.Order; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingResponseWrapper; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.HexFormat; import java.util.Set; /** * Enforces presence of {@code Idempotency-Key} header on state-mutating endpoints - * (POST, PATCH, DELETE) — excluding auth and invitation endpoints which use + * (POST, PATCH, DELETE) -- excluding auth and invitation endpoints which use * tokens as implicit idempotency controls. + * Uses INSERT-first reservation pattern to prevent TOCTOU races: + * 1. Try INSERT with status_code=0 (reservation) + * 2. If INSERT succeeds, proceed with request and UPDATE with real response + * 3. If INSERT fails (duplicate), re-read stored record for replay or conflict */ @Slf4j @Component @@ -25,8 +46,10 @@ public class IdempotencyKeyFilter extends OncePerRequestFilter { public static final String IDEMPOTENCY_KEY_HEADER = "Idempotency-Key"; + public static final String IDEMPOTENCY_REPLAY_HEADER = "Idempotency-Replay"; - private static final Set MUTATING_METHODS = Set.of("POST", "PATCH", "DELETE"); + private static final String TABLE_NAME = "merchantiam_idempotency_keys"; + private static final Set MUTATING_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE"); private static final Set EXEMPT_PREFIXES = Set.of( "/v1/auth/", "/v1/invitations/", @@ -39,23 +62,70 @@ public class IdempotencyKeyFilter extends OncePerRequestFilter { "/auth/login", "/auth/refresh", "/auth/logout", "/auth/mfa" ); + private final JdbcTemplate jdbcTemplate; + private final long ttlHours; + + public IdempotencyKeyFilter(JdbcTemplate jdbcTemplate, + @Value("${app.idempotency.ttl-hours:24}") long ttlHours) { + this.jdbcTemplate = jdbcTemplate; + this.ttlHours = ttlHours; + } + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { - if (requiresIdempotencyKey(request)) { - var key = request.getHeader(IDEMPOTENCY_KEY_HEADER); - if (key == null || key.isBlank()) { - log.info("Missing Idempotency-Key header for {} {}", request.getMethod(), request.getRequestURI()); - response.setStatus(HttpStatus.BAD_REQUEST.value()); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.getWriter().write( - "{\"code\":\"IAM-0001\",\"status\":\"Bad Request\"," + - "\"message\":\"Idempotency-Key header is required for mutating requests\",\"errors\":{}}"); + if (!requiresIdempotencyKey(request)) { + chain.doFilter(request, response); + return; + } + + var key = request.getHeader(IDEMPOTENCY_KEY_HEADER); + if (key == null || key.isBlank()) { + log.info("Missing Idempotency-Key header for {} {}", request.getMethod(), request.getRequestURI()); + response.setStatus(HttpStatus.BAD_REQUEST.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write( + "{\"code\":\"IAM-0001\",\"status\":\"Bad Request\"," + + "\"message\":\"Idempotency-Key header is required for mutating requests\"," + + "\"errors\":{}}"); + return; + } + + var method = request.getMethod(); + var path = request.getRequestURI(); + var bodyBytes = request.getInputStream().readAllBytes(); + var requestHash = computeSha256(bodyBytes); + + var reserved = reserveIdempotencyKey(key, method, path, requestHash); + if (reserved) { + var replayableRequest = new CachedBodyRequestWrapper(request, bodyBytes); + var wrappedResponse = new ContentCachingResponseWrapper(response); + try { + chain.doFilter(replayableRequest, wrappedResponse); + finalizeIdempotencyKey(key, method, path, wrappedResponse); + wrappedResponse.copyBodyToResponse(); + } catch (Exception e) { + deleteReservation(key, method, path); + throw e; + } + return; + } + + var existing = lookupIdempotencyKey(key, method, path); + if (existing == null) { + writeConflictError(response); + return; + } + if (existing.requestHash().equals(requestHash)) { + if (existing.statusCode() == 0) { + writeConflictError(response); return; } + replayResponse(response, existing); + return; } - chain.doFilter(request, response); + writeHashMismatchError(response); } private boolean requiresIdempotencyKey(HttpServletRequest request) { @@ -71,4 +141,175 @@ private boolean requiresIdempotencyKey(HttpServletRequest request) { // Exempt merchant-scoped auth endpoints: /v1/merchants/{id}/auth/login etc. return AUTH_PATH_SEGMENTS.stream().noneMatch(path::contains); } + + boolean reserveIdempotencyKey(String key, String method, String path, String requestHash) { + try { + jdbcTemplate.update( + "INSERT INTO " + TABLE_NAME + + " (idempotency_key, request_method, request_path, request_hash," + + " response_body, status_code, expires_at)" + + " VALUES (?, ?, ?, ?, '', 0, ?)", + key, method, path, requestHash, + Timestamp.from(Instant.now().plus(ttlHours, ChronoUnit.HOURS))); + return true; + } catch (DataIntegrityViolationException e) { + int deleted = jdbcTemplate.update( + "DELETE FROM " + TABLE_NAME + + " WHERE idempotency_key = ? AND request_method = ? AND request_path = ?" + + " AND expires_at <= NOW()", + key, method, path); + if (deleted > 0) { + try { + jdbcTemplate.update( + "INSERT INTO " + TABLE_NAME + + " (idempotency_key, request_method, request_path, request_hash," + + " response_body, status_code, expires_at)" + + " VALUES (?, ?, ?, ?, '', 0, ?)", + key, method, path, requestHash, + Timestamp.from(Instant.now().plus(ttlHours, ChronoUnit.HOURS))); + return true; + } catch (DataIntegrityViolationException retryEx) { + return false; + } + } + return false; + } + } + + IdempotencyRecord lookupIdempotencyKey(String key, String method, String path) { + var results = jdbcTemplate.query( + "SELECT idempotency_key, request_hash, response_body, status_code FROM " + TABLE_NAME + + " WHERE idempotency_key = ? AND request_method = ? AND request_path = ?" + + " AND expires_at > NOW()", + (rs, rowNum) -> new IdempotencyRecord( + rs.getString("idempotency_key"), + rs.getString("request_hash"), + rs.getString("response_body"), + rs.getInt("status_code")), + key, method, path); + return results.isEmpty() ? null : results.getFirst(); + } + + void finalizeIdempotencyKey(String key, String method, String path, + ContentCachingResponseWrapper wrappedResponse) { + var statusCode = wrappedResponse.getStatus(); + if (statusCode < 200 || statusCode >= 300) { + deleteReservation(key, method, path); + return; + } + var responseBody = new String(wrappedResponse.getContentAsByteArray(), StandardCharsets.UTF_8); + var expiresAt = Timestamp.from(Instant.now().plus(ttlHours, ChronoUnit.HOURS)); + + jdbcTemplate.update( + "UPDATE " + TABLE_NAME + + " SET response_body = ?, status_code = ?, expires_at = ?" + + " WHERE idempotency_key = ? AND request_method = ? AND request_path = ?", + responseBody, statusCode, expiresAt, key, method, path); + } + + private void deleteReservation(String key, String method, String path) { + jdbcTemplate.update( + "DELETE FROM " + TABLE_NAME + + " WHERE idempotency_key = ? AND request_method = ? AND request_path = ?", + key, method, path); + } + + private void replayResponse(HttpServletResponse response, IdempotencyRecord record) + throws IOException { + log.info("Replaying idempotent response"); + response.setStatus(record.statusCode()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setHeader(IDEMPOTENCY_REPLAY_HEADER, "true"); + response.getWriter().write(record.responseBody()); + } + + private void writeHashMismatchError(HttpServletResponse response) throws IOException { + response.setStatus(HttpStatus.UNPROCESSABLE_ENTITY.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write( + "{\"code\":\"IAM-0002\",\"status\":\"Unprocessable Entity\"," + + "\"message\":\"Idempotency-Key has already been used with a different request payload\"," + + "\"errors\":{}}"); + } + + private void writeConflictError(HttpServletResponse response) throws IOException { + response.setStatus(HttpStatus.CONFLICT.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write( + "{\"code\":\"IAM-0003\",\"status\":\"Conflict\"," + + "\"message\":\"A request with this Idempotency-Key is already in progress\"," + + "\"errors\":{}}"); + } + + private String computeSha256(byte[] body) { + try { + var digest = MessageDigest.getInstance("SHA-256"); + var hash = digest.digest(body); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + record IdempotencyRecord(String idempotencyKey, String requestHash, String responseBody, int statusCode) {} + + /** + * Request wrapper that replays a cached body so downstream filters and + * controllers can read the request body after it has already been consumed. + */ + private static class CachedBodyRequestWrapper extends HttpServletRequestWrapper { + + private final byte[] body; + + CachedBodyRequestWrapper(HttpServletRequest request, byte[] body) { + super(request); + this.body = body; + } + + @Override + public ServletInputStream getInputStream() { + var byteStream = new ByteArrayInputStream(body); + return new ServletInputStream() { + @Override + public boolean isFinished() { + return byteStream.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener readListener) { + throw new UnsupportedOperationException(); + } + + @Override + public int read() { + return byteStream.read(); + } + + @Override + public int read(byte[] b, int off, int len) { + return byteStream.read(b, off, len); + } + }; + } + + @Override + public BufferedReader getReader() { + return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); + } + + @Override + public int getContentLength() { + return body.length; + } + + @Override + public long getContentLengthLong() { + return body.length; + } + } } diff --git a/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/application/job/IdempotencyCleanupJob.java b/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/application/job/IdempotencyCleanupJob.java new file mode 100644 index 00000000..ecf0c904 --- /dev/null +++ b/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/application/job/IdempotencyCleanupJob.java @@ -0,0 +1,37 @@ +package com.stablecoin.payments.merchant.iam.application.job; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@ConditionalOnProperty(name = "app.idempotency.cleanup.enabled", havingValue = "true", matchIfMissing = true) +@RequiredArgsConstructor +public class IdempotencyCleanupJob { + + private final JdbcTemplate jdbcTemplate; + + @Scheduled(cron = "${app.idempotency.cleanup-cron:0 0 * * * *}") + public void cleanExpiredKeys() { + int totalDeleted = 0; + int deleted; + do { + deleted = jdbcTemplate.update( + "WITH expired AS (" + + "SELECT idempotency_key, request_method, request_path " + + "FROM merchantiam_idempotency_keys WHERE expires_at < NOW() LIMIT 1000" + + ") DELETE FROM merchantiam_idempotency_keys USING expired " + + "WHERE merchantiam_idempotency_keys.idempotency_key = expired.idempotency_key " + + "AND merchantiam_idempotency_keys.request_method = expired.request_method " + + "AND merchantiam_idempotency_keys.request_path = expired.request_path"); + totalDeleted += deleted; + } while (deleted >= 1000); + if (totalDeleted > 0) { + log.info("Cleaned up {} expired idempotency keys", totalDeleted); + } + } +} diff --git a/merchant-iam/merchant-iam/src/main/resources/application.yml b/merchant-iam/merchant-iam/src/main/resources/application.yml index fdfcee66..eb5a6404 100644 --- a/merchant-iam/merchant-iam/src/main/resources/application.yml +++ b/merchant-iam/merchant-iam/src/main/resources/application.yml @@ -66,6 +66,13 @@ spring: starttls: enable: false +app: + idempotency: + ttl-hours: 24 + cleanup-cron: "0 0 * * * *" + cleanup: + enabled: true + merchant-iam: jwt: private-key-base64: ${JWT_PRIVATE_KEY_BASE64:} diff --git a/merchant-iam/merchant-iam/src/main/resources/db/migration/V10__create_idempotency_keys_table.sql b/merchant-iam/merchant-iam/src/main/resources/db/migration/V10__create_idempotency_keys_table.sql new file mode 100644 index 00000000..0b727f36 --- /dev/null +++ b/merchant-iam/merchant-iam/src/main/resources/db/migration/V10__create_idempotency_keys_table.sql @@ -0,0 +1,10 @@ +CREATE TABLE merchantiam_idempotency_keys ( + idempotency_key VARCHAR(255) PRIMARY KEY, + request_hash VARCHAR(64) NOT NULL, + response_body TEXT NOT NULL DEFAULT '', + status_code INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX idx_merchantiam_idempotency_expires ON merchantiam_idempotency_keys (expires_at); diff --git a/merchant-iam/merchant-iam/src/main/resources/db/migration/V11__scope_idempotency_keys.sql b/merchant-iam/merchant-iam/src/main/resources/db/migration/V11__scope_idempotency_keys.sql new file mode 100644 index 00000000..f746ac83 --- /dev/null +++ b/merchant-iam/merchant-iam/src/main/resources/db/migration/V11__scope_idempotency_keys.sql @@ -0,0 +1,6 @@ +ALTER TABLE merchantiam_idempotency_keys DROP CONSTRAINT merchantiam_idempotency_keys_pkey; +ALTER TABLE merchantiam_idempotency_keys ADD COLUMN request_method VARCHAR(10) NOT NULL DEFAULT 'POST'; +ALTER TABLE merchantiam_idempotency_keys ADD COLUMN request_path VARCHAR(512) NOT NULL DEFAULT ''; +ALTER TABLE merchantiam_idempotency_keys ADD PRIMARY KEY (idempotency_key, request_method, request_path); +ALTER TABLE merchantiam_idempotency_keys ALTER COLUMN request_method DROP DEFAULT; +ALTER TABLE merchantiam_idempotency_keys ALTER COLUMN request_path DROP DEFAULT; diff --git a/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/application/config/IdempotencyKeyFilterTest.java b/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/application/config/IdempotencyKeyFilterTest.java new file mode 100644 index 00000000..0ae94c2e --- /dev/null +++ b/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/application/config/IdempotencyKeyFilterTest.java @@ -0,0 +1,193 @@ +package com.stablecoin.payments.merchant.iam.application.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.jdbc.core.JdbcTemplate; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; + +@ExtendWith(MockitoExtension.class) +@DisplayName("IdempotencyKeyFilter") +class IdempotencyKeyFilterTest { + + @Mock + private JdbcTemplate jdbcTemplate; + + @Mock + private FilterChain filterChain; + + private IdempotencyKeyFilter filter; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + private static final String REQUEST_BODY = "{\"name\":\"test\"}"; + private static final String REQUEST_HASH = computeHash(REQUEST_BODY.getBytes(StandardCharsets.UTF_8)); + + @BeforeEach + void setUp() { + filter = spy(new IdempotencyKeyFilter(jdbcTemplate, 24)); + request = new MockHttpServletRequest("POST", "/v1/merchants/123/roles"); + response = new MockHttpServletResponse(); + } + + @Nested + @DisplayName("Missing Idempotency-Key") + class MissingKey { + + @Test + @DisplayName("should return 400 when Idempotency-Key header is missing") + void shouldReturn400WhenIdempotencyKeyMissing() throws ServletException, IOException { + filter.doFilterInternal(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(400); + assertThat(response.getContentAsString()).contains("IAM-0001"); + } + } + + @Nested + @DisplayName("Replay stored response") + class ReplayResponse { + + @Test + @DisplayName("should replay stored response when reservation fails and hash matches") + void shouldReplayStoredResponse_whenSameKeyAndSameHash() throws ServletException, IOException { + request.addHeader("Idempotency-Key", "key-123"); + request.setContent(REQUEST_BODY.getBytes(StandardCharsets.UTF_8)); + + doReturn(false).when(filter).reserveIdempotencyKey("key-123", "POST", "/v1/merchants/123/roles", REQUEST_HASH); + doReturn(new IdempotencyKeyFilter.IdempotencyRecord("key-123", REQUEST_HASH, "{\"id\":\"abc\"}", 201)) + .when(filter).lookupIdempotencyKey("key-123", "POST", "/v1/merchants/123/roles"); + + filter.doFilterInternal(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(201); + assertThat(response.getContentAsString()).isEqualTo("{\"id\":\"abc\"}"); + assertThat(response.getHeader("Idempotency-Replay")).isEqualTo("true"); + then(filterChain).shouldHaveNoInteractions(); + } + } + + @Nested + @DisplayName("Hash mismatch") + class HashMismatch { + + @Test + @DisplayName("should return 422 when reservation fails and hash differs") + void shouldReturn422WhenSameKeyDifferentHash() throws ServletException, IOException { + request.addHeader("Idempotency-Key", "key-456"); + request.setContent(REQUEST_BODY.getBytes(StandardCharsets.UTF_8)); + + doReturn(false).when(filter).reserveIdempotencyKey("key-456", "POST", "/v1/merchants/123/roles", REQUEST_HASH); + doReturn(new IdempotencyKeyFilter.IdempotencyRecord("key-456", "different-hash", "{}", 201)) + .when(filter).lookupIdempotencyKey("key-456", "POST", "/v1/merchants/123/roles"); + + filter.doFilterInternal(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(422); + assertThat(response.getContentAsString()).contains("IAM-0002"); + then(filterChain).shouldHaveNoInteractions(); + } + } + + @Nested + @DisplayName("Reservation succeeds — proceed and finalize") + class ProceedAndPersist { + + @Test + @DisplayName("should proceed with chain when reservation succeeds") + void shouldProceedAndFinalize_whenReservationSucceeds() throws ServletException, IOException { + request.addHeader("Idempotency-Key", "key-789"); + request.setContent(REQUEST_BODY.getBytes(StandardCharsets.UTF_8)); + + doReturn(true).when(filter).reserveIdempotencyKey("key-789", "POST", "/v1/merchants/123/roles", REQUEST_HASH); + + filter.doFilterInternal(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()).doesNotContain("IAM-0001", "IAM-0002", "IAM-0003"); + } + } + + @Nested + @DisplayName("PUT requests") + class PutRequests { + + @Test + @DisplayName("should enforce idempotency check for PUT requests") + void shouldEnforceIdempotencyCheckForPutRequests() throws ServletException, IOException { + request = new MockHttpServletRequest("PUT", "/v1/merchants/123/roles"); + + filter.doFilterInternal(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(400); + assertThat(response.getContentAsString()).contains("IAM-0001"); + } + } + + @Nested + @DisplayName("GET requests") + class GetRequests { + + @Test + @DisplayName("should skip idempotency check for GET requests") + void shouldSkipIdempotencyCheckForGetRequests() throws ServletException, IOException { + request = new MockHttpServletRequest("GET", "/v1/merchants/123/roles"); + + filter.doFilterInternal(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(200); + then(filterChain).should().doFilter(request, response); + then(jdbcTemplate).shouldHaveNoInteractions(); + } + } + + @Nested + @DisplayName("Conflict — in-flight request") + class InFlightConflict { + + @Test + @DisplayName("should return 409 when reservation fails and stored record has status_code=0") + void shouldReturn409WhenRequestInFlight() throws ServletException, IOException { + request.addHeader("Idempotency-Key", "key-inflight"); + request.setContent(REQUEST_BODY.getBytes(StandardCharsets.UTF_8)); + + doReturn(false).when(filter).reserveIdempotencyKey("key-inflight", "POST", "/v1/merchants/123/roles", REQUEST_HASH); + doReturn(new IdempotencyKeyFilter.IdempotencyRecord("key-inflight", REQUEST_HASH, "", 0)) + .when(filter).lookupIdempotencyKey("key-inflight", "POST", "/v1/merchants/123/roles"); + + filter.doFilterInternal(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(409); + assertThat(response.getContentAsString()).contains("IAM-0003"); + then(filterChain).shouldHaveNoInteractions(); + } + } + + private static String computeHash(byte[] body) { + try { + var digest = MessageDigest.getInstance("SHA-256"); + var hash = digest.digest(body); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } +} diff --git a/merchant-onboarding/merchant-onboarding/src/integration-test/java/com/stablecoin/payments/merchant/onboarding/AbstractIntegrationTest.java b/merchant-onboarding/merchant-onboarding/src/integration-test/java/com/stablecoin/payments/merchant/onboarding/AbstractIntegrationTest.java index b4bbfc16..3fbc7a62 100644 --- a/merchant-onboarding/merchant-onboarding/src/integration-test/java/com/stablecoin/payments/merchant/onboarding/AbstractIntegrationTest.java +++ b/merchant-onboarding/merchant-onboarding/src/integration-test/java/com/stablecoin/payments/merchant/onboarding/AbstractIntegrationTest.java @@ -30,6 +30,10 @@ public abstract class AbstractIntegrationTest { static { POSTGRES.start(); KAFKA.start(); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + KAFKA.stop(); + POSTGRES.stop(); + })); } @DynamicPropertySource diff --git a/merchant-onboarding/merchant-onboarding/src/integration-test/java/com/stablecoin/payments/merchant/onboarding/application/config/IdempotencyKeyFilterIT.java b/merchant-onboarding/merchant-onboarding/src/integration-test/java/com/stablecoin/payments/merchant/onboarding/application/config/IdempotencyKeyFilterIT.java new file mode 100644 index 00000000..61e81b2f --- /dev/null +++ b/merchant-onboarding/merchant-onboarding/src/integration-test/java/com/stablecoin/payments/merchant/onboarding/application/config/IdempotencyKeyFilterIT.java @@ -0,0 +1,186 @@ +package com.stablecoin.payments.merchant.onboarding.application.config; + +import com.stablecoin.payments.merchant.onboarding.AbstractIntegrationTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DisplayName("IdempotencyKeyFilter IT") +class IdempotencyKeyFilterIT extends AbstractIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private static final String MERCHANT_ENDPOINT = "/api/v1/merchants"; + + @BeforeEach + void cleanIdempotencyKeys() { + jdbcTemplate.execute("DELETE FROM onboarding_idempotency_keys"); + } + + @Test + @DisplayName("should persist idempotency key after successful mutation") + @WithMockUser(authorities = "merchant:write") + void shouldPersistIdempotencyKey_afterSuccessfulMutation() throws Exception { + var idempotencyKey = UUID.randomUUID().toString(); + + mockMvc.perform(post(MERCHANT_ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Idempotency-Key", idempotencyKey) + .content(merchantRequestBody())) + .andExpect(status().isCreated()); + + var count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM onboarding_idempotency_keys WHERE idempotency_key = ?", + Integer.class, idempotencyKey); + + assertThat(count).isEqualTo(1); + } + + @Test + @DisplayName("should replay response on duplicate request with same key and body") + @WithMockUser(authorities = "merchant:write") + void shouldReplayResponse_onDuplicateRequest() throws Exception { + var idempotencyKey = UUID.randomUUID().toString(); + var requestBody = merchantRequestBody(); + + // First request + var firstResponse = mockMvc.perform(post(MERCHANT_ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Idempotency-Key", idempotencyKey) + .content(requestBody)) + .andExpect(status().isCreated()) + .andReturn(); + + // Second request — same key, same body + var secondResponse = mockMvc.perform(post(MERCHANT_ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Idempotency-Key", idempotencyKey) + .content(requestBody)) + .andExpect(status().isCreated()) + .andExpect(header().string("Idempotency-Replay", "true")) + .andReturn(); + + assertThat(secondResponse.getResponse().getContentAsString()) + .isEqualTo(firstResponse.getResponse().getContentAsString()); + } + + @Test + @DisplayName("should return 422 when same key but different body") + @WithMockUser(authorities = "merchant:write") + void shouldReturn422_whenSameKeyDifferentBody() throws Exception { + var idempotencyKey = UUID.randomUUID().toString(); + + // First request + mockMvc.perform(post(MERCHANT_ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Idempotency-Key", idempotencyKey) + .content(merchantRequestBody())) + .andExpect(status().isCreated()); + + // Second request — same key, different body + mockMvc.perform(post(MERCHANT_ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Idempotency-Key", idempotencyKey) + .content(differentMerchantRequestBody())) + .andExpect(status().isUnprocessableEntity()); + } + + @Test + @DisplayName("should delete expired keys when cleanup job runs") + void shouldDeleteExpiredKeys_whenCleanupJobRuns() throws Exception { + jdbcTemplate.update( + "INSERT INTO onboarding_idempotency_keys" + + " (idempotency_key, request_method, request_path, request_hash, response_body, status_code, expires_at)" + + " VALUES (?, ?, ?, ?, ?, ?, ?)", + "expired-key", "POST", "/api/v1/merchants", "somehash", "{}", 200, + Timestamp.from(Instant.now().minus(1, ChronoUnit.HOURS))); + + var cleanupJob = new com.stablecoin.payments.merchant.onboarding.application.job.IdempotencyCleanupJob(jdbcTemplate); + cleanupJob.cleanExpiredKeys(); + + var count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM onboarding_idempotency_keys WHERE idempotency_key = ?", + Integer.class, "expired-key"); + + assertThat(count).isEqualTo(0); + } + + private String merchantRequestBody() { + return """ + { + "legalName": "Test Corp %s", + "tradingName": "TestCo", + "registrationNumber": "REG-%s", + "registrationCountry": "GB", + "entityType": "PRIVATE_LIMITED", + "websiteUrl": "https://testcorp.com", + "primaryCurrency": "USD", + "primaryContactEmail": "john@testcorp.com", + "primaryContactName": "John Doe", + "registeredAddress": { + "streetLine1": "1 Test Street", + "city": "London", + "postcode": "EC1A 1BB", + "country": "GB" + }, + "beneficialOwners": [{ + "fullName": "John Doe", + "dateOfBirth": "1985-01-15", + "nationality": "GB", + "ownershipPct": 100.00, + "isPoliticallyExposed": false + }], + "requestedCorridors": ["GB->US"] + } + """.formatted(UUID.randomUUID(), UUID.randomUUID()); + } + + private String differentMerchantRequestBody() { + return """ + { + "legalName": "Different Corp %s", + "tradingName": "DiffCo", + "registrationNumber": "REG-%s", + "registrationCountry": "US", + "entityType": "PRIVATE_LIMITED", + "websiteUrl": "https://diffcorp.com", + "primaryCurrency": "EUR", + "primaryContactEmail": "jane@diffcorp.com", + "primaryContactName": "Jane Doe", + "registeredAddress": { + "streetLine1": "2 Other Street", + "city": "New York", + "postcode": "10001", + "country": "US" + }, + "beneficialOwners": [{ + "fullName": "Jane Doe", + "dateOfBirth": "1990-06-20", + "nationality": "US", + "ownershipPct": 100.00, + "isPoliticallyExposed": false + }], + "requestedCorridors": ["US->GB"] + } + """.formatted(UUID.randomUUID(), UUID.randomUUID()); + } +} diff --git a/merchant-onboarding/merchant-onboarding/src/integration-test/resources/application-integration-test.yml b/merchant-onboarding/merchant-onboarding/src/integration-test/resources/application-integration-test.yml index d77e896c..9648b182 100644 --- a/merchant-onboarding/merchant-onboarding/src/integration-test/resources/application-integration-test.yml +++ b/merchant-onboarding/merchant-onboarding/src/integration-test/resources/application-integration-test.yml @@ -37,6 +37,10 @@ outbox: app: fallback-adapters: enabled: true + idempotency: + ttl-hours: 24 + cleanup: + enabled: false server: port: 0 diff --git a/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/config/IdempotencyKeyFilter.java b/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/config/IdempotencyKeyFilter.java index 439193d3..4420832e 100644 --- a/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/config/IdempotencyKeyFilter.java +++ b/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/config/IdempotencyKeyFilter.java @@ -1,22 +1,43 @@ package com.stablecoin.payments.merchant.onboarding.application.config; import jakarta.servlet.FilterChain; +import jakarta.servlet.ReadListener; import jakarta.servlet.ServletException; +import jakarta.servlet.ServletInputStream; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.core.annotation.Order; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingResponseWrapper; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.HexFormat; import java.util.Set; /** * Enforces presence of {@code Idempotency-Key} header on state-mutating endpoints - * (POST, PATCH, DELETE) — excluding webhook and actuator endpoints. + * (POST, PATCH, DELETE) -- excluding webhook and actuator endpoints. + * Uses INSERT-first reservation pattern to prevent TOCTOU races: + * 1. Try INSERT with status_code=0 (reservation) + * 2. If INSERT succeeds, proceed with request and UPDATE with real response + * 3. If INSERT fails (duplicate), re-read stored record for replay or conflict */ @Slf4j @Component @@ -24,30 +45,79 @@ public class IdempotencyKeyFilter extends OncePerRequestFilter { public static final String IDEMPOTENCY_KEY_HEADER = "Idempotency-Key"; + public static final String IDEMPOTENCY_REPLAY_HEADER = "Idempotency-Replay"; - private static final Set MUTATING_METHODS = Set.of("POST", "PATCH", "DELETE"); + private static final String TABLE_NAME = "onboarding_idempotency_keys"; + private static final Set MUTATING_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE"); private static final Set EXEMPT_PREFIXES = Set.of( "/api/internal/webhooks/", "/actuator/" ); + private final JdbcTemplate jdbcTemplate; + private final long ttlHours; + + public IdempotencyKeyFilter(JdbcTemplate jdbcTemplate, + @Value("${app.idempotency.ttl-hours:24}") long ttlHours) { + this.jdbcTemplate = jdbcTemplate; + this.ttlHours = ttlHours; + } + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { - if (requiresIdempotencyKey(request)) { - var key = request.getHeader(IDEMPOTENCY_KEY_HEADER); - if (key == null || key.isBlank()) { - log.info("Missing Idempotency-Key header for {} {}", request.getMethod(), request.getRequestURI()); - response.setStatus(HttpStatus.BAD_REQUEST.value()); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.getWriter().write( - "{\"code\":\"MO-0001\",\"status\":\"Bad Request\"," + - "\"message\":\"Idempotency-Key header is required for mutating requests\",\"errors\":{}}"); + if (!requiresIdempotencyKey(request)) { + chain.doFilter(request, response); + return; + } + + var key = request.getHeader(IDEMPOTENCY_KEY_HEADER); + if (key == null || key.isBlank()) { + log.info("Missing Idempotency-Key header for {} {}", request.getMethod(), request.getRequestURI()); + response.setStatus(HttpStatus.BAD_REQUEST.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write( + "{\"code\":\"MO-0001\",\"status\":\"Bad Request\"," + + "\"message\":\"Idempotency-Key header is required for mutating requests\"," + + "\"errors\":{}}"); + return; + } + + var method = request.getMethod(); + var path = request.getRequestURI(); + var bodyBytes = request.getInputStream().readAllBytes(); + var requestHash = computeSha256(bodyBytes); + + var reserved = reserveIdempotencyKey(key, method, path, requestHash); + if (reserved) { + var replayableRequest = new CachedBodyRequestWrapper(request, bodyBytes); + var wrappedResponse = new ContentCachingResponseWrapper(response); + try { + chain.doFilter(replayableRequest, wrappedResponse); + finalizeIdempotencyKey(key, method, path, wrappedResponse); + wrappedResponse.copyBodyToResponse(); + } catch (Exception e) { + deleteReservation(key, method, path); + throw e; + } + return; + } + + var existing = lookupIdempotencyKey(key, method, path); + if (existing == null) { + writeConflictError(response); + return; + } + if (existing.requestHash().equals(requestHash)) { + if (existing.statusCode() == 0) { + writeConflictError(response); return; } + replayResponse(response, existing); + return; } - chain.doFilter(request, response); + writeHashMismatchError(response); } private boolean requiresIdempotencyKey(HttpServletRequest request) { @@ -59,4 +129,175 @@ private boolean requiresIdempotencyKey(HttpServletRequest request) { var path = contextPath.isEmpty() ? uri : uri.substring(contextPath.length()); return EXEMPT_PREFIXES.stream().noneMatch(path::startsWith); } + + boolean reserveIdempotencyKey(String key, String method, String path, String requestHash) { + try { + jdbcTemplate.update( + "INSERT INTO " + TABLE_NAME + + " (idempotency_key, request_method, request_path, request_hash," + + " response_body, status_code, expires_at)" + + " VALUES (?, ?, ?, ?, '', 0, ?)", + key, method, path, requestHash, + Timestamp.from(Instant.now().plus(ttlHours, ChronoUnit.HOURS))); + return true; + } catch (DataIntegrityViolationException e) { + int deleted = jdbcTemplate.update( + "DELETE FROM " + TABLE_NAME + + " WHERE idempotency_key = ? AND request_method = ? AND request_path = ?" + + " AND expires_at <= NOW()", + key, method, path); + if (deleted > 0) { + try { + jdbcTemplate.update( + "INSERT INTO " + TABLE_NAME + + " (idempotency_key, request_method, request_path, request_hash," + + " response_body, status_code, expires_at)" + + " VALUES (?, ?, ?, ?, '', 0, ?)", + key, method, path, requestHash, + Timestamp.from(Instant.now().plus(ttlHours, ChronoUnit.HOURS))); + return true; + } catch (DataIntegrityViolationException retryEx) { + return false; + } + } + return false; + } + } + + IdempotencyRecord lookupIdempotencyKey(String key, String method, String path) { + var results = jdbcTemplate.query( + "SELECT idempotency_key, request_hash, response_body, status_code FROM " + TABLE_NAME + + " WHERE idempotency_key = ? AND request_method = ? AND request_path = ?" + + " AND expires_at > NOW()", + (rs, rowNum) -> new IdempotencyRecord( + rs.getString("idempotency_key"), + rs.getString("request_hash"), + rs.getString("response_body"), + rs.getInt("status_code")), + key, method, path); + return results.isEmpty() ? null : results.getFirst(); + } + + void finalizeIdempotencyKey(String key, String method, String path, + ContentCachingResponseWrapper wrappedResponse) { + var statusCode = wrappedResponse.getStatus(); + if (statusCode < 200 || statusCode >= 300) { + deleteReservation(key, method, path); + return; + } + var responseBody = new String(wrappedResponse.getContentAsByteArray(), StandardCharsets.UTF_8); + var expiresAt = Timestamp.from(Instant.now().plus(ttlHours, ChronoUnit.HOURS)); + + jdbcTemplate.update( + "UPDATE " + TABLE_NAME + + " SET response_body = ?, status_code = ?, expires_at = ?" + + " WHERE idempotency_key = ? AND request_method = ? AND request_path = ?", + responseBody, statusCode, expiresAt, key, method, path); + } + + private void deleteReservation(String key, String method, String path) { + jdbcTemplate.update( + "DELETE FROM " + TABLE_NAME + + " WHERE idempotency_key = ? AND request_method = ? AND request_path = ?", + key, method, path); + } + + private void replayResponse(HttpServletResponse response, IdempotencyRecord record) + throws IOException { + log.info("Replaying idempotent response"); + response.setStatus(record.statusCode()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setHeader(IDEMPOTENCY_REPLAY_HEADER, "true"); + response.getWriter().write(record.responseBody()); + } + + private void writeHashMismatchError(HttpServletResponse response) throws IOException { + response.setStatus(HttpStatus.UNPROCESSABLE_ENTITY.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write( + "{\"code\":\"MO-0002\",\"status\":\"Unprocessable Entity\"," + + "\"message\":\"Idempotency-Key has already been used with a different request payload\"," + + "\"errors\":{}}"); + } + + private void writeConflictError(HttpServletResponse response) throws IOException { + response.setStatus(HttpStatus.CONFLICT.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write( + "{\"code\":\"MO-0003\",\"status\":\"Conflict\"," + + "\"message\":\"A request with this Idempotency-Key is already in progress\"," + + "\"errors\":{}}"); + } + + private String computeSha256(byte[] body) { + try { + var digest = MessageDigest.getInstance("SHA-256"); + var hash = digest.digest(body); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + record IdempotencyRecord(String idempotencyKey, String requestHash, String responseBody, int statusCode) {} + + /** + * Request wrapper that replays a cached body so downstream filters and + * controllers can read the request body after it has already been consumed. + */ + private static class CachedBodyRequestWrapper extends HttpServletRequestWrapper { + + private final byte[] body; + + CachedBodyRequestWrapper(HttpServletRequest request, byte[] body) { + super(request); + this.body = body; + } + + @Override + public ServletInputStream getInputStream() { + var byteStream = new ByteArrayInputStream(body); + return new ServletInputStream() { + @Override + public boolean isFinished() { + return byteStream.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener readListener) { + throw new UnsupportedOperationException(); + } + + @Override + public int read() { + return byteStream.read(); + } + + @Override + public int read(byte[] b, int off, int len) { + return byteStream.read(b, off, len); + } + }; + } + + @Override + public BufferedReader getReader() { + return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); + } + + @Override + public int getContentLength() { + return body.length; + } + + @Override + public long getContentLengthLong() { + return body.length; + } + } } diff --git a/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/job/IdempotencyCleanupJob.java b/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/job/IdempotencyCleanupJob.java new file mode 100644 index 00000000..2bba4ac2 --- /dev/null +++ b/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/job/IdempotencyCleanupJob.java @@ -0,0 +1,37 @@ +package com.stablecoin.payments.merchant.onboarding.application.job; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@ConditionalOnProperty(name = "app.idempotency.cleanup.enabled", havingValue = "true", matchIfMissing = true) +@RequiredArgsConstructor +public class IdempotencyCleanupJob { + + private final JdbcTemplate jdbcTemplate; + + @Scheduled(cron = "${app.idempotency.cleanup-cron:0 0 * * * *}") + public void cleanExpiredKeys() { + int totalDeleted = 0; + int deleted; + do { + deleted = jdbcTemplate.update( + "WITH expired AS (" + + "SELECT idempotency_key, request_method, request_path " + + "FROM onboarding_idempotency_keys WHERE expires_at < NOW() LIMIT 1000" + + ") DELETE FROM onboarding_idempotency_keys USING expired " + + "WHERE onboarding_idempotency_keys.idempotency_key = expired.idempotency_key " + + "AND onboarding_idempotency_keys.request_method = expired.request_method " + + "AND onboarding_idempotency_keys.request_path = expired.request_path"); + totalDeleted += deleted; + } while (deleted >= 1000); + if (totalDeleted > 0) { + log.info("Cleaned up {} expired idempotency keys", totalDeleted); + } + } +} diff --git a/merchant-onboarding/merchant-onboarding/src/main/resources/application.yml b/merchant-onboarding/merchant-onboarding/src/main/resources/application.yml index d8e445fb..f5f25040 100644 --- a/merchant-onboarding/merchant-onboarding/src/main/resources/application.yml +++ b/merchant-onboarding/merchant-onboarding/src/main/resources/application.yml @@ -63,6 +63,13 @@ spring: merchant-corridor-approved-out-0: destination: merchant.corridor.approved +app: + idempotency: + ttl-hours: 24 + cleanup-cron: "0 0 * * * *" + cleanup: + enabled: true + namastack: outbox: poll-interval: 2000 diff --git a/merchant-onboarding/merchant-onboarding/src/main/resources/db/migration/V16__create_idempotency_keys_table.sql b/merchant-onboarding/merchant-onboarding/src/main/resources/db/migration/V16__create_idempotency_keys_table.sql new file mode 100644 index 00000000..5c218a8b --- /dev/null +++ b/merchant-onboarding/merchant-onboarding/src/main/resources/db/migration/V16__create_idempotency_keys_table.sql @@ -0,0 +1,10 @@ +CREATE TABLE onboarding_idempotency_keys ( + idempotency_key VARCHAR(255) PRIMARY KEY, + request_hash VARCHAR(64) NOT NULL, + response_body TEXT NOT NULL DEFAULT '', + status_code INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX idx_onboarding_idempotency_expires ON onboarding_idempotency_keys (expires_at); diff --git a/merchant-onboarding/merchant-onboarding/src/main/resources/db/migration/V17__scope_idempotency_keys.sql b/merchant-onboarding/merchant-onboarding/src/main/resources/db/migration/V17__scope_idempotency_keys.sql new file mode 100644 index 00000000..f1ca11ac --- /dev/null +++ b/merchant-onboarding/merchant-onboarding/src/main/resources/db/migration/V17__scope_idempotency_keys.sql @@ -0,0 +1,6 @@ +ALTER TABLE onboarding_idempotency_keys DROP CONSTRAINT onboarding_idempotency_keys_pkey; +ALTER TABLE onboarding_idempotency_keys ADD COLUMN request_method VARCHAR(10) NOT NULL DEFAULT 'POST'; +ALTER TABLE onboarding_idempotency_keys ADD COLUMN request_path VARCHAR(512) NOT NULL DEFAULT ''; +ALTER TABLE onboarding_idempotency_keys ADD PRIMARY KEY (idempotency_key, request_method, request_path); +ALTER TABLE onboarding_idempotency_keys ALTER COLUMN request_method DROP DEFAULT; +ALTER TABLE onboarding_idempotency_keys ALTER COLUMN request_path DROP DEFAULT; diff --git a/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/application/config/IdempotencyKeyFilterTest.java b/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/application/config/IdempotencyKeyFilterTest.java new file mode 100644 index 00000000..b8caa42d --- /dev/null +++ b/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/application/config/IdempotencyKeyFilterTest.java @@ -0,0 +1,193 @@ +package com.stablecoin.payments.merchant.onboarding.application.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.jdbc.core.JdbcTemplate; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; + +@ExtendWith(MockitoExtension.class) +@DisplayName("IdempotencyKeyFilter") +class IdempotencyKeyFilterTest { + + @Mock + private JdbcTemplate jdbcTemplate; + + @Mock + private FilterChain filterChain; + + private IdempotencyKeyFilter filter; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + private static final String REQUEST_BODY = "{\"name\":\"test\"}"; + private static final String REQUEST_HASH = computeHash(REQUEST_BODY.getBytes(StandardCharsets.UTF_8)); + + @BeforeEach + void setUp() { + filter = spy(new IdempotencyKeyFilter(jdbcTemplate, 24)); + request = new MockHttpServletRequest("POST", "/api/v1/merchants"); + response = new MockHttpServletResponse(); + } + + @Nested + @DisplayName("Missing Idempotency-Key") + class MissingKey { + + @Test + @DisplayName("should return 400 when Idempotency-Key header is missing") + void shouldReturn400WhenIdempotencyKeyMissing() throws ServletException, IOException { + filter.doFilterInternal(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(400); + assertThat(response.getContentAsString()).contains("MO-0001"); + } + } + + @Nested + @DisplayName("Replay stored response") + class ReplayResponse { + + @Test + @DisplayName("should replay stored response when reservation fails and hash matches") + void shouldReplayStoredResponse_whenSameKeyAndSameHash() throws ServletException, IOException { + request.addHeader("Idempotency-Key", "key-123"); + request.setContent(REQUEST_BODY.getBytes(StandardCharsets.UTF_8)); + + doReturn(false).when(filter).reserveIdempotencyKey("key-123", "POST", "/api/v1/merchants", REQUEST_HASH); + doReturn(new IdempotencyKeyFilter.IdempotencyRecord("key-123", REQUEST_HASH, "{\"id\":\"abc\"}", 201)) + .when(filter).lookupIdempotencyKey("key-123", "POST", "/api/v1/merchants"); + + filter.doFilterInternal(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(201); + assertThat(response.getContentAsString()).isEqualTo("{\"id\":\"abc\"}"); + assertThat(response.getHeader("Idempotency-Replay")).isEqualTo("true"); + then(filterChain).shouldHaveNoInteractions(); + } + } + + @Nested + @DisplayName("Hash mismatch") + class HashMismatch { + + @Test + @DisplayName("should return 422 when reservation fails and hash differs") + void shouldReturn422WhenSameKeyDifferentHash() throws ServletException, IOException { + request.addHeader("Idempotency-Key", "key-456"); + request.setContent(REQUEST_BODY.getBytes(StandardCharsets.UTF_8)); + + doReturn(false).when(filter).reserveIdempotencyKey("key-456", "POST", "/api/v1/merchants", REQUEST_HASH); + doReturn(new IdempotencyKeyFilter.IdempotencyRecord("key-456", "different-hash", "{}", 201)) + .when(filter).lookupIdempotencyKey("key-456", "POST", "/api/v1/merchants"); + + filter.doFilterInternal(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(422); + assertThat(response.getContentAsString()).contains("MO-0002"); + then(filterChain).shouldHaveNoInteractions(); + } + } + + @Nested + @DisplayName("Reservation succeeds — proceed and finalize") + class ProceedAndPersist { + + @Test + @DisplayName("should proceed with chain when reservation succeeds") + void shouldProceedAndFinalize_whenReservationSucceeds() throws ServletException, IOException { + request.addHeader("Idempotency-Key", "key-789"); + request.setContent(REQUEST_BODY.getBytes(StandardCharsets.UTF_8)); + + doReturn(true).when(filter).reserveIdempotencyKey("key-789", "POST", "/api/v1/merchants", REQUEST_HASH); + + filter.doFilterInternal(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsString()).doesNotContain("MO-0001", "MO-0002", "MO-0003"); + } + } + + @Nested + @DisplayName("PUT requests") + class PutRequests { + + @Test + @DisplayName("should enforce idempotency check for PUT requests") + void shouldEnforceIdempotencyCheckForPutRequests() throws ServletException, IOException { + request = new MockHttpServletRequest("PUT", "/api/v1/merchants"); + + filter.doFilterInternal(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(400); + assertThat(response.getContentAsString()).contains("MO-0001"); + } + } + + @Nested + @DisplayName("GET requests") + class GetRequests { + + @Test + @DisplayName("should skip idempotency check for GET requests") + void shouldSkipIdempotencyCheckForGetRequests() throws ServletException, IOException { + request = new MockHttpServletRequest("GET", "/api/v1/merchants"); + + filter.doFilterInternal(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(200); + then(filterChain).should().doFilter(request, response); + then(jdbcTemplate).shouldHaveNoInteractions(); + } + } + + @Nested + @DisplayName("Conflict — in-flight request") + class InFlightConflict { + + @Test + @DisplayName("should return 409 when reservation fails and stored record has status_code=0") + void shouldReturn409WhenRequestInFlight() throws ServletException, IOException { + request.addHeader("Idempotency-Key", "key-inflight"); + request.setContent(REQUEST_BODY.getBytes(StandardCharsets.UTF_8)); + + doReturn(false).when(filter).reserveIdempotencyKey("key-inflight", "POST", "/api/v1/merchants", REQUEST_HASH); + doReturn(new IdempotencyKeyFilter.IdempotencyRecord("key-inflight", REQUEST_HASH, "", 0)) + .when(filter).lookupIdempotencyKey("key-inflight", "POST", "/api/v1/merchants"); + + filter.doFilterInternal(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(409); + assertThat(response.getContentAsString()).contains("MO-0003"); + then(filterChain).shouldHaveNoInteractions(); + } + } + + private static String computeHash(byte[] body) { + try { + var digest = MessageDigest.getInstance("SHA-256"); + var hash = digest.digest(body); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } +} diff --git a/payment-orchestrator/payment-orchestrator/src/integration-test/java/com/stablecoin/payments/orchestrator/AbstractIntegrationTest.java b/payment-orchestrator/payment-orchestrator/src/integration-test/java/com/stablecoin/payments/orchestrator/AbstractIntegrationTest.java index 418be834..75cbfbbd 100644 --- a/payment-orchestrator/payment-orchestrator/src/integration-test/java/com/stablecoin/payments/orchestrator/AbstractIntegrationTest.java +++ b/payment-orchestrator/payment-orchestrator/src/integration-test/java/com/stablecoin/payments/orchestrator/AbstractIntegrationTest.java @@ -31,6 +31,10 @@ public abstract class AbstractIntegrationTest { static { POSTGRES.start(); KAFKA.start(); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + KAFKA.stop(); + POSTGRES.stop(); + })); } @Autowired