Skip to content

Commit

Permalink
Merge pull request #14 from CamilYed/develop
Browse files Browse the repository at this point in the history
Add integration tests for account creation and currency exchange: validate UUID security
  • Loading branch information
CamilYed authored Oct 11, 2024
2 parents 4443481 + 611111a commit 8cc64dc
Show file tree
Hide file tree
Showing 25 changed files with 369 additions and 90 deletions.
19 changes: 2 additions & 17 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,6 @@ charset = utf-8
end_of_line = lf
insert_final_newline = true

[*.kt]
max_line_length = 120

[*.{kt,kts}]
import_order = camelcase, spaces, semicolons

[*.kt]
ij_kotlin_packages_to_use_import_on_demand = emptyList()

# Disable specific rules for all Kotlin test classes
[**/test/**/*.kt]
disabled_rules=no-blank-line-before-class-body

[**/integrationTest/**/*.kt]
disabled_rules=no-blank-line-before-class-body

[*.kt]
disabled_rules=empty-class-body
max_line_length = 120
ij_kotlin_packages_to_use_import_on_demand = emptyList()
73 changes: 54 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,22 @@ The project follows **Domain-Driven Design (DDD)** principles, which structure t
Transaction management is handled using a lambda function that wraps database operations in a transactional context.

```kotlin
fun <T> executeInTransaction(block: () -> T): T {
return inTransaction(block as () -> Any) as T
fun <T> inTransaction(block: () -> T): T {
return executeInTransaction(block as () -> Any) as T
}

private var inTransaction: (() -> Any) -> Any = { block -> block() }
private var executeInTransaction: (() -> Any) -> Any = { block ->
block()
}

@Configuration
class TransactionManagerConfig {

@PostConstruct
fun setupProductionTransaction() {
inTransaction = { block -> transaction { block() } }
executeInTransaction = { block ->
transaction { block() }
}
}
}
```
Expand All @@ -55,13 +59,13 @@ class TransactionManagerConfig {

```kotlin
fun create(command: CreateAccountCommand): AccountSnapshot {
val accountId = accountOperationRepository.findAccountIdBy(command.commandId)
val accountId = accountOperationRepository.findAccountIdBy(command.operationId)
if (accountId != null) {
return executeInTransaction { findAccount(accountId).toSnapshot() }
return inTransaction { findAccount(accountId).toSnapshot() }
}
val id = repository.nextAccountId()
val account = Account.createNewAccount(command.toCreateAccountData(id))
executeInTransaction {
inTransaction {
repository.save(account)
val events = account.getEvents()
accountOperationRepository.save(events)
Expand All @@ -75,8 +79,14 @@ This setup is used in the `AccountService` class, for example, during account cr
### Example of the `Account` Aggregate (Currency Exchange):

```kotlin
package camilyed.github.io.currencyexchangeapi.domain

import camilyed.github.io.common.Money
import java.math.BigDecimal
import java.util.UUID

class Account private constructor(
private val id: UUID,
private val id: AccountId,
private val owner: String,
private var balancePln: Money = Money(BigDecimal.ZERO, "PLN"),
private var balanceUsd: Money = Money(BigDecimal.ZERO, "USD"),
Expand All @@ -91,7 +101,7 @@ class Account private constructor(
fun exchangePlnToUsd(
amountPln: Money,
exchangeRate: ExchangeRate,
operationId: UUID,
operationId: OperationId,
) {
require(!amountPln.isZero()) {
throw InvalidAmountException("Amount must be greater than 0")
Expand All @@ -108,7 +118,7 @@ class Account private constructor(

addEvent(
AccountEvent.PlnToUsdExchangeEvent(
accountId = id,
accountId = id.value,
operationId = operationId,
amountPln = amountPln.amount,
amountUsd = amountUsd.amount,
Expand All @@ -117,25 +127,40 @@ class Account private constructor(
)
}

private fun addEvent(event: AccountEvent) {
events.add(event)
fun exchangeUsdToPln(amountUsd: Money, exchangeRate: ExchangeRate) {
require(!amountUsd.isZero()) {
throw InvalidAmountException("Amount must be greater than 0")
}
require(amountUsd <= balanceUsd) {
throw InsufficientFundsException(
"Insufficient USD balance",
)
}

val amountPln = Money(exchangeRate.convertToPln(amountUsd.amount), "PLN")
balanceUsd -= amountUsd
balancePln += amountPln
}

fun getEvents(): List<AccountEvent> = events.toList()

fun toSnapshot(): AccountSnapshot {
return AccountSnapshot(
id = id,
id = id.value,
owner = owner,
balancePln = balancePln.amount,
balanceUsd = balanceUsd.amount,
)
}

private fun addEvent(event: AccountEvent) {
events.add(event)
}

companion object {
fun createNewAccount(data: CreateAccountData): Account {
val account = Account(
id = data.id,
id = AccountId(data.id),
owner = data.owner,
balancePln = Money.pln(data.initialBalancePln),
balanceUsd = Money.usd(BigDecimal.ZERO),
Expand All @@ -153,7 +178,7 @@ class Account private constructor(

fun fromSnapshot(snapshot: AccountSnapshot): Account {
return Account(
id = snapshot.id,
id = AccountId(snapshot.id),
owner = snapshot.owner,
balancePln = Money.pln(snapshot.balancePln),
balanceUsd = Money.usd(snapshot.balanceUsd),
Expand All @@ -177,23 +202,24 @@ Events such as `PlnToUsdExchangeEvent` are stored in the history of the account
```kotlin
sealed class AccountEvent(
open val accountId: UUID,
open val operationId: UUID,
open val operationId: OperationId,
) {
data class AccountCreatedEvent(
override val accountId: UUID,
override val operationId: UUID,
override val operationId: OperationId,
val owner: String,
val initialBalancePln: BigDecimal,
) : AccountEvent(accountId, operationId)

data class PlnToUsdExchangeEvent(
override val accountId: UUID,
override val operationId: UUID,
override val operationId: OperationId,
val amountPln: BigDecimal,
val amountUsd: BigDecimal,
val exchangeRate: BigDecimal,
) : AccountEvent(accountId, operationId)
}

```

These events allow us to reconstruct the account's state and trace every operation performed.
Expand All @@ -202,6 +228,15 @@ These events allow us to reconstruct the account's state and trace every operati

All key operations (like account creation and currency exchange) are idempotent, meaning that repeated requests with the same `X-Request-Id` will yield the same result without duplicating the action.

### Secure UUID Validation for `OperationId`

In both account creation and currency exchange operations, the application enforces secure UUID validation for the `X-Request-Id` header. This ensures that all operations are identified with cryptographically secure UUIDs, preventing the use of weak or predictable identifiers.

- **Why secure UUIDs?**: Ensuring that `X-Request-Id` is generated securely helps protect against potential misuse of UUIDs that could be guessed or reused inappropriately.
- **Validation**: UUIDs passed in the `X-Request-Id` header are validated using entropy checks and other criteria to verify their randomness. If the UUID is deemed insecure, the operation is rejected with a `400 Bad Request` status, ensuring the integrity of the system.

This mechanism adds an additional layer of security, making sure that every operation (such as account creation and currency exchanges) is uniquely and safely identified.

## Tests

The project includes integration tests that use a DSL to improve readability and maintainability, inspired by [Allegro's blog post on readable tests](https://blog.allegro.tech/2022/02/readable-tests-by-example.html).
Expand Down Expand Up @@ -252,7 +287,7 @@ In this example, the test is written in a readable DSL style, ensuring clarity a
./gradlew runDev
```

4. The application will be accessible at `http://localhost:8080`.
4. The application will be accessible at `http://localhost:8090`.

## Running Tests

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,5 +114,22 @@ class AccountCreationIntegrationTest :
.hasProblemDetail("X-Request-Id", "X-Request-Id is required and must be a valid UUID")
}

@Test
fun `should get error if X-Request-Id is insecure`() {
// given
val insecureUUID = "00000000-0000-0000-0000-000000000000"

// when
val response = createAccount(aCreateAccount().withXRequestId(insecureUUID))

// then
expectThat(response)
.isBadRequest()
.hasProblemDetail(
"X-Request-Id",
"Insecure operation: UUID $insecureUUID does not meet security requirements.",
)
}

// TODO add test for optimistick locking ?
}
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,24 @@ class ExchangePlnToUsdIntegrationTest :
.hasPlnAmount("600.00")
.hasUsdAmount("100.00")
}

@Test
fun `should return error if X-Request-Id is insecure`() {
// given
val insecureUUID = "00000000-0000-0000-0000-000000000000"

// when
val response = exchangePlnToUsd(
anExchangePlnToUsd()
.withXRequestId(insecureUUID),
)

// then
expectThat(response)
.isBadRequest()
.hasProblemDetail(
"X-Request-Id",
"Insecure operation: UUID $insecureUUID does not meet security requirements.",
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package camilyed.github.io.currencyexchangeapi.adapters.postgres

import camilyed.github.io.currencyexchangeapi.domain.AccountEvent
import camilyed.github.io.currencyexchangeapi.domain.AccountOperationRepository
import camilyed.github.io.currencyexchangeapi.domain.OperationId
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.transactions.transaction
import org.springframework.stereotype.Component
Expand All @@ -14,11 +15,11 @@ class PostgresAccountOperationRepository(
private val clock: Clock,
) : AccountOperationRepository {

override fun findAccountIdBy(operationId: UUID): UUID? {
override fun findAccountIdBy(operationId: OperationId): UUID? {
return transaction {
AccountOperationsTable
.select(AccountOperationsTable.accountId)
.where { AccountOperationsTable.operationId eq operationId }
.where { AccountOperationsTable.operationId eq operationId.value }
.map { it[AccountOperationsTable.accountId] }
.singleOrNull()
}
Expand All @@ -33,7 +34,7 @@ class PostgresAccountOperationRepository(
it[id] = UUID.randomUUID()
it[accountId] = event.accountId
it[operationType] = "CREATE_ACCOUNT"
it[operationId] = event.operationId
it[operationId] = event.operationId.value
it[createdAt] = timestamp
it[amountPln] = event.initialBalancePln
it[amountUsd] = null
Expand All @@ -47,7 +48,7 @@ class PostgresAccountOperationRepository(
it[id] = UUID.randomUUID()
it[accountId] = event.accountId
it[operationType] = "PLN_TO_USD"
it[operationId] = event.operationId
it[operationId] = event.operationId.value
it[createdAt] = timestamp
it[amountPln] = event.amountPln
it[amountUsd] = event.amountUsd
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import camilyed.github.io.currencyexchangeapi.application.AccountService
import camilyed.github.io.currencyexchangeapi.application.CreateAccountCommand
import camilyed.github.io.currencyexchangeapi.application.ExchangePlnToUsdCommand
import camilyed.github.io.currencyexchangeapi.domain.AccountSnapshot
import camilyed.github.io.currencyexchangeapi.domain.OperationId
import camilyed.github.io.currencyexchangeapi.infrastructure.InvalidHeaderException
import jakarta.validation.Valid
import jakarta.validation.constraints.NotBlank
Expand All @@ -26,12 +27,12 @@ class AccountEndpoint(private val accountService: AccountService) {
@PostMapping
fun createAccount(
@Valid @RequestBody request: CreateAccountJson,
@RequestHeader("X-Request-Id") requestId: String?,
@RequestHeader("X-Request-Id") requestId: XRequestId?,
): ResponseEntity<AccountCreatedJson> {
if (requestId == null) {
throw InvalidHeaderException("X-Request-Id is required and must be a valid UUID")
}
val account = accountService.create(request.toCommand(requestId.toUUID()))
val account = accountService.create(request.toCommand(requestId.toOperationId()))
return ResponseEntity.status(HttpStatus.CREATED).body(account.toAccountCreatedJson())
}

Expand All @@ -43,7 +44,7 @@ class AccountEndpoint(private val accountService: AccountService) {
if (requestId == null) {
throw InvalidHeaderException("X-Request-Id is required and must be a valid UUID")
}
val command = request.toCommand(requestId.toUUID())
val command = request.toCommand(requestId.toOperationId())
val account = accountService.exchangePlnToUsd(command)
return ResponseEntity.ok(account.toAccountSnapshotJson())
}
Expand Down Expand Up @@ -76,27 +77,19 @@ class AccountEndpoint(private val accountService: AccountService) {
val id: String,
)

private fun CreateAccountJson.toCommand(commandId: UUID) =
private fun CreateAccountJson.toCommand(operationId: OperationId) =
CreateAccountCommand(
owner = this.owner!!,
initialBalance = BigDecimal(this.initialBalance),
commandId,
operationId,
)

private fun ExchangePlnToUsdJson.toCommand(operationId: UUID) = ExchangePlnToUsdCommand(
private fun ExchangePlnToUsdJson.toCommand(operationId: OperationId) = ExchangePlnToUsdCommand(
accountId = UUID.fromString(this.accountId),
amount = BigDecimal(this.amount),
commandId = operationId,
operationId = operationId,
)

private fun String.toUUID(): UUID {
try {
return UUID.fromString(this)
} catch (_: IllegalArgumentException) {
throw InvalidHeaderException("X-Request-Id is required and must be a valid UUID")
}
}

private fun AccountSnapshot.toAccountCreatedJson() = AccountCreatedJson(this.id.toString())

private fun AccountSnapshot.toAccountSnapshotJson() = AccountSnapshotJson(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package camilyed.github.io.currencyexchangeapi.api

import camilyed.github.io.currencyexchangeapi.domain.OperationId
import camilyed.github.io.currencyexchangeapi.infrastructure.InvalidHeaderException
import java.util.UUID

typealias XRequestId = String?

fun XRequestId.toOperationId() =
try {
OperationId(UUID.fromString(this))
} catch (_: IllegalArgumentException) {
throw InvalidHeaderException("X-Request-Id is required and must be a valid UUID")
}
Loading

0 comments on commit 8cc64dc

Please sign in to comment.