Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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" }
}
}
}
Original file line number Diff line number Diff line change
@@ -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" }
}
}
}
Original file line number Diff line number Diff line change
@@ -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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -18,4 +19,9 @@ data class AuditRecord(

interface AuditTrailWriter {
fun record(record: AuditRecord)

fun recordOutcome(
record: AuditRecord,
result: AuditResult,
)
}
Loading
Loading