Skip to content

Commit

Permalink
Add integration tests for account creation and currency exchange: val…
Browse files Browse the repository at this point in the history
…idate UUID security
  • Loading branch information
kamil.jedrzejuk committed Oct 11, 2024
1 parent d76ff65 commit 14160dd
Show file tree
Hide file tree
Showing 16 changed files with 107 additions and 41 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,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 @@ -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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import camilyed.github.io.currencyexchangeapi.domain.AccountRepository
import camilyed.github.io.currencyexchangeapi.domain.AccountSnapshot
import camilyed.github.io.currencyexchangeapi.domain.CreateAccountData
import camilyed.github.io.currencyexchangeapi.domain.CurrentExchangeRateProvider
import camilyed.github.io.currencyexchangeapi.domain.OperationId
import camilyed.github.io.currencyexchangeapi.infrastructure.inTransaction
import java.util.UUID

Expand All @@ -19,7 +18,7 @@ class AccountService(
) {

fun create(command: CreateAccountCommand): AccountSnapshot {
val accountId = accountOperationRepository.findAccountIdBy(OperationId(command.commandId))
val accountId = accountOperationRepository.findAccountIdBy(command.operationId)
if (accountId != null) {
return inTransaction { findAccount(accountId).toSnapshot() }
}
Expand All @@ -34,7 +33,7 @@ class AccountService(
}

fun exchangePlnToUsd(command: ExchangePlnToUsdCommand): AccountSnapshot {
val accountId = accountOperationRepository.findAccountIdBy(OperationId(command.commandId))
val accountId = accountOperationRepository.findAccountIdBy(command.operationId)
if (accountId != null) {
return inTransaction { findAccount(accountId).toSnapshot() }
}
Expand All @@ -43,7 +42,7 @@ class AccountService(
account.exchangePlnToUsd(
amountPln = Money.pln(command.amount),
exchangeRate = currentExchange,
operationId = command.commandId,
operationId = command.operationId,
)
inTransaction {
repository.save(account)
Expand All @@ -66,6 +65,6 @@ class AccountService(
id = id,
owner = this.owner,
initialBalancePln = this.initialBalance,
operationId = this.commandId,
operationId = this.operationId.value,
)
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package camilyed.github.io.currencyexchangeapi.application

import camilyed.github.io.currencyexchangeapi.domain.OperationId
import java.math.BigDecimal
import java.util.UUID

data class CreateAccountCommand(
val owner: String,
val initialBalance: BigDecimal,
val commandId: UUID,
val operationId: OperationId,
)
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
package camilyed.github.io.currencyexchangeapi.application

import camilyed.github.io.currencyexchangeapi.domain.OperationId
import java.math.BigDecimal
import java.util.UUID

data class ExchangePlnToUsdCommand(
val accountId: UUID,
val amount: BigDecimal,
val commandId: UUID,
val operationId: OperationId,
)
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,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 @@ -38,7 +38,7 @@ class Account private constructor(
addEvent(
AccountEvent.PlnToUsdExchangeEvent(
accountId = id.value,
operationId = operationId,
operationId = operationId.value,
amountPln = amountPln.amount,
amountUsd = amountUsd.amount,
exchangeRate = exchangeRate.rate,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ class InsufficientFundsException(message: String) : RuntimeException(message)
class InvalidAmountException(message: String) : RuntimeException(message)

class InvalidExchangeRateException(message: String) : RuntimeException(message)

class InsecureOperationException(message: String) : RuntimeException(message)
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
package camilyed.github.io.currencyexchangeapi.domain

import camilyed.github.io.security.UUIDSecureValidator
import java.util.UUID

@JvmInline
value class OperationId(val value: UUID)
value class OperationId(val value: UUID) {

init {
require(UUIDSecureValidator.default().isSecure(value)) {
throw InsecureOperationException("Insecure operation: UUID $value does not meet security requirements.")
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package camilyed.github.io.currencyexchangeapi.infrastructure

import camilyed.github.io.currencyexchangeapi.domain.InsecureOperationException
import camilyed.github.io.currencyexchangeapi.domain.InsufficientFundsException
import camilyed.github.io.currencyexchangeapi.domain.InvalidAmountException
import jakarta.servlet.http.HttpServletRequest
Expand Down Expand Up @@ -68,6 +69,20 @@ class GlobalExceptionHandler {
)
return ResponseEntity(problemDetails, HttpStatus.UNPROCESSABLE_ENTITY)
}

@ExceptionHandler(InsecureOperationException::class)
fun handleInsecureOperation(
ex: InsecureOperationException,
request: HttpServletRequest,
): ResponseEntity<ProblemDetails> {
val problemDetails = ProblemDetails(
title = "Insecure Operation",
status = HttpStatus.BAD_REQUEST.value(),
detail = "X-Request-Id: ${ex.message}",
instance = request.requestURI,
)
return ResponseEntity(problemDetails, HttpStatus.BAD_REQUEST)
}
}

data class ProblemDetails(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,11 @@ private class ConfigurableUUIDValidator(private val entropyThreshold: Double) :
private fun isValidUUID(uuid: UUID, entropyThreshold: Double): Boolean {
val uuidString = uuid.toString().replace("-", "")

if (uuidString.length != UUID_LENGTH_WITHOUT_DASHES) {
return false
}

val firstChar = uuidString[0]
val allSameChars = uuidString.all { it == firstChar }
if (allSameChars) return false
val isInvalid = uuidString.length != UUID_LENGTH_WITHOUT_DASHES ||
uuidString.all { it == uuidString[0] } ||
calculateEntropy(uuidString) <= entropyThreshold

val entropy = calculateEntropy(uuidString)
return entropy > entropyThreshold
return !isInvalid
}

private fun calculateEntropy(input: String): Double {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package camilyed.github.io.currencyexchangeapi.testing.builders

import camilyed.github.io.currencyexchangeapi.application.CreateAccountCommand
import camilyed.github.io.currencyexchangeapi.domain.OperationId
import java.math.BigDecimal
import java.util.UUID

Expand All @@ -19,7 +20,7 @@ class CreateAccountCommandBuilder private constructor() {
return CreateAccountCommand(
owner = owner,
initialBalance = initialBalance,
UUID.randomUUID(),
OperationId(UUID.randomUUID()),
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package camilyed.github.io.currencyexchangeapi.testing.builders

import camilyed.github.io.currencyexchangeapi.application.ExchangePlnToUsdCommand
import camilyed.github.io.currencyexchangeapi.domain.OperationId
import java.math.BigDecimal
import java.util.UUID

Expand All @@ -16,7 +17,7 @@ class ExchangePlnToUsdCommandBuilder private constructor() {
return ExchangePlnToUsdCommand(
accountId = accountId,
amount = amount,
commandId = UUID.randomUUID(),
operationId = OperationId(UUID.randomUUID()),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ package camilyed.github.io.security

import org.junit.jupiter.api.Test
import strikt.api.expectThat
import strikt.assertions.isTrue
import strikt.assertions.isFalse
import strikt.assertions.isTrue
import java.util.UUID

class ConfigurableUUIDValidatorTest {
Expand All @@ -12,7 +12,7 @@ class ConfigurableUUIDValidatorTest {
fun `should return secure for valid UUID with custom entropy threshold`() {
// given
val validUUID = UUID.fromString("f47ac10b-58cc-4372-a567-0e02b2c3d479")
val validator = UUIDSecureValidator.configurable(2.5) // Niższy próg entropii
val validator = UUIDSecureValidator.configurable(2.5)

// when
val result = validator.isSecure(validUUID)
Expand All @@ -38,7 +38,7 @@ class ConfigurableUUIDValidatorTest {
fun `should return insecure for valid UUID with high entropy threshold`() {
// given
val validUUID = UUID.fromString("f47ac10b-58cc-4372-a567-0e02b2c3d479")
val validator = UUIDSecureValidator.configurable(5.0) // Wysoki próg entropii
val validator = UUIDSecureValidator.configurable(5.0)

// when
val result = validator.isSecure(validUUID)
Expand Down

0 comments on commit 14160dd

Please sign in to comment.