diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/FailureAuditIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/FailureAuditIT.kt new file mode 100644 index 0000000..cf69129 --- /dev/null +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/FailureAuditIT.kt @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.api + +import com.fincore.core.AccountId +import com.fincore.core.TransactionId +import com.fincore.ledger.api.idempotency.IdempotencyAttributes +import com.fincore.ledger.api.observability.CorrelationIdAttributes +import com.fincore.ledger.application.RequestHashing +import com.fincore.ledger.domain.enum.AuditResult +import com.fincore.ledger.infrastructure.persistence.AuditEventRepository +import com.fincore.ledger.infrastructure.persistence.OutboxEventRepository +import com.fincore.test.containers.PostgresContainerExtension +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import java.time.Instant +import java.util.UUID + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ExtendWith(PostgresContainerExtension::class) +@Import(FailureAuditIT.TestSecurity::class) +class FailureAuditIT( + @Autowired private val rest: TestRestTemplate, + @Autowired private val auditRepository: AuditEventRepository, + @Autowired private val outboxRepository: OutboxEventRepository, +) { + @TestConfiguration + class TestSecurity { + @Bean + fun jwtDecoder(): JwtDecoder = + JwtDecoder { token -> + Jwt + .withTokenValue(token) + .header("alg", "none") + .subject(ACTOR) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(EXPIRY_SECONDS)) + .build() + } + } + + @AfterEach + fun cleanOutbox() { + outboxRepository.deleteAll() + } + + @Test + fun `should write a single committed FAILURE row with request hash for an unbalanced post`() { + val correlationId = UUID.randomUUID().toString() + val body = + """{"reference":"ref-${unique()}","currency":"USD","entries":[""" + + """{"accountId":"${AccountId.generate()}","direction":"DEBIT","amount":"100"},""" + + """{"accountId":"${AccountId.generate()}","direction":"CREDIT","amount":"50"}]}""" + + val response = post("/v1/transactions", body, correlationId) + + response.statusCode.value() shouldBe 422 + val rows = auditRepository.findAll().filter { it.correlationId == correlationId } + rows shouldHaveSize 1 + val row = rows.first() + row.result shouldBe AuditResult.FAILURE + row.action shouldBe "TRANSACTION_POST" + row.resourceType shouldBe "TRANSACTION" + row.actorId shouldBe ACTOR + row.requestHash shouldBe RequestHashing.sha256Hex(body) + } + + @Test + fun `should write a single FAILURE row with the original transaction id for a reverse of an unknown transaction`() { + val correlationId = UUID.randomUUID().toString() + val unknownId = TransactionId.generate().toString() + val body = "{}" + + val response = post("/v1/transactions/$unknownId/reverse", body, correlationId) + + response.statusCode.value() shouldBe 404 + val rows = auditRepository.findAll().filter { it.correlationId == correlationId } + rows shouldHaveSize 1 + val row = rows.first() + row.result shouldBe AuditResult.FAILURE + row.action shouldBe "TRANSACTION_REVERSE" + row.resourceId shouldBe unknownId + row.requestHash shouldBe RequestHashing.sha256Hex(body) + } + + @Test + fun `should not write any audit row for a validation failure`() { + val correlationId = UUID.randomUUID().toString() + + val response = post("/v1/transactions", """{"currency":""}""", correlationId) + + response.statusCode.value() shouldBe 400 + auditRepository.findAll().filter { it.correlationId == correlationId } shouldHaveSize 0 + } + + private fun post( + path: String, + body: String, + correlationId: String, + ) = rest.exchange( + path, + HttpMethod.POST, + HttpEntity( + body, + HttpHeaders().apply { + contentType = MediaType.APPLICATION_JSON + setBearerAuth("failure-audit-it-token") + set(IdempotencyAttributes.HEADER, idemKey()) + set(CorrelationIdAttributes.HEADER, correlationId) + }, + ), + String::class.java, + ) + + private companion object { + const val ACTOR = "failure-audit-it" + const val EXPIRY_SECONDS = 3600L + const val KEY_LENGTH = 40 + private var counter = 0 + + fun unique(): Int = ++counter + + fun idemKey(): String { + val suffix = unique().toString() + return "k".repeat(KEY_LENGTH - suffix.length) + suffix + } + + @JvmStatic + @DynamicPropertySource + fun datasourceProperties(registry: DynamicPropertyRegistry) { + registry.add("spring.datasource.url") { PostgresContainerExtension.jdbcUrl } + registry.add("spring.datasource.username") { PostgresContainerExtension.username } + registry.add("spring.datasource.password") { PostgresContainerExtension.password } + registry.add("spring.datasource.hikari.maximum-pool-size") { "2" } + registry.add("spring.jpa.hibernate.ddl-auto") { "none" } + } + } +} diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/config/DeniedAuditIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/config/DeniedAuditIT.kt new file mode 100644 index 0000000..ff78d9a --- /dev/null +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/config/DeniedAuditIT.kt @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.config + +import com.fincore.ledger.api.idempotency.IdempotencyAttributes +import com.fincore.ledger.api.observability.CorrelationIdAttributes +import com.fincore.ledger.domain.enum.AuditResult +import com.fincore.ledger.infrastructure.persistence.AuditEventRepository +import com.fincore.test.containers.PostgresContainerExtension +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.core.annotation.Order +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.security.web.SecurityFilterChain +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import java.time.Instant +import java.util.UUID + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ExtendWith(PostgresContainerExtension::class) +@Import(DeniedAuditIT.DenyWritesSecurity::class) +class DeniedAuditIT( + @Autowired private val rest: TestRestTemplate, + @Autowired private val auditRepository: AuditEventRepository, +) { + @TestConfiguration + class DenyWritesSecurity { + @Bean + @Order(1) + fun deniedAccountsChain( + http: HttpSecurity, + accessDeniedHandler: AuditingAccessDeniedHandler, + ): SecurityFilterChain = + http + .securityMatcher("/v1/accounts") + .csrf { it.disable() } + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .authorizeHttpRequests { it.anyRequest().denyAll() } + .exceptionHandling { it.accessDeniedHandler(accessDeniedHandler) } + .oauth2ResourceServer { it.jwt {} } + .build() + + @Bean + fun jwtDecoder(): JwtDecoder = + JwtDecoder { token -> + Jwt + .withTokenValue(token) + .header("alg", "none") + .subject(ACTOR) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(EXPIRY_SECONDS)) + .build() + } + } + + @Test + fun `should write a single committed DENIED row when an authenticated write is forbidden`() { + val correlationId = UUID.randomUUID().toString() + + val response = post(token = "valid-token", correlationId = correlationId) + + response.statusCode.value() shouldBe 403 + val rows = auditRepository.findAll().filter { it.correlationId == correlationId } + rows shouldHaveSize 1 + val row = rows.first() + row.result shouldBe AuditResult.DENIED + row.action shouldBe "ACCOUNT_CREATE" + row.resourceType shouldBe "ACCOUNT" + row.resourceId shouldBe "unknown" + row.actorId shouldBe ACTOR + } + + @Test + fun `should write no audit row when an unauthenticated write is rejected`() { + val correlationId = UUID.randomUUID().toString() + + val response = post(token = null, correlationId = correlationId) + + response.statusCode.value() shouldBe 401 + auditRepository.findAll().filter { it.correlationId == correlationId } shouldHaveSize 0 + } + + private fun post( + token: String?, + correlationId: String, + ) = rest.exchange( + "/v1/accounts", + HttpMethod.POST, + HttpEntity( + """{"name":"Denied wallet","type":"USER_WALLET","currency":"USD"}""", + HttpHeaders().apply { + contentType = MediaType.APPLICATION_JSON + set(IdempotencyAttributes.HEADER, idemKey()) + set(CorrelationIdAttributes.HEADER, correlationId) + token?.let { setBearerAuth(it) } + }, + ), + String::class.java, + ) + + private companion object { + const val ACTOR = "denied-audit-it" + const val EXPIRY_SECONDS = 3600L + const val KEY_LENGTH = 40 + private var counter = 0 + + fun unique(): Int = ++counter + + fun idemKey(): String { + val suffix = unique().toString() + return "d".repeat(KEY_LENGTH - suffix.length) + suffix + } + + @JvmStatic + @DynamicPropertySource + fun datasourceProperties(registry: DynamicPropertyRegistry) { + registry.add("spring.datasource.url") { PostgresContainerExtension.jdbcUrl } + registry.add("spring.datasource.username") { PostgresContainerExtension.username } + registry.add("spring.datasource.password") { PostgresContainerExtension.password } + registry.add("spring.datasource.hikari.maximum-pool-size") { "2" } + registry.add("spring.jpa.hibernate.ddl-auto") { "none" } + } + } +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/api/error/AuditEndpointResolver.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/api/error/AuditEndpointResolver.kt new file mode 100644 index 0000000..eb94c2a --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/api/error/AuditEndpointResolver.kt @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.api.error + +import com.fincore.ledger.domain.enum.AuditAction +import com.fincore.ledger.domain.enum.AuditResourceType +import org.springframework.http.HttpMethod +import org.springframework.stereotype.Component + +data class AuditedEndpoint( + val action: AuditAction, + val resourceType: AuditResourceType, + val resourceId: String, +) + +@Component +class AuditEndpointResolver { + fun resolve( + method: String, + uri: String, + ): AuditedEndpoint? { + if (method != HttpMethod.POST.name()) return null + REVERSE_PATH.matchEntire(uri)?.let { + return AuditedEndpoint(AuditAction.TRANSACTION_REVERSE, AuditResourceType.TRANSACTION, it.groupValues[1]) + } + return when (uri) { + ACCOUNTS_PATH -> AuditedEndpoint(AuditAction.ACCOUNT_CREATE, AuditResourceType.ACCOUNT, NOT_YET_CREATED) + TRANSACTIONS_PATH -> AuditedEndpoint(AuditAction.TRANSACTION_POST, AuditResourceType.TRANSACTION, NOT_YET_CREATED) + else -> null + } + } + + private companion object { + const val ACCOUNTS_PATH = "/v1/accounts" + const val TRANSACTIONS_PATH = "/v1/transactions" + const val NOT_YET_CREATED = "unknown" + val REVERSE_PATH = Regex("/v1/transactions/([^/]+)/reverse") + } +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/api/error/ProblemType.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/api/error/ProblemType.kt index 4bdbf11..30afd01 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/api/error/ProblemType.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/api/error/ProblemType.kt @@ -49,6 +49,7 @@ enum class ProblemType( DOUBLE_ENTRY_VIOLATION("double-entry-violation", HttpStatus.UNPROCESSABLE_ENTITY, "ENTRIES_SUM_NOT_ZERO", "double-entry violation"), DOMAIN_RULE_VIOLATION("domain-rule-violation", HttpStatus.UNPROCESSABLE_ENTITY, "DOMAIN_RULE_VIOLATION", "domain rule violation"), CONCURRENCY_CONFLICT("concurrency-conflict", HttpStatus.SERVICE_UNAVAILABLE, "CONCURRENCY_CONFLICT", "concurrency conflict, retry"), + ACCESS_DENIED("access-denied", HttpStatus.FORBIDDEN, "ACCESS_DENIED", "access denied"), VALIDATION_FAILED("validation-failed", HttpStatus.BAD_REQUEST, "VALIDATION_FAILED", "invalid request"), MALFORMED_REQUEST("malformed-request", HttpStatus.BAD_REQUEST, "MALFORMED_REQUEST", "invalid request"), INVALID_REQUEST("invalid-request", HttpStatus.BAD_REQUEST, "INVALID_REQUEST", "invalid request"), diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/api/security/CurrentActor.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/api/security/CurrentActor.kt new file mode 100644 index 0000000..4202c26 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/api/security/CurrentActor.kt @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.api.security + +import org.springframework.security.core.context.SecurityContextHolder + +object CurrentActor { + private const val ANONYMOUS = "anonymousUser" + + fun resolveOrNull(): String? { + val authentication = SecurityContextHolder.getContext().authentication ?: return null + if (!authentication.isAuthenticated || authentication.name == ANONYMOUS) return null + return authentication.name + } +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/AuditTrailWriter.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/AuditTrailWriter.kt index 14eb294..faeda9b 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/application/AuditTrailWriter.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/AuditTrailWriter.kt @@ -5,6 +5,7 @@ package com.fincore.ledger.application import com.fincore.ledger.domain.enum.AuditAction import com.fincore.ledger.domain.enum.AuditResourceType +import com.fincore.ledger.domain.enum.AuditResult @Suppress("LongParameterList") data class AuditRecord( @@ -18,4 +19,9 @@ data class AuditRecord( interface AuditTrailWriter { fun record(record: AuditRecord) + + fun recordOutcome( + record: AuditRecord, + result: AuditResult, + ) } diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyServiceImpl.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyServiceImpl.kt index c432a9b..041a2ba 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyServiceImpl.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyServiceImpl.kt @@ -7,7 +7,6 @@ import com.fincore.core.IdempotencyKey import com.fincore.ledger.domain.exception.ConcurrencyConflictException import org.springframework.dao.OptimisticLockingFailureException import org.springframework.stereotype.Service -import java.security.MessageDigest @Service class IdempotencyServiceImpl( @@ -18,8 +17,8 @@ class IdempotencyServiceImpl( requestBody: String, action: (String) -> StoredResponse, ): IdempotentResult { - val keyHash = sha256Hex(key.value) - val requestHash = sha256Hex(requestBody) + val keyHash = RequestHashing.sha256Hex(key.value) + val requestHash = RequestHashing.sha256Hex(requestBody) return runWithRetry(keyHash, requestHash, action) } @@ -42,12 +41,6 @@ class IdempotencyServiceImpl( } } - private fun sha256Hex(value: String): String = - MessageDigest - .getInstance("SHA-256") - .digest(value.toByteArray(Charsets.UTF_8)) - .joinToString("") { "%02x".format(it) } - companion object { const val MAX_ATTEMPTS = 3 } diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/RequestHashing.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/RequestHashing.kt new file mode 100644 index 0000000..238298a --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/RequestHashing.kt @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.application + +import java.security.MessageDigest + +object RequestHashing { + fun sha256Hex(value: String): String = + MessageDigest + .getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/config/AuditingAccessDeniedHandler.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/config/AuditingAccessDeniedHandler.kt new file mode 100644 index 0000000..82d7c89 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/config/AuditingAccessDeniedHandler.kt @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.config + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fincore.ledger.api.error.AuditEndpointResolver +import com.fincore.ledger.api.error.ProblemType +import com.fincore.ledger.api.security.CurrentActor +import com.fincore.ledger.application.AuditRecord +import com.fincore.ledger.application.AuditTrailWriter +import com.fincore.ledger.domain.enum.AuditResult +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory +import org.springframework.http.MediaType +import org.springframework.http.ProblemDetail +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.web.access.AccessDeniedHandler +import org.springframework.stereotype.Component +import java.net.URI + +@Component +class AuditingAccessDeniedHandler( + private val auditTrailWriter: AuditTrailWriter, + private val endpointResolver: AuditEndpointResolver, + private val objectMapper: ObjectMapper, +) : AccessDeniedHandler { + override fun handle( + request: HttpServletRequest, + response: HttpServletResponse, + accessDeniedException: AccessDeniedException, + ) { + recordDenied(request) + writeForbidden(request, response) + } + + private fun recordDenied(request: HttpServletRequest) { + val endpoint = endpointResolver.resolve(request.method, request.requestURI) ?: return + val actor = CurrentActor.resolveOrNull() ?: return + val record = + AuditRecord( + actorId = actor, + action = endpoint.action, + resourceType = endpoint.resourceType, + resourceId = endpoint.resourceId, + requestHash = null, + payload = mapOf("code" to ProblemType.ACCESS_DENIED.code), + ) + try { + auditTrailWriter.recordOutcome(record, AuditResult.DENIED) + } catch ( + @Suppress("TooGenericExceptionCaught") ex: RuntimeException, + ) { + log.warn("audit DENIED write failed for {} {}", request.method, request.requestURI, ex) + } + } + + private fun writeForbidden( + request: HttpServletRequest, + response: HttpServletResponse, + ) { + val type = ProblemType.ACCESS_DENIED + val problem = ProblemDetail.forStatusAndDetail(type.status, type.title) + problem.title = type.title + problem.type = type.type + problem.instance = URI.create(request.requestURI) + problem.setProperty("code", type.code) + response.status = type.status.value() + response.contentType = MediaType.APPLICATION_PROBLEM_JSON_VALUE + objectMapper.writeValue(response.writer, problem) + } + + private companion object { + val log = LoggerFactory.getLogger(AuditingAccessDeniedHandler::class.java) + } +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/config/SecurityConfig.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/config/SecurityConfig.kt index 73ead5b..391ba4a 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/config/SecurityConfig.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/config/SecurityConfig.kt @@ -14,7 +14,10 @@ import org.springframework.security.web.SecurityFilterChain @EnableWebSecurity class SecurityConfig { @Bean - fun filterChain(http: HttpSecurity): SecurityFilterChain = + fun filterChain( + http: HttpSecurity, + accessDeniedHandler: AuditingAccessDeniedHandler, + ): SecurityFilterChain = http .csrf { it.disable() } .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } @@ -24,7 +27,8 @@ class SecurityConfig { .permitAll() .anyRequest() .authenticated() - }.oauth2ResourceServer { it.jwt {} } + }.exceptionHandling { it.accessDeniedHandler(accessDeniedHandler) } + .oauth2ResourceServer { it.jwt {} } .build() private companion object { diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/exception/FailureAuditRecorder.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/exception/FailureAuditRecorder.kt new file mode 100644 index 0000000..5fc770c --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/exception/FailureAuditRecorder.kt @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.exception + +import com.fincore.ledger.api.error.AuditEndpointResolver +import com.fincore.ledger.api.error.ProblemType +import com.fincore.ledger.api.idempotency.IdempotencyAttributes +import com.fincore.ledger.api.security.CurrentActor +import com.fincore.ledger.application.AuditRecord +import com.fincore.ledger.application.AuditTrailWriter +import com.fincore.ledger.application.RequestHashing +import com.fincore.ledger.domain.enum.AuditResult +import jakarta.servlet.http.HttpServletRequest +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component + +@Component +class FailureAuditRecorder( + private val auditTrailWriter: AuditTrailWriter, + private val endpointResolver: AuditEndpointResolver, +) { + fun record( + request: HttpServletRequest, + problemType: ProblemType, + ) { + if (problemType !in FAILURE_PROBLEM_TYPES) return + val endpoint = endpointResolver.resolve(request.method, request.requestURI) ?: return + val actor = CurrentActor.resolveOrNull() ?: return + val record = + AuditRecord( + actorId = actor, + action = endpoint.action, + resourceType = endpoint.resourceType, + resourceId = endpoint.resourceId, + requestHash = requestHash(request), + payload = mapOf("code" to problemType.code), + ) + try { + auditTrailWriter.recordOutcome(record, AuditResult.FAILURE) + } catch ( + @Suppress("TooGenericExceptionCaught") ex: RuntimeException, + ) { + log.warn("audit FAILURE write failed for {} {}", request.method, request.requestURI, ex) + } + } + + private fun requestHash(request: HttpServletRequest): String? = + (request.getAttribute(IdempotencyAttributes.BODY) as? String)?.let { RequestHashing.sha256Hex(it) } + + private companion object { + val log = LoggerFactory.getLogger(FailureAuditRecorder::class.java) + val FAILURE_PROBLEM_TYPES = + setOf( + ProblemType.DOUBLE_ENTRY_VIOLATION, + ProblemType.CURRENCY_CONSISTENCY_VIOLATION, + ProblemType.TRANSACTION_ALREADY_REVERSED, + ProblemType.CONCURRENCY_CONFLICT, + ProblemType.DUPLICATE_TRANSACTION, + ProblemType.DOMAIN_RULE_VIOLATION, + ProblemType.TRANSACTION_NOT_FOUND, + ProblemType.IDEMPOTENCY_CONFLICT, + ) + } +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/exception/GlobalExceptionHandler.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/exception/GlobalExceptionHandler.kt index 6ba886c..6dcab1b 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/exception/GlobalExceptionHandler.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/exception/GlobalExceptionHandler.kt @@ -25,7 +25,9 @@ import org.springframework.web.bind.annotation.RestControllerAdvice import java.net.URI @RestControllerAdvice -class GlobalExceptionHandler { +class GlobalExceptionHandler( + private val failureAuditRecorder: FailureAuditRecorder, +) { @ExceptionHandler(AccountNotFoundException::class) fun handleAccountNotFound( ex: AccountNotFoundException, @@ -126,13 +128,15 @@ class GlobalExceptionHandler { type: ProblemType, detail: String?, request: HttpServletRequest, - ): ProblemDetail = - ProblemDetail.forStatusAndDetail(type.status, detail ?: type.title).apply { + ): ProblemDetail { + failureAuditRecorder.record(request, type) + return ProblemDetail.forStatusAndDetail(type.status, detail ?: type.title).apply { this.title = type.title this.type = type.type this.instance = URI.create(request.requestURI) setProperty("code", type.code) } + } private companion object { const val RETRY_AFTER_SECONDS = "1" diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/audit/AuditTrailWriterImpl.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/audit/AuditTrailWriterImpl.kt index d3f5ee7..0685d98 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/audit/AuditTrailWriterImpl.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/audit/AuditTrailWriterImpl.kt @@ -12,6 +12,8 @@ import com.fincore.ledger.infrastructure.persistence.AuditEventEntity import com.fincore.ledger.infrastructure.persistence.AuditEventRepository import org.slf4j.MDC import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.support.TransactionSynchronizationManager import java.time.Instant import java.util.UUID @@ -25,24 +27,34 @@ class AuditTrailWriterImpl( check(TransactionSynchronizationManager.isActualTransactionActive()) { "AuditTrailWriter.record must be called within an active transaction" } - auditRepository.saveAndFlush( - AuditEventEntity( - id = UUID.randomUUID(), - actorId = record.actorId, - correlationId = resolveCorrelationId(), - action = record.action.name, - resourceType = record.resourceType.name, - resourceId = record.resourceId, - result = AuditResult.SUCCESS, - requestHash = record.requestHash, - createdAt = Instant.now(), - payload = record.payload?.let { objectMapper.writeValueAsString(it) }, - ), - ) + auditRepository.saveAndFlush(toEntity(record, AuditResult.SUCCESS)) } - private fun resolveCorrelationId(): String { - val fromMdc = MDC.get(CorrelationIdAttributes.MDC_KEY) - return if (fromMdc.isNullOrBlank()) UUID.randomUUID().toString() else fromMdc + @Transactional(propagation = Propagation.REQUIRES_NEW) + override fun recordOutcome( + record: AuditRecord, + result: AuditResult, + ) { + auditRepository.saveAndFlush(toEntity(record, result)) } + + private fun toEntity( + record: AuditRecord, + result: AuditResult, + ): AuditEventEntity = + AuditEventEntity( + id = UUID.randomUUID(), + actorId = record.actorId, + correlationId = resolveCorrelationId(), + action = record.action.name, + resourceType = record.resourceType.name, + resourceId = record.resourceId, + result = result, + requestHash = record.requestHash, + createdAt = Instant.now(), + payload = record.payload?.let { objectMapper.writeValueAsString(it) }, + ) + + private fun resolveCorrelationId(): String = + MDC.get(CorrelationIdAttributes.MDC_KEY)?.takeIf { it.isNotBlank() } ?: UUID.randomUUID().toString() } diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/api/AccountControllerTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/api/AccountControllerTest.kt index db6d49f..04ffea6 100644 --- a/services/ledger/src/test/kotlin/com/fincore/ledger/api/AccountControllerTest.kt +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/api/AccountControllerTest.kt @@ -8,6 +8,7 @@ import com.fincore.core.Currency import com.fincore.core.EntryId import com.fincore.core.Money import com.fincore.core.TransactionId +import com.fincore.ledger.api.error.AuditEndpointResolver import com.fincore.ledger.api.idempotency.IdempotencyAttributes import com.fincore.ledger.api.idempotency.IdempotencyFilter import com.fincore.ledger.api.mapper.LedgerApiMapper @@ -16,16 +17,19 @@ import com.fincore.ledger.application.AccountEntry import com.fincore.ledger.application.AccountEntryPage import com.fincore.ledger.application.AccountPage import com.fincore.ledger.application.AccountService +import com.fincore.ledger.application.AuditTrailWriter import com.fincore.ledger.application.BalanceService import com.fincore.ledger.application.CreateAccountCommand import com.fincore.ledger.application.EntryQueryService import com.fincore.ledger.application.IdempotentResult +import com.fincore.ledger.config.AuditingAccessDeniedHandler import com.fincore.ledger.config.SecurityConfig import com.fincore.ledger.domain.Account import com.fincore.ledger.domain.enum.AccountType import com.fincore.ledger.domain.enum.EntryDirection import com.fincore.ledger.domain.exception.AccountNotFoundException import com.fincore.ledger.domain.exception.IdempotencyConflictException +import com.fincore.ledger.exception.FailureAuditRecorder import io.kotest.matchers.shouldBe import io.mockk.clearMocks import io.mockk.every @@ -56,7 +60,15 @@ import java.math.BigDecimal import java.time.Instant @WebMvcTest(AccountController::class) -@Import(SecurityConfig::class, LedgerApiMapper::class, IdempotencyFilter::class, AccountControllerTest.Mocks::class) +@Import( + SecurityConfig::class, + LedgerApiMapper::class, + IdempotencyFilter::class, + AuditEndpointResolver::class, + FailureAuditRecorder::class, + AuditingAccessDeniedHandler::class, + AccountControllerTest.Mocks::class, +) class AccountControllerTest( @Autowired private val mockMvc: MockMvc, @Autowired private val accountService: AccountService, @@ -76,6 +88,8 @@ class AccountControllerTest( @Bean fun idempotencyService(): FakeIdempotencyService = FakeIdempotencyService() + @Bean fun auditTrailWriter(): AuditTrailWriter = mockk(relaxed = true) + @Bean fun jwtDecoder(): JwtDecoder = mockk() } diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/api/TransactionControllerTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/api/TransactionControllerTest.kt index 4000fa3..ceddf9c 100644 --- a/services/ledger/src/test/kotlin/com/fincore/ledger/api/TransactionControllerTest.kt +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/api/TransactionControllerTest.kt @@ -5,9 +5,11 @@ package com.fincore.ledger.api import com.fincore.core.AccountId import com.fincore.core.TransactionId +import com.fincore.ledger.api.error.AuditEndpointResolver import com.fincore.ledger.api.idempotency.IdempotencyAttributes import com.fincore.ledger.api.idempotency.IdempotencyFilter import com.fincore.ledger.api.mapper.LedgerApiMapper +import com.fincore.ledger.application.AuditTrailWriter import com.fincore.ledger.application.EntryView import com.fincore.ledger.application.PostTransactionCommand import com.fincore.ledger.application.PostedTransaction @@ -15,6 +17,7 @@ import com.fincore.ledger.application.TransactionDetail import com.fincore.ledger.application.TransactionPage import com.fincore.ledger.application.TransactionService import com.fincore.ledger.application.TransactionSummary +import com.fincore.ledger.config.AuditingAccessDeniedHandler import com.fincore.ledger.config.SecurityConfig import com.fincore.ledger.domain.enum.EntryDirection import com.fincore.ledger.domain.enum.TransactionStatus @@ -25,6 +28,7 @@ import com.fincore.ledger.domain.exception.DoubleEntryViolationException import com.fincore.ledger.domain.exception.DuplicateTransactionException import com.fincore.ledger.domain.exception.TransactionAlreadyReversedException import com.fincore.ledger.domain.exception.TransactionNotFoundException +import com.fincore.ledger.exception.FailureAuditRecorder import io.kotest.matchers.shouldBe import io.mockk.clearMocks import io.mockk.every @@ -52,7 +56,15 @@ import java.math.BigDecimal import java.time.Instant @WebMvcTest(TransactionController::class) -@Import(SecurityConfig::class, LedgerApiMapper::class, IdempotencyFilter::class, TransactionControllerTest.Mocks::class) +@Import( + SecurityConfig::class, + LedgerApiMapper::class, + IdempotencyFilter::class, + AuditEndpointResolver::class, + FailureAuditRecorder::class, + AuditingAccessDeniedHandler::class, + TransactionControllerTest.Mocks::class, +) class TransactionControllerTest( @Autowired private val mockMvc: MockMvc, @Autowired private val transactionService: TransactionService, @@ -68,6 +80,8 @@ class TransactionControllerTest( @Bean fun idempotencyService(): FakeIdempotencyService = FakeIdempotencyService() + @Bean fun auditTrailWriter(): AuditTrailWriter = mockk(relaxed = true) + @Bean fun jwtDecoder(): JwtDecoder = mockk() } diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/api/error/AuditEndpointResolverTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/api/error/AuditEndpointResolverTest.kt new file mode 100644 index 0000000..ed954fa --- /dev/null +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/api/error/AuditEndpointResolverTest.kt @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.api.error + +import com.fincore.ledger.domain.enum.AuditAction +import com.fincore.ledger.domain.enum.AuditResourceType +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test + +class AuditEndpointResolverTest { + private val resolver = AuditEndpointResolver() + + @Test + fun `should map POST accounts to ACCOUNT_CREATE with sentinel resource id`() { + resolver.resolve("POST", "/v1/accounts") shouldBe + AuditedEndpoint(AuditAction.ACCOUNT_CREATE, AuditResourceType.ACCOUNT, "unknown") + } + + @Test + fun `should map POST transactions to TRANSACTION_POST with sentinel resource id`() { + resolver.resolve("POST", "/v1/transactions") shouldBe + AuditedEndpoint(AuditAction.TRANSACTION_POST, AuditResourceType.TRANSACTION, "unknown") + } + + @Test + fun `should map POST reverse to TRANSACTION_REVERSE with the path transaction id`() { + resolver.resolve("POST", "/v1/transactions/tx_0001/reverse") shouldBe + AuditedEndpoint(AuditAction.TRANSACTION_REVERSE, AuditResourceType.TRANSACTION, "tx_0001") + } + + @Test + fun `should return null for a GET transaction read`() { + resolver.resolve("GET", "/v1/transactions/tx_0001").shouldBeNull() + } + + @Test + fun `should return null for a GET account read`() { + resolver.resolve("GET", "/v1/accounts/acc_0001").shouldBeNull() + } + + @Test + fun `should return null for an unknown path`() { + resolver.resolve("POST", "/v1/other").shouldBeNull() + } +} diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/config/AuditingAccessDeniedHandlerTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/config/AuditingAccessDeniedHandlerTest.kt new file mode 100644 index 0000000..3e1d7d8 --- /dev/null +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/config/AuditingAccessDeniedHandlerTest.kt @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.config + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fincore.ledger.api.error.AuditEndpointResolver +import com.fincore.ledger.application.AuditRecord +import com.fincore.ledger.application.AuditTrailWriter +import com.fincore.ledger.domain.enum.AuditResult +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.mockk.Runs +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.authentication.TestingAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder + +class AuditingAccessDeniedHandlerTest { + private val auditTrailWriter = mockk() + private val objectMapper = jacksonObjectMapper() + private val handler = AuditingAccessDeniedHandler(auditTrailWriter, AuditEndpointResolver(), objectMapper) + + @BeforeEach + fun authenticate() { + val authentication = TestingAuthenticationToken(ACTOR, "credentials") + authentication.isAuthenticated = true + SecurityContextHolder.getContext().authentication = authentication + } + + @AfterEach + fun tearDown() { + SecurityContextHolder.clearContext() + clearAllMocks() + } + + @Test + fun `should record a DENIED outcome with null hash and write a 403 problem`() { + val recordSlot = slot() + val resultSlot = slot() + every { auditTrailWriter.recordOutcome(capture(recordSlot), capture(resultSlot)) } just Runs + val response = MockHttpServletResponse() + + handler.handle(MockHttpServletRequest("POST", "/v1/accounts"), response, AccessDeniedException("denied")) + + resultSlot.captured shouldBe AuditResult.DENIED + recordSlot.captured.actorId shouldBe ACTOR + recordSlot.captured.requestHash shouldBe null + recordSlot.captured.payload shouldBe mapOf("code" to "ACCESS_DENIED") + response.status shouldBe 403 + response.contentType shouldBe "application/problem+json" + response.contentAsString shouldContain "ACCESS_DENIED" + } + + @Test + fun `should still write the 403 when the audit write fails`() { + every { auditTrailWriter.recordOutcome(any(), any()) } throws RuntimeException("db down") + val response = MockHttpServletResponse() + + shouldNotThrowAny { + handler.handle(MockHttpServletRequest("POST", "/v1/accounts"), response, AccessDeniedException("denied")) + } + + response.status shouldBe 403 + } + + @Test + fun `should write a 403 without an audit row for a non-write endpoint`() { + val response = MockHttpServletResponse() + + handler.handle(MockHttpServletRequest("GET", "/v1/accounts/acc_1"), response, AccessDeniedException("denied")) + + response.status shouldBe 403 + verify(exactly = 0) { auditTrailWriter.recordOutcome(any(), any()) } + } + + private companion object { + const val ACTOR = "auth0|test-actor" + } +} diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/exception/FailureAuditRecorderTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/exception/FailureAuditRecorderTest.kt new file mode 100644 index 0000000..859c107 --- /dev/null +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/exception/FailureAuditRecorderTest.kt @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.exception + +import com.fincore.ledger.api.error.AuditEndpointResolver +import com.fincore.ledger.api.error.ProblemType +import com.fincore.ledger.api.idempotency.IdempotencyAttributes +import com.fincore.ledger.application.AuditRecord +import com.fincore.ledger.application.AuditTrailWriter +import com.fincore.ledger.application.RequestHashing +import com.fincore.ledger.domain.enum.AuditAction +import com.fincore.ledger.domain.enum.AuditResourceType +import com.fincore.ledger.domain.enum.AuditResult +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.matchers.shouldBe +import io.mockk.Runs +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.security.authentication.TestingAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder + +class FailureAuditRecorderTest { + private val auditTrailWriter = mockk() + private val recorder = FailureAuditRecorder(auditTrailWriter, AuditEndpointResolver()) + + @BeforeEach + fun authenticate() { + val authentication = TestingAuthenticationToken(ACTOR, "credentials") + authentication.isAuthenticated = true + SecurityContextHolder.getContext().authentication = authentication + } + + @AfterEach + fun tearDown() { + SecurityContextHolder.clearContext() + clearAllMocks() + } + + @Test + fun `should record a FAILURE outcome with request hash and code payload for a rejected write`() { + val recordSlot = slot() + val resultSlot = slot() + every { auditTrailWriter.recordOutcome(capture(recordSlot), capture(resultSlot)) } just Runs + val body = """{"reference":"ref-1","currency":"USD"}""" + val request = MockHttpServletRequest("POST", "/v1/transactions") + request.setAttribute(IdempotencyAttributes.BODY, body) + + recorder.record(request, ProblemType.DOUBLE_ENTRY_VIOLATION) + + resultSlot.captured shouldBe AuditResult.FAILURE + val record = recordSlot.captured + record.actorId shouldBe ACTOR + record.action shouldBe AuditAction.TRANSACTION_POST + record.resourceType shouldBe AuditResourceType.TRANSACTION + record.resourceId shouldBe "unknown" + record.requestHash shouldBe RequestHashing.sha256Hex(body) + record.payload shouldBe mapOf("code" to ProblemType.DOUBLE_ENTRY_VIOLATION.code) + } + + @Test + fun `should record the original transaction id for a rejected reverse`() { + val recordSlot = slot() + every { auditTrailWriter.recordOutcome(capture(recordSlot), any()) } just Runs + + recorder.record(MockHttpServletRequest("POST", "/v1/transactions/tx_77/reverse"), ProblemType.TRANSACTION_ALREADY_REVERSED) + + recordSlot.captured.action shouldBe AuditAction.TRANSACTION_REVERSE + recordSlot.captured.resourceId shouldBe "tx_77" + } + + @Test + fun `should leave request hash null when no body attribute is present`() { + val recordSlot = slot() + every { auditTrailWriter.recordOutcome(capture(recordSlot), any()) } just Runs + + recorder.record(MockHttpServletRequest("POST", "/v1/transactions/tx_9/reverse"), ProblemType.TRANSACTION_NOT_FOUND) + + recordSlot.captured.requestHash shouldBe null + } + + @Test + fun `should not record when the problem type is not failure-eligible on a write url`() { + recorder.record(MockHttpServletRequest("POST", "/v1/transactions"), ProblemType.VALIDATION_FAILED) + + verify(exactly = 0) { auditTrailWriter.recordOutcome(any(), any()) } + } + + @Test + fun `should not record for a non-write endpoint`() { + recorder.record(MockHttpServletRequest("GET", "/v1/transactions/tx_1"), ProblemType.TRANSACTION_NOT_FOUND) + + verify(exactly = 0) { auditTrailWriter.recordOutcome(any(), any()) } + } + + @Test + fun `should not record when there is no authenticated actor`() { + SecurityContextHolder.clearContext() + + recorder.record(MockHttpServletRequest("POST", "/v1/transactions"), ProblemType.DOUBLE_ENTRY_VIOLATION) + + verify(exactly = 0) { auditTrailWriter.recordOutcome(any(), any()) } + } + + @Test + fun `should not propagate when the audit write fails`() { + every { auditTrailWriter.recordOutcome(any(), any()) } throws RuntimeException("db down") + + shouldNotThrowAny { + recorder.record(MockHttpServletRequest("POST", "/v1/transactions"), ProblemType.DOUBLE_ENTRY_VIOLATION) + } + } + + private companion object { + const val ACTOR = "auth0|test-actor" + } +} diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/exception/GlobalExceptionHandlerTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/exception/GlobalExceptionHandlerTest.kt index 2dc6035..69c2f65 100644 --- a/services/ledger/src/test/kotlin/com/fincore/ledger/exception/GlobalExceptionHandlerTest.kt +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/exception/GlobalExceptionHandlerTest.kt @@ -4,11 +4,14 @@ package com.fincore.ledger.exception import com.fincore.core.AccountId +import com.fincore.ledger.api.error.ProblemType import com.fincore.ledger.domain.exception.AccountNotFoundException import com.fincore.ledger.domain.exception.ConcurrencyConflictException import com.fincore.ledger.domain.exception.DoubleEntryViolationException import com.fincore.ledger.domain.exception.IdempotencyConflictException import io.kotest.matchers.shouldBe +import io.mockk.mockk +import io.mockk.verify import org.junit.jupiter.api.Test import org.springframework.mock.web.MockHttpServletRequest import org.springframework.validation.BeanPropertyBindingResult @@ -17,7 +20,8 @@ import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.method.HandlerMethod class GlobalExceptionHandlerTest { - private val handler = GlobalExceptionHandler() + private val failureAuditRecorder = mockk(relaxed = true) + private val handler = GlobalExceptionHandler(failureAuditRecorder) private val request = MockHttpServletRequest("GET", "/v1/test") @Test @@ -51,6 +55,12 @@ class GlobalExceptionHandlerTest { response.body?.properties?.get("code") shouldBe "CONCURRENCY_CONFLICT" } + @Test + fun `should route the problem type to the failure audit recorder`() { + handler.handleDoubleEntry(DoubleEntryViolationException("net=5"), request) + verify { failureAuditRecorder.record(request, ProblemType.DOUBLE_ENTRY_VIOLATION) } + } + @Test fun `should derive per-field codes from the failing constraint`() { val problem = handler.handleValidation(validationException(fieldError("currency", "NotBlank")), request) diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/infrastructure/audit/AuditTrailWriterImplTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/infrastructure/audit/AuditTrailWriterImplTest.kt index 837308b..efc8100 100644 --- a/services/ledger/src/test/kotlin/com/fincore/ledger/infrastructure/audit/AuditTrailWriterImplTest.kt +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/infrastructure/audit/AuditTrailWriterImplTest.kt @@ -227,6 +227,52 @@ class AuditTrailWriterImplTest { slot.captured.requestHash.shouldBeNull() } + @Test + fun `should save entity with the given result and no active-transaction check when recordOutcome is called`() { + val slot = slot() + every { auditRepository.saveAndFlush(capture(slot)) } answers { firstArg() } + + writer.recordOutcome( + AuditRecord( + actorId = "auth0|actor", + action = AuditAction.TRANSACTION_POST, + resourceType = AuditResourceType.TRANSACTION, + resourceId = "unknown", + requestHash = "c".repeat(64), + payload = mapOf("code" to "ENTRIES_SUM_NOT_ZERO"), + ), + AuditResult.FAILURE, + ) + + val saved = slot.captured + saved.result shouldBe AuditResult.FAILURE + saved.resourceId shouldBe "unknown" + saved.requestHash shouldBe "c".repeat(64) + val tree = objectMapper.readTree(saved.payload.shouldNotBeNull()) + tree.get("code").asText() shouldBe "ENTRIES_SUM_NOT_ZERO" + } + + @Test + fun `should save a DENIED entity when recordOutcome is called with DENIED`() { + val slot = slot() + every { auditRepository.saveAndFlush(capture(slot)) } answers { firstArg() } + + writer.recordOutcome( + AuditRecord( + actorId = "auth0|actor", + action = AuditAction.ACCOUNT_CREATE, + resourceType = AuditResourceType.ACCOUNT, + resourceId = "unknown", + requestHash = null, + payload = mapOf("code" to "ACCESS_DENIED"), + ), + AuditResult.DENIED, + ) + + slot.captured.result shouldBe AuditResult.DENIED + slot.captured.requestHash.shouldBeNull() + } + @Test fun `should store a 64-char requestHash when provided`() { mockkStatic(TransactionSynchronizationManager::class)