diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..16f7c68 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +ORACLE_PASSWORD=oracle_admin_password_placeholder +SPRING_DATASOURCE_USERNAME=dev_user_placeholder +SPRING_DATASOURCE_PASSWORD=dev_password_placeholder +JWT_SECRET=replace_with_64_plus_character_hmac_secret_placeholder +REDIS_HOST=localhost +REDIS_PORT=6379 +RATE_LIMIT_CAPACITY=120 +RATE_LIMIT_REFILL_PER_MINUTE=120 diff --git a/.gitignore b/.gitignore index 549e00a..c899ede 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ HELP.md target/ +.env !.mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ diff --git a/README.md b/README.md index 14dcb9d..44c54c6 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,22 @@ # Merchant API -Merchant API is a Spring Boot REST service for managing merchants and their payment transactions. The project is being modernized as a clean, production-minded Java backend portfolio application with simple architecture, explicit configuration, and database migrations. +Merchant API is a Java 21 / Spring Boot 3.5 backend for merchant onboarding and idempotent payment transaction processing. The project demonstrates production-minded REST design, database migrations, stateless authentication, request throttling, keyset pagination, structured errors, and containerized local infrastructure. -## Current Stack +## Stack - Java 21 - Spring Boot 3.5 -- Spring Web +- Spring Web, Validation, Security, Actuator - Spring Data JPA / Hibernate -- Oracle JDBC for local and production-style runtime -- Flyway for SQL database migrations -- H2 for test execution -- Testcontainers with Oracle XE for integration testing +- Oracle JDBC with Flyway SQL migrations +- Redis-backed request rate limiting +- JWT bearer authentication and merchant API keys +- RFC 7807 `ProblemDetail` errors +- Structured JSON logging with correlation IDs - Springdoc OpenAPI / Swagger UI -- Docker Compose with Oracle Free -- Maven Wrapper +- Docker Compose for Oracle Free, Redis, and the API +- Testcontainers integration tests +- k6 benchmark script ## API Surface @@ -23,11 +25,22 @@ POST /api/v1/merchants GET /api/v1/merchants/{merchantId} POST /api/v1/transactions -GET /api/v1/transactions -GET /api/v1/merchants/{merchantId}/transactions +GET /api/v1/transactions?afterId={cursor}&limit={size} +GET /api/v1/merchants/{merchantId}/transactions?afterId={cursor}&limit={size} ``` -Transaction creation requires an `Idempotency-Key` header. Reusing a key returns `409 Conflict`. +Transaction creation requires: + +```text +X-API-Key: +Idempotency-Key: +``` + +Administrative endpoints require: + +```text +Authorization: Bearer +``` Swagger UI is available at: @@ -35,38 +48,64 @@ Swagger UI is available at: http://localhost:8080/swagger-ui.html ``` -## Configuration +## Environment + +Runtime configuration is supplied through environment variables. Copy `.env.example` to `.env` for local Docker Compose runs and replace every placeholder with development-only values. -Runtime database settings are externalized through environment variables. Local defaults are provided for convenience. +Required variables: ```text -SPRING_DATASOURCE_URL +ORACLE_PASSWORD SPRING_DATASOURCE_USERNAME SPRING_DATASOURCE_PASSWORD +JWT_SECRET +REDIS_HOST +REDIS_PORT +RATE_LIMIT_CAPACITY +RATE_LIMIT_REFILL_PER_MINUTE ``` -Default local values: +The default Spring profile intentionally does not provide database or JWT secret fallbacks. Missing runtime secrets fail application startup instead of silently using unsafe credentials. -```text -jdbc:oracle:thin:@localhost:1521:orcl -system -oracle -``` +## Data Model -The application uses Flyway migrations from profile-specific folders: +Flyway migrations are split by database profile: ```text src/main/resources/db/migration/oracle src/main/resources/db/migration/h2 ``` -JPA schema generation is disabled for safety. Hibernate validates the schema created by Flyway. +The API stores API keys as SHA-256 hashes in `api_key`; raw API keys are never persisted. Idempotency records are pruned by a scheduled cleanup job after the configured retention window. + +## Concurrency + +Transaction creation uses database-backed idempotency plus a pessimistic merchant-row lock. This keeps duplicate requests from creating multiple transaction rows and gives future merchant balance/state transitions a clear isolation point. + +Ledger reads use keyset pagination through `afterId` and `limit` instead of offset pagination, keeping large-table scans predictable. + +The isolation rationale is documented in: + +```text +docs/adr/0002-concurrency-and-isolation.md +``` + +## Health And Telemetry + +Health probes: + +```text +GET /health/live +GET /health/ready +``` -## Profiles +Logs are emitted as JSON and include `correlation_id` when callers pass: -The default profile targets Oracle using externalized datasource settings. +```text +X-Correlation-ID: +``` -The `test` profile uses an in-memory H2 database in Oracle compatibility mode so tests can run without a local Oracle instance. +If no correlation id is supplied, the API generates one and returns it in the response header. ## Build And Test @@ -76,43 +115,39 @@ The `test` profile uses an in-memory H2 database in Oracle compatibility mode so .\mvnw.cmd clean verify ``` -`clean test` runs the fast unit and WebMvc slice tests. `clean verify` also runs the Failsafe integration-test phase, including a Testcontainers-backed Oracle XE integration test. Docker must be running locally. +`clean test` runs unit, WebMvc, security, and rate-limit tests against H2. `clean verify` also runs Failsafe integration tests with Testcontainers-backed Oracle XE and Redis when Docker is available. Local runs skip the container tests if Docker is unreachable; CI runners provide Docker, so the real Oracle/Redis integration path is exercised on push and pull request builds. ## Docker Compose -Start the API with Oracle Free: +Create `.env` from `.env.example`, then start the stack: ```powershell docker compose up --build ``` -The API is exposed on `localhost:8080`, and Oracle is exposed on `localhost:1521`. - -## CI - -GitHub Actions runs on every push and pull request to `main`: - -```text -.github/workflows/ci.yml -``` - -The workflow uses Java 21, Maven dependency caching, Docker-enabled Ubuntu runners, a pre-pulled Oracle XE test image, and `./mvnw --batch-mode clean verify`. +The API is exposed on `localhost:8080`, Oracle on `localhost:1521`, and Redis on `localhost:6379`. -## Local Run +## Benchmarks -Set database credentials if your Oracle setup differs from the defaults: +Install k6, then run: ```powershell -$env:SPRING_DATASOURCE_URL="jdbc:oracle:thin:@localhost:1521:orcl" -$env:SPRING_DATASOURCE_USERNAME="system" -$env:SPRING_DATASOURCE_PASSWORD="oracle" -.\mvnw.cmd spring-boot:run +k6 run benchmarks/transaction-create.k6.js ` + -e BASE_URL=http://localhost:8080 ` + -e MERCHANT_ID=1 ` + -e MERCHANT_API_KEY= ` + -e RPS=25 ` + -e DURATION=1m ``` -## Architecture Decisions +The script reports p50, p95, p99 latency and request failure rate. Tune `RPS`, `VUS`, `MAX_VUS`, and `DURATION` to explore throughput limits. + +## CI -Architecture rationale is documented in: +GitHub Actions runs on every push and pull request to `main`: ```text -docs/adr/0001-architecture-and-modernization.md +.github/workflows/ci.yml ``` + +The workflow uses Java 21, Maven dependency caching, Docker-enabled Ubuntu runners, a pre-pulled Oracle XE test image, and `./mvnw --batch-mode clean verify`. diff --git a/benchmarks/transaction-create.k6.js b/benchmarks/transaction-create.k6.js new file mode 100644 index 0000000..10e34d6 --- /dev/null +++ b/benchmarks/transaction-create.k6.js @@ -0,0 +1,47 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import exec from 'k6/execution'; + +export const options = { + scenarios: { + transaction_create: { + executor: 'constant-arrival-rate', + rate: Number(__ENV.RPS || 25), + timeUnit: '1s', + duration: __ENV.DURATION || '1m', + preAllocatedVUs: Number(__ENV.VUS || 25), + maxVUs: Number(__ENV.MAX_VUS || 100), + }, + }, + thresholds: { + http_req_duration: ['p(50)<100', 'p(95)<300', 'p(99)<750'], + http_req_failed: ['rate<0.01'], + }, +}; + +const baseUrl = __ENV.BASE_URL || 'http://localhost:8080'; +const apiKey = __ENV.MERCHANT_API_KEY; +const merchantId = Number(__ENV.MERCHANT_ID || 1); + +export default function () { + const idempotencyKey = `bench-${exec.scenario.iterationInTest}`; + const payload = JSON.stringify({ + transactionAmount: '12.34', + merchantId, + transactionType: 'SALE', + connectionMode: 'Live', + }); + + const response = http.post(`${baseUrl}/api/v1/transactions`, payload, { + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': apiKey, + 'Idempotency-Key': idempotencyKey, + 'X-Correlation-ID': idempotencyKey, + }, + }); + + check(response, { + 'created': (res) => res.status === 201, + }); +} diff --git a/docker-compose.yml b/docker-compose.yml index c2d8b03..e6789f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,9 +3,9 @@ services: image: gvenzl/oracle-free:23-slim container_name: merchant-db environment: - ORACLE_PASSWORD: oracle - APP_USER: merchant_api - APP_USER_PASSWORD: merchant_api + ORACLE_PASSWORD: ${ORACLE_PASSWORD:?Set ORACLE_PASSWORD in .env} + APP_USER: ${SPRING_DATASOURCE_USERNAME:?Set SPRING_DATASOURCE_USERNAME in .env} + APP_USER_PASSWORD: ${SPRING_DATASOURCE_PASSWORD:?Set SPRING_DATASOURCE_PASSWORD in .env} ports: - "1521:1521" volumes: @@ -16,16 +16,32 @@ services: timeout: 5s retries: 10 + merchant-redis: + image: redis:7-alpine + container_name: merchant-redis + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 10 + merchant-api: build: . container_name: merchant-api depends_on: merchant-db: condition: service_healthy + merchant-redis: + condition: service_healthy environment: SPRING_DATASOURCE_URL: jdbc:oracle:thin:@merchant-db:1521/FREEPDB1 - SPRING_DATASOURCE_USERNAME: merchant_api - SPRING_DATASOURCE_PASSWORD: merchant_api + SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME:?Set SPRING_DATASOURCE_USERNAME in .env} + SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD:?Set SPRING_DATASOURCE_PASSWORD in .env} + REDIS_HOST: merchant-redis + REDIS_PORT: 6379 + JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in .env} ports: - "8080:8080" diff --git a/docs/adr/0002-concurrency-and-isolation.md b/docs/adr/0002-concurrency-and-isolation.md new file mode 100644 index 0000000..49edd41 --- /dev/null +++ b/docs/adr/0002-concurrency-and-isolation.md @@ -0,0 +1,36 @@ +# ADR 0002: Concurrency and Transaction Isolation + +## Status + +Accepted + +## Context + +Transaction creation must be safe under concurrent duplicate submissions and future merchant balance/state transitions. The API already uses database-backed idempotency keys, but idempotency alone does not protect shared merchant state if later business rules deduct balances, reserve funds, or transition account status. + +## Decision + +The service uses the database default `READ COMMITTED` isolation level and applies explicit pessimistic row locks to the merchant row during transaction creation. + +The transaction flow is: + +1. Validate the idempotency key. +2. Acquire a `PESSIMISTIC_WRITE` lock on the merchant row. +3. Insert the idempotency record using its primary-key uniqueness guarantee. +4. Insert the transaction row. +5. Link the idempotency record to the created transaction. + +## Rationale + +`READ COMMITTED` keeps throughput high for ledger reads and independent merchants. The explicit row lock serializes only state transitions for the affected merchant, avoiding the broader contention and retry behavior of `SERIALIZABLE`. + +This keeps the lock scope small and predictable: + +- Ledger reads remain non-blocking. +- Concurrent writes for different merchants proceed independently. +- Concurrent duplicate requests for the same idempotency key are rejected by the database primary key. +- Future merchant balance deductions can safely occur while holding the same merchant-row lock. + +## Consequences + +Application code must acquire the merchant lock before adding new transaction-side effects that depend on merchant state. Long external calls must not run while holding the transaction open. diff --git a/pom.xml b/pom.xml index 16d3646..4a55cae 100644 --- a/pom.xml +++ b/pom.xml @@ -17,12 +17,25 @@ 21 1.18.38 2.8.13 + 8.0 org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + org.springframework.boot spring-boot-starter-validation @@ -31,11 +44,20 @@ org.springframework.boot spring-boot-starter-data-jpa + + org.springframework.boot + spring-boot-starter-data-redis + org.springdoc springdoc-openapi-starter-webmvc-ui ${springdoc.version} + + net.logstash.logback + logstash-logback-encoder + ${logstash-logback.version} + org.flywaydb flyway-core @@ -81,6 +103,11 @@ spring-boot-starter-test test + + org.springframework.security + spring-security-test + test + diff --git a/src/main/java/com/portfolio/merchantapi/Application.java b/src/main/java/com/portfolio/merchantapi/Application.java index 9ff4559..ac7d62a 100644 --- a/src/main/java/com/portfolio/merchantapi/Application.java +++ b/src/main/java/com/portfolio/merchantapi/Application.java @@ -2,8 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@ConfigurationPropertiesScan +@EnableScheduling public class Application { public static void main(String[] args) { diff --git a/src/main/java/com/portfolio/merchantapi/common/config/OpenApiConfig.java b/src/main/java/com/portfolio/merchantapi/common/config/OpenApiConfig.java index b887b9d..92546f5 100644 --- a/src/main/java/com/portfolio/merchantapi/common/config/OpenApiConfig.java +++ b/src/main/java/com/portfolio/merchantapi/common/config/OpenApiConfig.java @@ -2,6 +2,9 @@ import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; import org.springframework.context.annotation.Configuration; @Configuration @@ -12,5 +15,17 @@ description = "REST API for merchant and transaction management with Flyway migrations, structured errors, and transaction idempotency." ) ) +@SecurityScheme( + name = "bearerAuth", + type = SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT" +) +@SecurityScheme( + name = "apiKeyAuth", + type = SecuritySchemeType.APIKEY, + in = SecuritySchemeIn.HEADER, + paramName = "X-API-Key" +) public class OpenApiConfig { } diff --git a/src/main/java/com/portfolio/merchantapi/common/config/SecurityProperties.java b/src/main/java/com/portfolio/merchantapi/common/config/SecurityProperties.java new file mode 100644 index 0000000..8049e3e --- /dev/null +++ b/src/main/java/com/portfolio/merchantapi/common/config/SecurityProperties.java @@ -0,0 +1,34 @@ +package com.portfolio.merchantapi.common.config; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +import java.time.Duration; + +@Validated +@ConfigurationProperties(prefix = "merchant-api") +public record SecurityProperties( + Security security, + Idempotency idempotency, + RateLimit rateLimit +) { + + public record Security(@NotBlank String jwtSecret) { + } + + public record Idempotency(Duration retention, Duration cleanupDelay) { + public Idempotency { + if (retention == null) { + retention = Duration.ofHours(24); + } + if (cleanupDelay == null) { + cleanupDelay = Duration.ofMinutes(15); + } + } + } + + public record RateLimit(@Min(1) int capacity, @Min(1) int refillPerMinute) { + } +} diff --git a/src/main/java/com/portfolio/merchantapi/merchant/MerchantController.java b/src/main/java/com/portfolio/merchantapi/merchant/MerchantController.java index 4bc67ce..93fe956 100644 --- a/src/main/java/com/portfolio/merchantapi/merchant/MerchantController.java +++ b/src/main/java/com/portfolio/merchantapi/merchant/MerchantController.java @@ -5,12 +5,14 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -22,6 +24,7 @@ @RequiredArgsConstructor @RequestMapping("/api/v1/merchants") @Tag(name = "Merchants", description = "Merchant account management") +@SecurityRequirement(name = "bearerAuth") public class MerchantController { private final MerchantService merchantService; @@ -34,6 +37,7 @@ public class MerchantController { @ApiResponse(responseCode = "400", description = "Invalid request payload", content = @Content(schema = @Schema(implementation = ProblemDetail.class))) }) + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity createMerchant(@Valid @RequestBody MerchantRequest request) { return ResponseEntity.status(HttpStatus.CREATED) .body(merchantService.createMerchant(request)); @@ -47,6 +51,7 @@ public ResponseEntity createMerchant(@Valid @RequestBody Merch @ApiResponse(responseCode = "404", description = "Merchant not found", content = @Content(schema = @Schema(implementation = ProblemDetail.class))) }) + @PreAuthorize("hasRole('ADMIN')") public MerchantResponse getMerchant(@PathVariable Long merchantId) { return merchantService.findMerchantById(merchantId); } diff --git a/src/main/java/com/portfolio/merchantapi/merchant/MerchantRepository.java b/src/main/java/com/portfolio/merchantapi/merchant/MerchantRepository.java index c7b2ba7..7b9ace7 100644 --- a/src/main/java/com/portfolio/merchantapi/merchant/MerchantRepository.java +++ b/src/main/java/com/portfolio/merchantapi/merchant/MerchantRepository.java @@ -1,6 +1,16 @@ package com.portfolio.merchantapi.merchant; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import jakarta.persistence.LockModeType; +import java.util.Optional; public interface MerchantRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select m from Merchant m where m.id = :id") + Optional findLockedById(@Param("id") Long id); } diff --git a/src/main/java/com/portfolio/merchantapi/merchant/MerchantService.java b/src/main/java/com/portfolio/merchantapi/merchant/MerchantService.java index 18ec0a1..50107c8 100644 --- a/src/main/java/com/portfolio/merchantapi/merchant/MerchantService.java +++ b/src/main/java/com/portfolio/merchantapi/merchant/MerchantService.java @@ -27,4 +27,9 @@ public MerchantResponse findMerchantById(Long id) { public boolean existsById(Long id) { return merchantRepository.existsById(id); } + + public Merchant lockMerchant(Long id) { + return merchantRepository.findLockedById(id) + .orElseThrow(() -> new MerchantNotFoundException(id)); + } } diff --git a/src/main/java/com/portfolio/merchantapi/rate/RateLimitFilter.java b/src/main/java/com/portfolio/merchantapi/rate/RateLimitFilter.java new file mode 100644 index 0000000..a8c4e6f --- /dev/null +++ b/src/main/java/com/portfolio/merchantapi/rate/RateLimitFilter.java @@ -0,0 +1,84 @@ +package com.portfolio.merchantapi.rate; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.portfolio.merchantapi.security.ApiKeyPrincipal; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.net.URI; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class RateLimitFilter extends OncePerRequestFilter { + + private final RateLimiterService rateLimiterService; + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + if (isPublicPath(request.getRequestURI())) { + filterChain.doFilter(request, response); + return; + } + + String subject = subject(); + if (subject != null && !rateLimiterService.allow(subject)) { + writeTooManyRequests(response); + return; + } + + filterChain.doFilter(request, response); + } + + private boolean isPublicPath(String uri) { + return uri.startsWith("/health/") + || uri.startsWith("/actuator/health") + || uri.startsWith("/swagger-ui") + || uri.startsWith("/v3/api-docs"); + } + + private String subject() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + return null; + } + + Object principal = authentication.getPrincipal(); + if (principal instanceof ApiKeyPrincipal apiKeyPrincipal) { + return "merchant:%s".formatted(apiKeyPrincipal.merchantId()); + } + + if (principal instanceof Jwt jwt) { + return "jwt:%s".formatted(jwt.getSubject()); + } + + return "principal:%s".formatted(authentication.getName()); + } + + private void writeTooManyRequests(HttpServletResponse response) throws IOException { + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE); + objectMapper.writeValue(response.getOutputStream(), Map.of( + "type", URI.create("https://merchant-api/errors/rate-limit-exceeded").toString(), + "title", "Too many requests", + "status", HttpStatus.TOO_MANY_REQUESTS.value(), + "detail", "Request rate limit exceeded" + )); + } +} diff --git a/src/main/java/com/portfolio/merchantapi/rate/RateLimiterService.java b/src/main/java/com/portfolio/merchantapi/rate/RateLimiterService.java new file mode 100644 index 0000000..918cbf9 --- /dev/null +++ b/src/main/java/com/portfolio/merchantapi/rate/RateLimiterService.java @@ -0,0 +1,73 @@ +package com.portfolio.merchantapi.rate; + +import com.portfolio.merchantapi.common.config.SecurityProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class RateLimiterService { + + private static final String SCRIPT = """ + local tokens_key = KEYS[1] + local timestamp_key = KEYS[2] + local capacity = tonumber(ARGV[1]) + local refill_per_second = tonumber(ARGV[2]) + local now = tonumber(ARGV[3]) + local requested = tonumber(ARGV[4]) + local ttl = tonumber(ARGV[5]) + + local tokens = tonumber(redis.call('get', tokens_key)) + if tokens == nil then + tokens = capacity + end + + local last_refreshed = tonumber(redis.call('get', timestamp_key)) + if last_refreshed == nil then + last_refreshed = now + end + + local delta = math.max(0, now - last_refreshed) + local filled = math.min(capacity, tokens + (delta * refill_per_second)) + local allowed = filled >= requested + + if allowed then + filled = filled - requested + end + + redis.call('setex', tokens_key, ttl, filled) + redis.call('setex', timestamp_key, ttl, now) + + if allowed then + return 1 + end + return 0 + """; + + private final StringRedisTemplate redisTemplate; + private final SecurityProperties properties; + + public boolean allow(String subject) { + SecurityProperties.RateLimit rateLimit = properties.rateLimit(); + double refillPerSecond = rateLimit.refillPerMinute() / 60.0; + long now = Instant.now().getEpochSecond(); + long ttl = Math.max(60, (long) Math.ceil(rateLimit.capacity() / refillPerSecond)); + + Long allowed = redisTemplate.execute( + new DefaultRedisScript<>(SCRIPT, Long.class), + List.of("rate-limit:%s:tokens".formatted(subject), "rate-limit:%s:timestamp".formatted(subject)), + String.valueOf(rateLimit.capacity()), + String.valueOf(refillPerSecond), + String.valueOf(now), + "1", + String.valueOf(ttl) + ); + + return allowed != null && allowed == 1; + } +} diff --git a/src/main/java/com/portfolio/merchantapi/security/ApiKey.java b/src/main/java/com/portfolio/merchantapi/security/ApiKey.java new file mode 100644 index 0000000..a435b80 --- /dev/null +++ b/src/main/java/com/portfolio/merchantapi/security/ApiKey.java @@ -0,0 +1,31 @@ +package com.portfolio.merchantapi.security; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Data; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "api_key") +@Data +public class ApiKey { + + @Id + @Column(name = "key_hash", nullable = false, length = 64) + private String keyHash; + + @Column(name = "merchant_id", nullable = false) + private Long merchantId; + + @Column(name = "role_name", nullable = false, length = 50) + private String roleName; + + @Column(name = "active", nullable = false) + private boolean active; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/portfolio/merchantapi/security/ApiKeyAuthenticationFilter.java b/src/main/java/com/portfolio/merchantapi/security/ApiKeyAuthenticationFilter.java new file mode 100644 index 0000000..ebd422c --- /dev/null +++ b/src/main/java/com/portfolio/merchantapi/security/ApiKeyAuthenticationFilter.java @@ -0,0 +1,58 @@ +package com.portfolio.merchantapi.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class ApiKeyAuthenticationFilter extends OncePerRequestFilter { + + public static final String API_KEY_HEADER = "X-API-Key"; + + private final ApiKeyRepository apiKeyRepository; + private final ApiKeyHasher apiKeyHasher; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String apiKey = request.getHeader(API_KEY_HEADER); + String authorization = request.getHeader(HttpHeaders.AUTHORIZATION); + if (apiKey == null || apiKey.isBlank() || authorization != null) { + filterChain.doFilter(request, response); + return; + } + + apiKeyRepository.findByKeyHashAndActiveTrue(apiKeyHasher.hash(apiKey)) + .ifPresent(this::authenticate); + + filterChain.doFilter(request, response); + } + + private void authenticate(ApiKey apiKey) { + String role = apiKey.getRoleName().startsWith("ROLE_") + ? apiKey.getRoleName() + : "ROLE_" + apiKey.getRoleName(); + ApiKeyPrincipal principal = new ApiKeyPrincipal(apiKey.getMerchantId(), role); + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority(role)) + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + } +} diff --git a/src/main/java/com/portfolio/merchantapi/security/ApiKeyHasher.java b/src/main/java/com/portfolio/merchantapi/security/ApiKeyHasher.java new file mode 100644 index 0000000..8040368 --- /dev/null +++ b/src/main/java/com/portfolio/merchantapi/security/ApiKeyHasher.java @@ -0,0 +1,22 @@ +package com.portfolio.merchantapi.security; + +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +@Component +public class ApiKeyHasher { + + public String hash(String apiKey) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] encoded = digest.digest(apiKey.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(encoded); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is not available", exception); + } + } +} diff --git a/src/main/java/com/portfolio/merchantapi/security/ApiKeyPrincipal.java b/src/main/java/com/portfolio/merchantapi/security/ApiKeyPrincipal.java new file mode 100644 index 0000000..ba6c54e --- /dev/null +++ b/src/main/java/com/portfolio/merchantapi/security/ApiKeyPrincipal.java @@ -0,0 +1,4 @@ +package com.portfolio.merchantapi.security; + +public record ApiKeyPrincipal(Long merchantId, String roleName) { +} diff --git a/src/main/java/com/portfolio/merchantapi/security/ApiKeyRepository.java b/src/main/java/com/portfolio/merchantapi/security/ApiKeyRepository.java new file mode 100644 index 0000000..98ab0dc --- /dev/null +++ b/src/main/java/com/portfolio/merchantapi/security/ApiKeyRepository.java @@ -0,0 +1,10 @@ +package com.portfolio.merchantapi.security; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ApiKeyRepository extends JpaRepository { + + Optional findByKeyHashAndActiveTrue(String keyHash); +} diff --git a/src/main/java/com/portfolio/merchantapi/security/MerchantAuthorizationService.java b/src/main/java/com/portfolio/merchantapi/security/MerchantAuthorizationService.java new file mode 100644 index 0000000..f99f7b7 --- /dev/null +++ b/src/main/java/com/portfolio/merchantapi/security/MerchantAuthorizationService.java @@ -0,0 +1,38 @@ +package com.portfolio.merchantapi.security; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Component; + +@Component("merchantAuthz") +public class MerchantAuthorizationService { + + public boolean canAccessMerchant(Authentication authentication, Long merchantId) { + if (authentication == null || !authentication.isAuthenticated()) { + return false; + } + + if (hasRole(authentication, "ROLE_ADMIN")) { + return true; + } + + Object principal = authentication.getPrincipal(); + if (principal instanceof ApiKeyPrincipal apiKeyPrincipal) { + return apiKeyPrincipal.merchantId().equals(merchantId); + } + + if (principal instanceof Jwt jwt) { + Number tokenMerchantId = jwt.getClaim("merchant_id"); + return tokenMerchantId != null && tokenMerchantId.longValue() == merchantId; + } + + return false; + } + + private boolean hasRole(Authentication authentication, String role) { + return authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .anyMatch(role::equals); + } +} diff --git a/src/main/java/com/portfolio/merchantapi/security/SecurityConfig.java b/src/main/java/com/portfolio/merchantapi/security/SecurityConfig.java new file mode 100644 index 0000000..af5b1a5 --- /dev/null +++ b/src/main/java/com/portfolio/merchantapi/security/SecurityConfig.java @@ -0,0 +1,91 @@ +package com.portfolio.merchantapi.security; + +import com.portfolio.merchantapi.common.config.SecurityProperties; +import com.portfolio.merchantapi.rate.RateLimitFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; +import org.springframework.security.web.SecurityFilterChain; + +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +@Configuration +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final ApiKeyAuthenticationFilter apiKeyAuthenticationFilter; + private final RateLimitFilter rateLimitFilter; + private final SecurityProperties securityProperties; + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationConverter jwtAuthenticationConverter) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/health/live", + "/health/ready", + "/actuator/health/**", + "/swagger-ui.html", + "/swagger-ui/**", + "/v3/api-docs/**" + ).permitAll() + .requestMatchers("/api/v1/merchants/**").hasRole("ADMIN") + .requestMatchers("/api/v1/transactions/**", "/api/v1/merchants/*/transactions").authenticated() + .anyRequest().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter))) + .addFilterBefore(apiKeyAuthenticationFilter, BearerTokenAuthenticationFilter.class) + .addFilterAfter(rateLimitFilter, BearerTokenAuthenticationFilter.class) + .build(); + } + + @Bean + JwtDecoder jwtDecoder() { + byte[] secret = securityProperties.security().jwtSecret().getBytes(StandardCharsets.UTF_8); + return NimbusJwtDecoder.withSecretKey(new SecretKeySpec(secret, "HmacSHA256")).build(); + } + + @Bean + JwtAuthenticationConverter jwtAuthenticationConverter() { + JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); + converter.setJwtGrantedAuthoritiesConverter(new RolesClaimConverter()); + return converter; + } + + private static class RolesClaimConverter implements Converter> { + + @Override + public Collection convert(Jwt jwt) { + List roles = jwt.getClaimAsStringList("roles"); + if (roles == null) { + roles = List.of(); + } + + List authorities = new ArrayList<>(); + for (String role : roles) { + String normalized = role.startsWith("ROLE_") ? role : "ROLE_" + role; + authorities.add(new SimpleGrantedAuthority(normalized)); + } + return authorities; + } + } +} diff --git a/src/main/java/com/portfolio/merchantapi/telemetry/CorrelationIdFilter.java b/src/main/java/com/portfolio/merchantapi/telemetry/CorrelationIdFilter.java new file mode 100644 index 0000000..6495a33 --- /dev/null +++ b/src/main/java/com/portfolio/merchantapi/telemetry/CorrelationIdFilter.java @@ -0,0 +1,38 @@ +package com.portfolio.merchantapi.telemetry; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.UUID; + +@Component +public class CorrelationIdFilter extends OncePerRequestFilter { + + public static final String CORRELATION_ID_HEADER = "X-Correlation-ID"; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String correlationId = request.getHeader(CORRELATION_ID_HEADER); + if (correlationId == null || correlationId.isBlank()) { + correlationId = UUID.randomUUID().toString(); + } + + MDC.put("correlation_id", correlationId); + response.setHeader(CORRELATION_ID_HEADER, correlationId); + try { + filterChain.doFilter(request, response); + } finally { + MDC.remove("correlation_id"); + } + } +} diff --git a/src/main/java/com/portfolio/merchantapi/telemetry/HealthAliasController.java b/src/main/java/com/portfolio/merchantapi/telemetry/HealthAliasController.java new file mode 100644 index 0000000..b468254 --- /dev/null +++ b/src/main/java/com/portfolio/merchantapi/telemetry/HealthAliasController.java @@ -0,0 +1,42 @@ +package com.portfolio.merchantapi.telemetry; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.boot.actuate.health.Status; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequiredArgsConstructor +public class HealthAliasController { + + private final Map healthIndicators; + + @GetMapping("/health/live") + public Map live() { + return Map.of("status", Status.UP.getCode()); + } + + @GetMapping("/health/ready") + public ResponseEntity> ready() { + boolean ready = isUp("dbHealthIndicator") && isUp("redisHealthIndicator"); + HttpStatus status = ready ? HttpStatus.OK : HttpStatus.SERVICE_UNAVAILABLE; + return ResponseEntity.status(status) + .body(Map.of("status", ready ? Status.UP.getCode() : Status.DOWN.getCode())); + } + + private boolean isUp(String beanName) { + HealthIndicator indicator = healthIndicators.get(beanName); + if (indicator == null) { + return true; + } + + Health health = indicator.health(); + return Status.UP.equals(health.getStatus()); + } +} diff --git a/src/main/java/com/portfolio/merchantapi/transaction/IdempotencyCleanupJob.java b/src/main/java/com/portfolio/merchantapi/transaction/IdempotencyCleanupJob.java new file mode 100644 index 0000000..255baf3 --- /dev/null +++ b/src/main/java/com/portfolio/merchantapi/transaction/IdempotencyCleanupJob.java @@ -0,0 +1,29 @@ +package com.portfolio.merchantapi.transaction; + +import com.portfolio.merchantapi.common.config.SecurityProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Slf4j +@Component +@RequiredArgsConstructor +public class IdempotencyCleanupJob { + + private final IdempotencyRecordRepository idempotencyRecordRepository; + private final SecurityProperties properties; + + @Transactional + @Scheduled(fixedDelayString = "${merchant-api.idempotency.cleanup-delay}") + public void deleteExpiredCompletedRecords() { + LocalDateTime expiresBefore = LocalDateTime.now().minus(properties.idempotency().retention()); + int deleted = idempotencyRecordRepository.deleteCompletedRecordsCreatedBefore(expiresBefore); + if (deleted > 0) { + log.info("Deleted expired idempotency records count={}", deleted); + } + } +} diff --git a/src/main/java/com/portfolio/merchantapi/transaction/IdempotencyRecordRepository.java b/src/main/java/com/portfolio/merchantapi/transaction/IdempotencyRecordRepository.java index 447b42c..2255683 100644 --- a/src/main/java/com/portfolio/merchantapi/transaction/IdempotencyRecordRepository.java +++ b/src/main/java/com/portfolio/merchantapi/transaction/IdempotencyRecordRepository.java @@ -1,6 +1,19 @@ package com.portfolio.merchantapi.transaction; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; public interface IdempotencyRecordRepository extends JpaRepository { + + @Modifying + @Query(""" + delete from IdempotencyRecord r + where r.createdAt < :expiresBefore + and r.transactionId is not null + """) + int deleteCompletedRecordsCreatedBefore(@Param("expiresBefore") LocalDateTime expiresBefore); } diff --git a/src/main/java/com/portfolio/merchantapi/transaction/TransactionController.java b/src/main/java/com/portfolio/merchantapi/transaction/TransactionController.java index c599000..ee8b217 100644 --- a/src/main/java/com/portfolio/merchantapi/transaction/TransactionController.java +++ b/src/main/java/com/portfolio/merchantapi/transaction/TransactionController.java @@ -8,12 +8,14 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -23,12 +25,12 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.List; - @RestController @RequiredArgsConstructor @RequestMapping("/api/v1") @Tag(name = "Transactions", description = "Merchant transaction processing") +@SecurityRequirement(name = "apiKeyAuth") +@SecurityRequirement(name = "bearerAuth") public class TransactionController { private final TransactionService transactionService; @@ -45,6 +47,7 @@ public class TransactionController { @ApiResponse(responseCode = "409", description = "Duplicate Idempotency-Key", content = @Content(schema = @Schema(implementation = ProblemDetail.class))) }) + @PreAuthorize("@merchantAuthz.canAccessMerchant(authentication, #request.merchantId())") public ResponseEntity createTransaction( @Parameter( name = "Idempotency-Key", @@ -64,8 +67,13 @@ public ResponseEntity createTransaction( @Operation(summary = "List transactions") @ApiResponse(responseCode = "200", description = "Transactions returned", content = @Content(array = @ArraySchema(schema = @Schema(implementation = TransactionResponse.class)))) - public List getTransactions(@RequestParam(required = false) String connectionMode) { - return transactionService.findTransactions(connectionMode); + @PreAuthorize("hasRole('ADMIN')") + public TransactionPageResponse getTransactions( + @RequestParam(required = false) String connectionMode, + @RequestParam(required = false) Long afterId, + @RequestParam(defaultValue = "50") int limit) { + + return transactionService.findTransactions(connectionMode, afterId, limit); } @GetMapping("/merchants/{merchantId}/transactions") @@ -76,10 +84,13 @@ public List getTransactions(@RequestParam(required = false) @ApiResponse(responseCode = "404", description = "Merchant not found", content = @Content(schema = @Schema(implementation = ProblemDetail.class))) }) - public List getMerchantTransactions( + @PreAuthorize("@merchantAuthz.canAccessMerchant(authentication, #merchantId)") + public TransactionPageResponse getMerchantTransactions( @PathVariable Long merchantId, - @RequestParam(required = false) String connectionMode) { + @RequestParam(required = false) String connectionMode, + @RequestParam(required = false) Long afterId, + @RequestParam(defaultValue = "50") int limit) { - return transactionService.findTransactionsByMerchant(merchantId, connectionMode); + return transactionService.findTransactionsByMerchant(merchantId, connectionMode, afterId, limit); } } diff --git a/src/main/java/com/portfolio/merchantapi/transaction/TransactionPageResponse.java b/src/main/java/com/portfolio/merchantapi/transaction/TransactionPageResponse.java new file mode 100644 index 0000000..e333a6f --- /dev/null +++ b/src/main/java/com/portfolio/merchantapi/transaction/TransactionPageResponse.java @@ -0,0 +1,10 @@ +package com.portfolio.merchantapi.transaction; + +import java.util.List; + +public record TransactionPageResponse( + List transactions, + Long nextCursor, + boolean hasMore +) { +} diff --git a/src/main/java/com/portfolio/merchantapi/transaction/TransactionRepository.java b/src/main/java/com/portfolio/merchantapi/transaction/TransactionRepository.java index fb4a0ca..7a1bb87 100644 --- a/src/main/java/com/portfolio/merchantapi/transaction/TransactionRepository.java +++ b/src/main/java/com/portfolio/merchantapi/transaction/TransactionRepository.java @@ -1,6 +1,9 @@ package com.portfolio.merchantapi.transaction; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.domain.Pageable; +import org.springframework.data.repository.query.Param; import java.util.List; @@ -11,4 +14,28 @@ public interface TransactionRepository extends JpaRepository findByMerchantIdAndConnectionModeIgnoreCase(Long merchantId, String connectionMode); List findByConnectionModeIgnoreCase(String connectionMode); + + @Query(""" + select t from MerchantTransaction t + where (:afterId is null or t.id > :afterId) + and (:connectionMode is null or lower(t.connectionMode) = lower(:connectionMode)) + order by t.id asc + """) + List findLedgerPage( + @Param("connectionMode") String connectionMode, + @Param("afterId") Long afterId, + Pageable pageable); + + @Query(""" + select t from MerchantTransaction t + where t.merchantId = :merchantId + and (:afterId is null or t.id > :afterId) + and (:connectionMode is null or lower(t.connectionMode) = lower(:connectionMode)) + order by t.id asc + """) + List findMerchantLedgerPage( + @Param("merchantId") Long merchantId, + @Param("connectionMode") String connectionMode, + @Param("afterId") Long afterId, + Pageable pageable); } diff --git a/src/main/java/com/portfolio/merchantapi/transaction/TransactionService.java b/src/main/java/com/portfolio/merchantapi/transaction/TransactionService.java index f7bc6b0..ac4cb1b 100644 --- a/src/main/java/com/portfolio/merchantapi/transaction/TransactionService.java +++ b/src/main/java/com/portfolio/merchantapi/transaction/TransactionService.java @@ -6,6 +6,8 @@ import com.portfolio.merchantapi.merchant.MerchantService; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,9 +25,7 @@ public class TransactionService { @Transactional public TransactionResponse createTransaction(TransactionRequest request, String idempotencyKey) { validateIdempotencyKey(idempotencyKey); - if (!merchantService.existsById(request.merchantId())) { - throw new MerchantNotFoundException(request.merchantId()); - } + merchantService.lockMerchant(request.merchantId()); IdempotencyRecord idempotencyRecord = createIdempotencyRecord(idempotencyKey); MerchantTransaction transaction = new MerchantTransaction(); @@ -43,37 +43,41 @@ public TransactionResponse createTransaction(TransactionRequest request, String return TransactionResponse.from(savedTransaction); } - public List findTransactionsByMerchant(Long merchantId, String connectionMode) { + public TransactionPageResponse findTransactionsByMerchant(Long merchantId, String connectionMode, Long afterId, int limit) { if (!merchantService.existsById(merchantId)) { throw new MerchantNotFoundException(merchantId); } - return findMerchantTransactions(merchantId, connectionMode).stream() - .map(TransactionResponse::from) - .toList(); + List transactions = transactionRepository.findMerchantLedgerPage( + merchantId, + normalizedConnectionMode(connectionMode), + afterId, + pageRequest(limit) + ); + + return pageResponse(transactions, limit); } - public List findTransactions(String connectionMode) { - List transactions = isConnectionMode(connectionMode) - ? transactionRepository.findByConnectionModeIgnoreCase(connectionMode) - : transactionRepository.findAll(); + public TransactionPageResponse findTransactions(String connectionMode, Long afterId, int limit) { + List transactions = transactionRepository.findLedgerPage( + normalizedConnectionMode(connectionMode), + afterId, + pageRequest(limit) + ); - return transactions.stream() - .map(TransactionResponse::from) - .toList(); + return pageResponse(transactions, limit); } - private List findMerchantTransactions(Long merchantId, String connectionMode) { - if (isConnectionMode(connectionMode)) { - return transactionRepository.findByMerchantIdAndConnectionModeIgnoreCase(merchantId, connectionMode); + private String normalizedConnectionMode(String connectionMode) { + if (connectionMode == null) { + return null; } - return transactionRepository.findByMerchantId(merchantId); - } + if (connectionMode.equalsIgnoreCase("Live") || connectionMode.equalsIgnoreCase("Test")) { + return connectionMode; + } - private boolean isConnectionMode(String connectionMode) { - return connectionMode != null - && (connectionMode.equalsIgnoreCase("Live") || connectionMode.equalsIgnoreCase("Test")); + return null; } private IdempotencyRecord createIdempotencyRecord(String idempotencyKey) { @@ -101,4 +105,27 @@ private void validateIdempotencyKey(String idempotencyKey) { throw new InvalidIdempotencyKeyException("Idempotency-Key must be 128 characters or less"); } } + + private Pageable pageRequest(int limit) { + int sanitizedLimit = sanitizedLimit(limit); + return PageRequest.of(0, sanitizedLimit + 1); + } + + private TransactionPageResponse pageResponse(List transactions, int requestedLimit) { + int sanitizedLimit = sanitizedLimit(requestedLimit); + boolean hasMore = transactions.size() > sanitizedLimit; + List visibleTransactions = hasMore + ? transactions.subList(0, sanitizedLimit) + : transactions; + List responses = visibleTransactions.stream() + .map(TransactionResponse::from) + .toList(); + Long nextCursor = hasMore ? visibleTransactions.getLast().getId() : null; + + return new TransactionPageResponse(responses, nextCursor, hasMore); + } + + private int sanitizedLimit(int limit) { + return Math.max(1, Math.min(limit, 100)); + } } diff --git a/src/main/resources/application-test.properties b/src/main/resources/application-test.properties index c46c4b6..4475d46 100644 --- a/src/main/resources/application-test.properties +++ b/src/main/resources/application-test.properties @@ -8,3 +8,12 @@ spring.jpa.open-in-view=false spring.flyway.enabled=true spring.flyway.locations=classpath:db/migration/h2 + +spring.data.redis.host=localhost +spring.data.redis.port=6379 + +merchant-api.security.jwt-secret=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef +merchant-api.idempotency.retention=PT24H +merchant-api.idempotency.cleanup-delay=PT1H +merchant-api.rate-limit.capacity=1000 +merchant-api.rate-limit.refill-per-minute=1000 diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 12a770b..de8ee00 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,8 +1,8 @@ spring.application.name=merchant-api -spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:oracle:thin:@localhost:1521:orcl} -spring.datasource.username=${SPRING_DATASOURCE_USERNAME:system} -spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:oracle} +spring.datasource.url=${SPRING_DATASOURCE_URL} +spring.datasource.username=${SPRING_DATASOURCE_USERNAME} +spring.datasource.password=${SPRING_DATASOURCE_PASSWORD} spring.jpa.hibernate.ddl-auto=validate spring.jpa.open-in-view=false @@ -10,4 +10,21 @@ spring.jpa.open-in-view=false spring.flyway.enabled=true spring.flyway.locations=classpath:db/migration/oracle +spring.data.redis.host=${REDIS_HOST} +spring.data.redis.port=${REDIS_PORT:6379} +spring.data.redis.repositories.enabled=false + +merchant-api.security.jwt-secret=${JWT_SECRET} + +management.endpoints.web.exposure.include=health,info +management.endpoint.health.probes.enabled=true +management.endpoints.web.base-path=/ +management.endpoint.health.group.live.include=livenessState +management.endpoint.health.group.ready.include=readinessState,db,redis + +merchant-api.idempotency.retention=PT24H +merchant-api.idempotency.cleanup-delay=PT15M +merchant-api.rate-limit.capacity=${RATE_LIMIT_CAPACITY:120} +merchant-api.rate-limit.refill-per-minute=${RATE_LIMIT_REFILL_PER_MINUTE:120} + springdoc.swagger-ui.path=/swagger-ui.html diff --git a/src/main/resources/db/migration/h2/V3__create_api_key_table.sql b/src/main/resources/db/migration/h2/V3__create_api_key_table.sql new file mode 100644 index 0000000..caee665 --- /dev/null +++ b/src/main/resources/db/migration/h2/V3__create_api_key_table.sql @@ -0,0 +1,17 @@ +CREATE TABLE api_key ( + key_hash VARCHAR(64) NOT NULL, + merchant_id BIGINT NOT NULL, + role_name VARCHAR(50) NOT NULL, + active BOOLEAN NOT NULL, + created_at TIMESTAMP NOT NULL, + CONSTRAINT pk_api_key PRIMARY KEY (key_hash), + CONSTRAINT fk_api_key_merchant + FOREIGN KEY (merchant_id) + REFERENCES merchant (id) +); + +CREATE INDEX idx_api_key_merchant_id + ON api_key (merchant_id); + +CREATE INDEX idx_merchant_transaction_id_merchant + ON merchant_transaction (id, merchant_id); diff --git a/src/main/resources/db/migration/oracle/V3__create_api_key_table.sql b/src/main/resources/db/migration/oracle/V3__create_api_key_table.sql new file mode 100644 index 0000000..06f3e3f --- /dev/null +++ b/src/main/resources/db/migration/oracle/V3__create_api_key_table.sql @@ -0,0 +1,19 @@ +CREATE TABLE api_key ( + key_hash VARCHAR2(64) NOT NULL, + merchant_id NUMBER(19, 0) NOT NULL, + role_name VARCHAR2(50) NOT NULL, + active NUMBER(1) NOT NULL, + created_at TIMESTAMP NOT NULL, + CONSTRAINT pk_api_key PRIMARY KEY (key_hash), + CONSTRAINT fk_api_key_merchant + FOREIGN KEY (merchant_id) + REFERENCES merchant (id), + CONSTRAINT ck_api_key_active + CHECK (active IN (0, 1)) +); + +CREATE INDEX idx_api_key_merchant_id + ON api_key (merchant_id); + +CREATE INDEX idx_merchant_transaction_id_merchant + ON merchant_transaction (id, merchant_id); diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..14f349a --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,17 @@ + + + + + + + {"service":"${applicationName}"} + correlation_id + trace_id + span_id + + + + + + + diff --git a/src/test/java/com/portfolio/merchantapi/merchant/MerchantControllerTest.java b/src/test/java/com/portfolio/merchantapi/merchant/MerchantControllerTest.java index 1d97384..06e7ab6 100644 --- a/src/test/java/com/portfolio/merchantapi/merchant/MerchantControllerTest.java +++ b/src/test/java/com/portfolio/merchantapi/merchant/MerchantControllerTest.java @@ -2,7 +2,11 @@ import com.portfolio.merchantapi.common.exception.GlobalExceptionHandler; import com.portfolio.merchantapi.common.exception.MerchantNotFoundException; +import com.portfolio.merchantapi.rate.RateLimiterService; +import com.portfolio.merchantapi.security.ApiKeyHasher; +import com.portfolio.merchantapi.security.ApiKeyRepository; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -18,6 +22,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(MerchantController.class) +@AutoConfigureMockMvc(addFilters = false) @Import(GlobalExceptionHandler.class) class MerchantControllerTest { @@ -27,6 +32,15 @@ class MerchantControllerTest { @MockitoBean private MerchantService merchantService; + @MockitoBean + private RateLimiterService rateLimiterService; + + @MockitoBean + private ApiKeyRepository apiKeyRepository; + + @MockitoBean + private ApiKeyHasher apiKeyHasher; + @Test void createMerchantReturnsCreated() throws Exception { when(merchantService.createMerchant(any(MerchantRequest.class))) diff --git a/src/test/java/com/portfolio/merchantapi/rate/RateLimiterServiceTest.java b/src/test/java/com/portfolio/merchantapi/rate/RateLimiterServiceTest.java new file mode 100644 index 0000000..328ea48 --- /dev/null +++ b/src/test/java/com/portfolio/merchantapi/rate/RateLimiterServiceTest.java @@ -0,0 +1,38 @@ +package com.portfolio.merchantapi.rate; + +import com.portfolio.merchantapi.common.config.SecurityProperties; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class RateLimiterServiceTest { + + private final StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + private final SecurityProperties properties = new SecurityProperties( + new SecurityProperties.Security("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"), + new SecurityProperties.Idempotency(Duration.ofHours(24), Duration.ofMinutes(15)), + new SecurityProperties.RateLimit(10, 60) + ); + + @Test + void allowsRequestWhenRedisScriptReturnsOne() { + when(redisTemplate.execute(any(RedisScript.class), anyList(), any(), any(), any(), any(), any())).thenReturn(1L); + + assertThat(new RateLimiterService(redisTemplate, properties).allow("merchant:1")).isTrue(); + } + + @Test + void rejectsRequestWhenRedisScriptReturnsZero() { + when(redisTemplate.execute(any(RedisScript.class), anyList(), any(), any(), any(), any(), any())).thenReturn(0L); + + assertThat(new RateLimiterService(redisTemplate, properties).allow("merchant:1")).isFalse(); + } +} diff --git a/src/test/java/com/portfolio/merchantapi/security/SecurityIntegrationTest.java b/src/test/java/com/portfolio/merchantapi/security/SecurityIntegrationTest.java new file mode 100644 index 0000000..9fd3278 --- /dev/null +++ b/src/test/java/com/portfolio/merchantapi/security/SecurityIntegrationTest.java @@ -0,0 +1,124 @@ +package com.portfolio.merchantapi.security; + +import com.nimbusds.jose.jwk.source.ImmutableSecret; +import com.portfolio.merchantapi.merchant.Merchant; +import com.portfolio.merchantapi.merchant.MerchantRepository; +import com.portfolio.merchantapi.rate.RateLimiterService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jwt.JwsHeader; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class SecurityIntegrationTest { + + private static final String JWT_SECRET = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private MerchantRepository merchantRepository; + + @Autowired + private ApiKeyRepository apiKeyRepository; + + @Autowired + private ApiKeyHasher apiKeyHasher; + + @MockitoBean + private RateLimiterService rateLimiterService; + + private Merchant merchant; + + @BeforeEach + void setUp() { + when(rateLimiterService.allow(anyString())).thenReturn(true); + apiKeyRepository.deleteAll(); + merchantRepository.deleteAll(); + + merchant = new Merchant(); + merchant.setMerchantName("Acme Market"); + merchant.setMerchantAddress("100 Main Street"); + merchant = merchantRepository.save(merchant); + } + + @Test + void rejectsAnonymousAdminRequest() throws Exception { + mockMvc.perform(get("/api/v1/merchants/{merchantId}", merchant.getId())) + .andExpect(status().isUnauthorized()); + } + + @Test + void rejectsMerchantApiKeyForAdminEndpoint() throws Exception { + String apiKey = createApiKey(); + + mockMvc.perform(get("/api/v1/merchants/{merchantId}", merchant.getId()) + .header(ApiKeyAuthenticationFilter.API_KEY_HEADER, apiKey)) + .andExpect(status().isForbidden()); + } + + @Test + void allowsAdminJwtForAdminEndpoint() throws Exception { + mockMvc.perform(get("/api/v1/merchants/{merchantId}", merchant.getId()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + jwt("admin", List.of("ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(merchant.getId())); + } + + @Test + void returnsTooManyRequestsWhenRateLimitIsExceeded() throws Exception { + when(rateLimiterService.allow(anyString())).thenReturn(false); + + mockMvc.perform(get("/api/v1/merchants/{merchantId}", merchant.getId()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + jwt("admin", List.of("ADMIN")))) + .andExpect(status().isTooManyRequests()) + .andExpect(jsonPath("$.title").value("Too many requests")); + } + + private String createApiKey() { + String rawApiKey = "merchant-api-key"; + ApiKey apiKey = new ApiKey(); + apiKey.setKeyHash(apiKeyHasher.hash(rawApiKey)); + apiKey.setMerchantId(merchant.getId()); + apiKey.setRoleName("MERCHANT"); + apiKey.setActive(true); + apiKey.setCreatedAt(LocalDateTime.now()); + apiKeyRepository.save(apiKey); + return rawApiKey; + } + + private String jwt(String subject, List roles) { + NimbusJwtEncoder encoder = new NimbusJwtEncoder(new ImmutableSecret<>(JWT_SECRET.getBytes())); + JwtClaimsSet claims = JwtClaimsSet.builder() + .subject(subject) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .claim("roles", roles) + .build(); + JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build(); + return encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue(); + } +} diff --git a/src/test/java/com/portfolio/merchantapi/transaction/TransactionControllerTest.java b/src/test/java/com/portfolio/merchantapi/transaction/TransactionControllerTest.java index c6e6f50..755f1b1 100644 --- a/src/test/java/com/portfolio/merchantapi/transaction/TransactionControllerTest.java +++ b/src/test/java/com/portfolio/merchantapi/transaction/TransactionControllerTest.java @@ -3,8 +3,12 @@ import com.portfolio.merchantapi.common.exception.DuplicateIdempotencyKeyException; import com.portfolio.merchantapi.common.exception.GlobalExceptionHandler; import com.portfolio.merchantapi.common.exception.MerchantNotFoundException; +import com.portfolio.merchantapi.rate.RateLimiterService; +import com.portfolio.merchantapi.security.ApiKeyHasher; +import com.portfolio.merchantapi.security.ApiKeyRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; @@ -15,6 +19,8 @@ import java.time.LocalDateTime; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -23,6 +29,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(TransactionController.class) +@AutoConfigureMockMvc(addFilters = false) @Import(GlobalExceptionHandler.class) class TransactionControllerTest { @@ -32,6 +39,15 @@ class TransactionControllerTest { @MockitoBean private TransactionService transactionService; + @MockitoBean + private RateLimiterService rateLimiterService; + + @MockitoBean + private ApiKeyRepository apiKeyRepository; + + @MockitoBean + private ApiKeyHasher apiKeyHasher; + @Test void createTransactionReturnsCreated() throws Exception { when(transactionService.createTransaction(any(TransactionRequest.class), anyString())) @@ -122,7 +138,7 @@ void createTransactionReturnsConflictForDuplicateIdempotencyKey() throws Excepti @Test void getMerchantTransactionsReturnsNotFoundForMissingMerchant() throws Exception { - when(transactionService.findTransactionsByMerchant(99L, null)) + when(transactionService.findTransactionsByMerchant(anyLong(), any(), any(), anyInt())) .thenThrow(new MerchantNotFoundException(99L)); mockMvc.perform(get("/api/v1/merchants/{merchantId}/transactions", 99L)) diff --git a/src/test/java/com/portfolio/merchantapi/transaction/TransactionIntegrationTest.java b/src/test/java/com/portfolio/merchantapi/transaction/TransactionIntegrationTest.java index c6d0d35..0f59b48 100644 --- a/src/test/java/com/portfolio/merchantapi/transaction/TransactionIntegrationTest.java +++ b/src/test/java/com/portfolio/merchantapi/transaction/TransactionIntegrationTest.java @@ -2,6 +2,9 @@ import com.portfolio.merchantapi.merchant.MerchantRequest; import com.portfolio.merchantapi.merchant.MerchantResponse; +import com.portfolio.merchantapi.security.ApiKey; +import com.portfolio.merchantapi.security.ApiKeyHasher; +import com.portfolio.merchantapi.security.ApiKeyRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -10,23 +13,38 @@ import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.OracleContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.utility.DockerImageName; +import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jwt.JwsHeader; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; +import com.nimbusds.jose.jwk.source.ImmutableSecret; import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.stream.IntStream; import static org.assertj.core.api.Assertions.assertThat; -@Testcontainers +@Testcontainers(disabledWithoutDocker = true) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class TransactionIntegrationTest { + private static final String JWT_SECRET = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; private static final DockerImageName ORACLE_IMAGE = DockerImageName .parse("gvenzl/oracle-xe:21-slim-faststart") .asCompatibleSubstituteFor("gvenzl/oracle-xe"); @@ -36,12 +54,22 @@ class TransactionIntegrationTest { .withUsername("merchant_api") .withPassword("merchant_api"); + @Container + static final GenericContainer redis = new GenericContainer<>("redis:7-alpine") + .withExposedPorts(6379); + @LocalServerPort private int port; @Autowired private TestRestTemplate restTemplate; + @Autowired + private ApiKeyRepository apiKeyRepository; + + @Autowired + private ApiKeyHasher apiKeyHasher; + @DynamicPropertySource static void configureOracle(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", oracle::getJdbcUrl); @@ -49,11 +77,17 @@ static void configureOracle(DynamicPropertyRegistry registry) { registry.add("spring.datasource.password", oracle::getPassword); registry.add("spring.flyway.locations", () -> "classpath:db/migration/oracle"); registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); + registry.add("spring.data.redis.host", redis::getHost); + registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379)); + registry.add("merchant-api.security.jwt-secret", () -> JWT_SECRET); + registry.add("merchant-api.rate-limit.capacity", () -> "1000"); + registry.add("merchant-api.rate-limit.refill-per-minute", () -> "1000"); } @Test void createTransactionRejectsDuplicateIdempotencyKey() { MerchantResponse merchant = createMerchant(); + String apiKey = createApiKey(merchant.id()); TransactionRequest request = new TransactionRequest( BigDecimal.valueOf(25.50), merchant.id(), @@ -61,7 +95,7 @@ void createTransactionRejectsDuplicateIdempotencyKey() { "Live" ); - HttpEntity transactionEntity = new HttpEntity<>(request, idempotencyHeaders()); + HttpEntity transactionEntity = new HttpEntity<>(request, idempotencyHeaders("txn-integration-0001", apiKey)); ResponseEntity firstResponse = restTemplate.postForEntity( url("/api/v1/transactions"), @@ -82,10 +116,55 @@ void createTransactionRejectsDuplicateIdempotencyKey() { assertThat(duplicateResponse.getBody()).contains("Duplicate idempotency key"); } + @Test + void concurrentDuplicateRequestsCreateOneTransaction() throws Exception { + MerchantResponse merchant = createMerchant(); + String apiKey = createApiKey(merchant.id()); + TransactionRequest request = new TransactionRequest( + BigDecimal.valueOf(19.99), + merchant.id(), + "SALE", + "Live" + ); + HttpEntity entity = new HttpEntity<>(request, idempotencyHeaders("txn-concurrent-0001", apiKey)); + + try (var executor = Executors.newFixedThreadPool(8)) { + List> calls = IntStream.range(0, 8) + .mapToObj(index -> (Callable) () -> restTemplate.postForEntity( + url("/api/v1/transactions"), + entity, + String.class + ).getStatusCode()) + .toList(); + + List statuses = executor.invokeAll(calls).stream() + .map(future -> { + try { + return future.get().value(); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + }) + .toList(); + + assertThat(statuses).containsExactlyInAnyOrderElementsOf(List.of( + HttpStatus.CREATED.value(), + HttpStatus.CONFLICT.value(), + HttpStatus.CONFLICT.value(), + HttpStatus.CONFLICT.value(), + HttpStatus.CONFLICT.value(), + HttpStatus.CONFLICT.value(), + HttpStatus.CONFLICT.value(), + HttpStatus.CONFLICT.value() + )); + } + } + private MerchantResponse createMerchant() { MerchantRequest request = new MerchantRequest("Acme Market", "100 Main Street"); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(adminToken()); ResponseEntity response = restTemplate.postForEntity( url("/api/v1/merchants"), @@ -98,14 +177,39 @@ private MerchantResponse createMerchant() { return response.getBody(); } - private HttpHeaders idempotencyHeaders() { + private HttpHeaders idempotencyHeaders(String idempotencyKey, String apiKey) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("Idempotency-Key", "txn-integration-0001"); + headers.set("Idempotency-Key", idempotencyKey); + headers.set("X-API-Key", apiKey); return headers; } private String url(String path) { return "http://localhost:" + port + path; } + + private String createApiKey(Long merchantId) { + String rawApiKey = "merchant-test-api-key-" + merchantId; + ApiKey apiKey = new ApiKey(); + apiKey.setKeyHash(apiKeyHasher.hash(rawApiKey)); + apiKey.setMerchantId(merchantId); + apiKey.setRoleName("MERCHANT"); + apiKey.setActive(true); + apiKey.setCreatedAt(LocalDateTime.now()); + apiKeyRepository.save(apiKey); + return rawApiKey; + } + + private String adminToken() { + NimbusJwtEncoder encoder = new NimbusJwtEncoder(new ImmutableSecret<>(JWT_SECRET.getBytes())); + JwtClaimsSet claims = JwtClaimsSet.builder() + .subject("admin") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .claim("roles", List.of("ADMIN")) + .build(); + JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build(); + return encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue(); + } }