aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAntoine A <>2023-10-19 13:18:59 +0000
committerAntoine A <>2023-10-19 13:18:59 +0000
commit79c1992f05cc6bcc7ab49f479c9a46c684c36a09 (patch)
tree36ef6f0e63a71cf714a76367a7a456eebbdef74b
parent525a1b1e2ea9e17c6cf8c1fd927c66eb871b2e47 (diff)
downloadlibeufin-79c1992f05cc6bcc7ab49f479c9a46c684c36a09.tar.gz
libeufin-79c1992f05cc6bcc7ab49f479c9a46c684c36a09.tar.bz2
libeufin-79c1992f05cc6bcc7ab49f479c9a46c684c36a09.zip
Improve test
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt247
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/Main.kt4
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt12
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/helpers.kt7
-rw-r--r--bank/src/test/kotlin/BankIntegrationApiTest.kt78
-rw-r--r--bank/src/test/kotlin/CoreBankApiTest.kt459
-rw-r--r--bank/src/test/kotlin/DatabaseTest.kt26
-rw-r--r--bank/src/test/kotlin/helpers.kt36
8 files changed, 432 insertions, 437 deletions
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt
index 59a713e0..a610c7cc 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt
@@ -18,30 +18,7 @@ import kotlin.random.Random
private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.accountsMgmtHandlers")
-/**
- * This function collects all the /accounts handlers that
- * create, update, delete, show bank accounts. No histories
- * and wire transfers should belong here.
- */
-fun Routing.accountsMgmtApi(db: Database, ctx: BankApplicationContext) {
- // TOKEN ENDPOINTS
- delete("/accounts/{USERNAME}/token") {
- call.authCheck(db, TokenScope.readonly)
- val token = call.getAuthToken() ?: throw badRequest("Basic auth not supported here.")
-
- /**
- * Not sanity-checking the token, as it was used by the authentication already.
- * If harder errors happen, then they'll get Ktor respond with 500.
- */
- db.bearerTokenDelete(Base32Crockford.decode(token))
- /**
- * Responding 204 regardless of it being actually deleted or not.
- * If it wasn't found, then it must have been deleted before we
- * reached here, but the token was valid as it served the authentication
- * => no reason to fail the request.
- */
- call.respond(HttpStatusCode.NoContent)
- }
+fun Routing.coreBankTokenApi(db: Database) {
post("/accounts/{USERNAME}/token") {
val (login, _) = call.authCheck(db, TokenScope.refreshable)
val maybeAuthToken = call.getAuthToken()
@@ -97,111 +74,27 @@ fun Routing.accountsMgmtApi(db: Database, ctx: BankApplicationContext) {
)
)
)
- return@post
}
- // WITHDRAWAL ENDPOINTS
- post("/accounts/{USERNAME}/withdrawals") {
- val (login, _) = call.authCheck(db, TokenScope.readwrite)
- val req = call.receive<BankAccountCreateWithdrawalRequest>() // Checking that the user has enough funds.
-
- if (req.amount.currency != ctx.currency)
- throw badRequest("Wrong currency: ${req.amount.currency}")
+ delete("/accounts/{USERNAME}/token") {
+ call.authCheck(db, TokenScope.readonly)
+ val token = call.getAuthToken() ?: throw badRequest("Basic auth not supported here.")
- val opId = UUID.randomUUID()
- when (db.talerWithdrawalCreate(login, opId, req.amount)) {
- WithdrawalCreationResult.ACCOUNT_NOT_FOUND -> throw notFound(
- "Customer $login not found",
- TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT
- )
- WithdrawalCreationResult.ACCOUNT_IS_EXCHANGE -> throw conflict(
- "Exchange account cannot perform withdrawal operation",
- TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT
- )
- WithdrawalCreationResult.BALANCE_INSUFFICIENT -> throw conflict(
- "Insufficient funds to withdraw with Taler",
- TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT
- )
- WithdrawalCreationResult.SUCCESS -> {
- val bankBaseUrl = call.request.getBaseUrl() ?: throw internalServerError("Bank could not find its own base URL")
- call.respond(
- BankAccountCreateWithdrawalResponse(
- withdrawal_id = opId.toString(), taler_withdraw_uri = getTalerWithdrawUri(bankBaseUrl, opId.toString())
- )
- )
- }
- }
- }
- get("/withdrawals/{withdrawal_id}") {
- val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id"))
- call.respond(
- BankAccountGetWithdrawalResponse(
- amount = op.amount,
- aborted = op.aborted,
- confirmation_done = op.confirmationDone,
- selection_done = op.selectionDone,
- selected_exchange_account = op.selectedExchangePayto,
- selected_reserve_pub = op.reservePub
- )
- )
- }
- post("/withdrawals/{withdrawal_id}/abort") {
- val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) // Idempotency:
- if (op.aborted) {
- call.respondText("{}", ContentType.Application.Json)
- return@post
- } // Op is found, it'll now fail only if previously confirmed (DB checks).
- if (!db.talerWithdrawalAbort(op.withdrawalUuid)) throw conflict(
- hint = "Cannot abort confirmed withdrawal", talerEc = TalerErrorCode.TALER_EC_END
- )
- call.respondText("{}", ContentType.Application.Json)
- }
- post("/withdrawals/{withdrawal_id}/confirm") {
- val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) // Checking idempotency:
- if (op.confirmationDone) {
- call.respondText("{}", ContentType.Application.Json)
- return@post
- }
- if (op.aborted) throw conflict(
- hint = "Cannot confirm an aborted withdrawal", talerEc = TalerErrorCode.TALER_EC_BANK_CONFIRM_ABORT_CONFLICT
- ) // Checking that reserve GOT indeed selected.
- if (!op.selectionDone) throw LibeufinBankException(
- httpStatus = HttpStatusCode.UnprocessableEntity, talerError = TalerError(
- hint = "Cannot confirm an unselected withdrawal", code = TalerErrorCode.TALER_EC_END.code
- )
- ) // Confirmation conditions are all met, now put the operation
- // to the selected state _and_ wire the funds to the exchange.
- // Note: 'when' helps not to omit more result codes, should more
- // be added.
- when (db.talerWithdrawalConfirm(op.withdrawalUuid, Instant.now())) {
- WithdrawalConfirmationResult.BALANCE_INSUFFICIENT ->
- throw conflict(
- "Insufficient funds",
- TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT
- )
- WithdrawalConfirmationResult.OP_NOT_FOUND ->
- /**
- * Despite previous checks, the database _still_ did not
- * find the withdrawal operation, that's on the bank.
- */
- throw internalServerError("Withdrawal operation (${op.withdrawalUuid}) not found")
- WithdrawalConfirmationResult.EXCHANGE_NOT_FOUND ->
- /**
- * That can happen because the bank did not check the exchange
- * exists when POST /withdrawals happened, or because the exchange
- * bank account got removed before this confirmation.
- */
- throw conflict(
- hint = "Exchange to withdraw from not found",
- talerEc = TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT
- )
- WithdrawalConfirmationResult.CONFLICT -> throw internalServerError("Bank didn't check for idempotency")
- WithdrawalConfirmationResult.SUCCESS -> call.respondText(
- "{}", ContentType.Application.Json
- )
- }
+ /**
+ * Not sanity-checking the token, as it was used by the authentication already.
+ * If harder errors happen, then they'll get Ktor respond with 500.
+ */
+ db.bearerTokenDelete(Base32Crockford.decode(token))
+ /**
+ * Responding 204 regardless of it being actually deleted or not.
+ * If it wasn't found, then it must have been deleted before we
+ * reached here, but the token was valid as it served the authentication
+ * => no reason to fail the request.
+ */
+ call.respond(HttpStatusCode.NoContent)
}
}
+
fun Routing.coreBankAccountsMgmtApi(db: Database, ctx: BankApplicationContext) {
post("/accounts") {
// check if only admin is allowed to create new accounts
@@ -477,10 +370,7 @@ fun Routing.coreBankTransactionsApi(db: Database, ctx: BankApplicationContext) {
val subject = tx.payto_uri.message ?: throw badRequest("Wire transfer lacks subject")
val amount = tx.payto_uri.amount ?: tx.amount ?: throw badRequest("Wire transfer lacks amount")
- if (amount.currency != ctx.currency) throw badRequest(
- "Wrong currency: ${amount.currency}",
- talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH
- )
+ checkInternalCurrency(ctx, amount)
val result = db.bankTransaction(
creditAccountPayto = tx.payto_uri,
debitAccountUsername = login,
@@ -508,4 +398,105 @@ fun Routing.coreBankTransactionsApi(db: Database, ctx: BankApplicationContext) {
BankTransactionResult.SUCCESS -> call.respond(HttpStatusCode.OK)
}
}
+}
+
+fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankApplicationContext) {
+ post("/accounts/{USERNAME}/withdrawals") {
+ val (login, _) = call.authCheck(db, TokenScope.readwrite)
+ val req = call.receive<BankAccountCreateWithdrawalRequest>() // Checking that the user has enough funds.
+
+ checkInternalCurrency(ctx, req.amount)
+
+ val opId = UUID.randomUUID()
+ when (db.talerWithdrawalCreate(login, opId, req.amount)) {
+ WithdrawalCreationResult.ACCOUNT_NOT_FOUND -> throw notFound(
+ "Customer $login not found",
+ TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT
+ )
+ WithdrawalCreationResult.ACCOUNT_IS_EXCHANGE -> throw conflict(
+ "Exchange account cannot perform withdrawal operation",
+ TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT
+ )
+ WithdrawalCreationResult.BALANCE_INSUFFICIENT -> throw conflict(
+ "Insufficient funds to withdraw with Taler",
+ TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT
+ )
+ WithdrawalCreationResult.SUCCESS -> {
+ val bankBaseUrl = call.request.getBaseUrl() ?: throw internalServerError("Bank could not find its own base URL")
+ call.respond(
+ BankAccountCreateWithdrawalResponse(
+ withdrawal_id = opId.toString(), taler_withdraw_uri = getTalerWithdrawUri(bankBaseUrl, opId.toString())
+ )
+ )
+ }
+ }
+ }
+ get("/withdrawals/{withdrawal_id}") {
+ val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id"))
+ call.respond(
+ BankAccountGetWithdrawalResponse(
+ amount = op.amount,
+ aborted = op.aborted,
+ confirmation_done = op.confirmationDone,
+ selection_done = op.selectionDone,
+ selected_exchange_account = op.selectedExchangePayto,
+ selected_reserve_pub = op.reservePub
+ )
+ )
+ }
+ post("/withdrawals/{withdrawal_id}/abort") {
+ val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) // Idempotency:
+ if (op.aborted) {
+ call.respondText("{}", ContentType.Application.Json)
+ return@post
+ } // Op is found, it'll now fail only if previously confirmed (DB checks).
+ if (!db.talerWithdrawalAbort(op.withdrawalUuid)) throw conflict(
+ hint = "Cannot abort confirmed withdrawal", talerEc = TalerErrorCode.TALER_EC_END
+ )
+ call.respondText("{}", ContentType.Application.Json)
+ }
+ post("/withdrawals/{withdrawal_id}/confirm") {
+ val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) // Checking idempotency:
+ if (op.confirmationDone) {
+ call.respondText("{}", ContentType.Application.Json)
+ return@post
+ }
+ if (op.aborted) throw conflict(
+ hint = "Cannot confirm an aborted withdrawal", talerEc = TalerErrorCode.TALER_EC_BANK_CONFIRM_ABORT_CONFLICT
+ ) // Checking that reserve GOT indeed selected.
+ if (!op.selectionDone) throw LibeufinBankException(
+ httpStatus = HttpStatusCode.UnprocessableEntity, talerError = TalerError(
+ hint = "Cannot confirm an unselected withdrawal", code = TalerErrorCode.TALER_EC_END.code
+ )
+ ) // Confirmation conditions are all met, now put the operation
+ // to the selected state _and_ wire the funds to the exchange.
+ // Note: 'when' helps not to omit more result codes, should more
+ // be added.
+ when (db.talerWithdrawalConfirm(op.withdrawalUuid, Instant.now())) {
+ WithdrawalConfirmationResult.BALANCE_INSUFFICIENT -> throw conflict(
+ "Insufficient funds",
+ TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT
+ )
+ WithdrawalConfirmationResult.OP_NOT_FOUND ->
+ /**
+ * Despite previous checks, the database _still_ did not
+ * find the withdrawal operation, that's on the bank.
+ */
+ throw internalServerError("Withdrawal operation (${op.withdrawalUuid}) not found")
+ WithdrawalConfirmationResult.EXCHANGE_NOT_FOUND ->
+ /**
+ * That can happen because the bank did not check the exchange
+ * exists when POST /withdrawals happened, or because the exchange
+ * bank account got removed before this confirmation.
+ */
+ throw conflict(
+ hint = "Exchange to withdraw from not found",
+ talerEc = TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT
+ )
+ WithdrawalConfirmationResult.CONFLICT -> throw internalServerError("Bank didn't check for idempotency")
+ WithdrawalConfirmationResult.SUCCESS -> call.respondText(
+ "{}", ContentType.Application.Json
+ )
+ }
+ }
} \ No newline at end of file
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
index 159d27fd..0afad9a5 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
@@ -259,10 +259,10 @@ fun Application.corebankWebApp(db: Database, ctx: BankApplicationContext) {
call.respond(Config(ctx.currencySpecification))
return@get
}
- this.accountsMgmtApi(db, ctx)
+ this.coreBankTokenApi(db)
this.coreBankAccountsMgmtApi(db, ctx)
this.coreBankTransactionsApi(db, ctx)
- this.accountsMgmtApi(db, ctx)
+ this.coreBankWithdrawalApi(db, ctx)
this.bankIntegrationApi(db, ctx)
this.wireGatewayApi(db, ctx)
}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt
index e1cfd897..dcf1f785 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt
@@ -45,11 +45,7 @@ fun Routing.wireGatewayApi(db: Database, ctx: BankApplicationContext) {
post("/accounts/{USERNAME}/taler-wire-gateway/transfer") {
val (login, _) = call.authCheck(db, TokenScope.readwrite)
val req = call.receive<TransferRequest>()
- if (req.amount.currency != ctx.currency)
- throw badRequest(
- "Currency mismatch",
- TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH
- )
+ checkInternalCurrency(ctx, req.amount)
val dbRes = db.talerTransferCreate(
req = req,
username = login,
@@ -128,11 +124,7 @@ fun Routing.wireGatewayApi(db: Database, ctx: BankApplicationContext) {
post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming") {
val (login, _) = call.authCheck(db, TokenScope.readwrite) // TODO authAdmin ?
val req = call.receive<AddIncomingRequest>()
- if (req.amount.currency != ctx.currency)
- throw badRequest(
- "Currency mismatch",
- TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH
- )
+ checkInternalCurrency(ctx, req.amount)
val timestamp = Instant.now()
val dbRes = db.talerAddIncomingCreate(
req = req,
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt
index 8ab6ba28..d8123ed2 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt
@@ -114,6 +114,13 @@ fun badRequest(
)
)
+fun checkInternalCurrency(ctx: BankApplicationContext, amount: TalerAmount) {
+ if (amount.currency != ctx.currency) throw badRequest(
+ "Wrong currency: expected internal currency ${ctx.currency} got ${amount.currency}",
+ talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH
+ )
+}
+
// Generates a new Payto-URI with IBAN scheme.
fun genIbanPaytoUri(): String = "payto://iban/SANDBOXX/${getIban()}"
diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt b/bank/src/test/kotlin/BankIntegrationApiTest.kt
index b5dab990..79c0b7bf 100644
--- a/bank/src/test/kotlin/BankIntegrationApiTest.kt
+++ b/bank/src/test/kotlin/BankIntegrationApiTest.kt
@@ -34,7 +34,7 @@ class BankIntegrationApiTest {
}.assertOk().run {
val resp = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(bodyAsText())
val uuid = resp.taler_withdraw_uri.split("/").last()
- client.get("/taler-integration/withdrawal-operation/${uuid}")
+ client.get("/taler-integration/withdrawal-operation/$uuid")
.assertOk()
}
@@ -74,15 +74,15 @@ class BankIntegrationApiTest {
val uuid = resp.taler_withdraw_uri.split("/").last()
// Check OK
- client.post("/taler-integration/withdrawal-operation/${uuid}") {
+ client.post("/taler-integration/withdrawal-operation/$uuid") {
jsonBody(req)
}.assertOk()
// Check idempotence
- client.post("/taler-integration/withdrawal-operation/${uuid}") {
+ client.post("/taler-integration/withdrawal-operation/$uuid") {
jsonBody(req)
}.assertOk()
// Check already selected
- client.post("/taler-integration/withdrawal-operation/${uuid}") {
+ client.post("/taler-integration/withdrawal-operation/$uuid") {
jsonBody(json(req) {
"reserve_pub" to randEddsaPublicKey()
})
@@ -97,18 +97,18 @@ class BankIntegrationApiTest {
val uuid = resp.taler_withdraw_uri.split("/").last()
// Check reserve_pub_reuse
- client.post("/taler-integration/withdrawal-operation/${uuid}") {
+ client.post("/taler-integration/withdrawal-operation/$uuid") {
jsonBody(req)
}.assertConflict().assertErr(TalerErrorCode.TALER_EC_BANK_DUPLICATE_RESERVE_PUB_SUBJECT)
// Check unknown account
- client.post("/taler-integration/withdrawal-operation/${uuid}") {
+ client.post("/taler-integration/withdrawal-operation/$uuid") {
jsonBody(json {
"reserve_pub" to randEddsaPublicKey()
"selected_exchange" to IbanPayTo("payto://iban/UNKNOWN-IBAN-XYZ")
})
}.assertConflict().assertErr(TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT)
// Check account not exchange
- client.post("/taler-integration/withdrawal-operation/${uuid}") {
+ client.post("/taler-integration/withdrawal-operation/$uuid") {
jsonBody(json {
"reserve_pub" to randEddsaPublicKey()
"selected_exchange" to IbanPayTo("payto://iban/MERCHANT-IBAN-XYZ")
@@ -117,70 +117,6 @@ class BankIntegrationApiTest {
}
}
- // Testing withdrawal abort
- @Test
- fun withdrawalAbort() = bankSetup { db ->
- val uuid = UUID.randomUUID()
- // insert new.
- assertEquals(WithdrawalCreationResult.SUCCESS, db.talerWithdrawalCreate(
- opUUID = uuid,
- walletAccountUsername = "merchant",
- amount = TalerAmount(1, 0, "KUDOS")
- ))
- val op = db.talerWithdrawalGet(uuid)
- assert(op?.aborted == false)
- assertEquals(WithdrawalSelectionResult.SUCCESS,
- db.talerWithdrawalSetDetails(uuid, IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ"), randEddsaPublicKey()).first
- )
-
- client.post("/withdrawals/${uuid}/abort") {
- basicAuth("merchant", "merchant-password")
- }.assertOk()
-
- val opAbo = db.talerWithdrawalGet(uuid)
- assert(opAbo?.aborted == true && opAbo.selectionDone == true)
- }
-
- // Testing withdrawal creation
- @Test
- fun withdrawalCreation() = bankSetup { _ ->
- // Creating the withdrawal as if the SPA did it.
- val r = client.post("/accounts/merchant/withdrawals") {
- basicAuth("merchant", "merchant-password")
- jsonBody(BankAccountCreateWithdrawalRequest(TalerAmount(value = 9, frac = 0, currency = "KUDOS")))
- }.assertOk()
- val opId = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(r.bodyAsText())
- // Getting the withdrawal from the bank. Throws (failing the test) if not found.
- client.get("/withdrawals/${opId.withdrawal_id}") {
- basicAuth("merchant", "merchant-password")
- }.assertOk()
- }
-
- // Testing withdrawal confirmation
- @Test
- fun withdrawalConfirmation() = bankSetup { db ->
- // Artificially making a withdrawal operation for merchant.
- val uuid = UUID.randomUUID()
- assertEquals(WithdrawalCreationResult.SUCCESS, db.talerWithdrawalCreate(
- opUUID = uuid,
- walletAccountUsername = "merchant",
- amount = TalerAmount(1, 0, "KUDOS")
- ))
- // Specifying the exchange via its Payto URI.
- assertEquals(WithdrawalSelectionResult.SUCCESS,
- db.talerWithdrawalSetDetails(
- opUuid = uuid,
- exchangePayto = IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ"),
- reservePub = randEddsaPublicKey()
- ).first
- )
-
- // Starting the bank and POSTing as Foo to /confirm the operation.
- client.post("/withdrawals/${uuid}/confirm") {
- basicAuth("merchant", "merchant-password")
- }.assertOk()
- }
-
// Testing the generation of taler://withdraw-URIs.
@Test
fun testWithdrawUri() {
diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt
index 811c3011..2498d8f6 100644
--- a/bank/src/test/kotlin/CoreBankApiTest.kt
+++ b/bank/src/test/kotlin/CoreBankApiTest.kt
@@ -18,10 +18,135 @@ import java.sql.DriverManager
import java.time.Duration
import java.time.Instant
import java.time.temporal.ChronoUnit
+import java.util.*
import kotlin.random.Random
import kotlin.test.*
import kotlinx.coroutines.*
+class CoreBankConfigTest {
+ @Test
+ fun getConfig() = bankSetup { _ ->
+ client.get("/config").assertOk()
+ }
+}
+
+class CoreBankTokenApiTest {
+ // POST /accounts/USERNAME/token
+ @Test
+ fun post() = bankSetup { db ->
+ // Wrong user
+ client.post("/accounts/merchant/token") {
+ basicAuth("exchange", "exchange-password")
+ }.assertUnauthorized()
+
+ // New default token
+ client.post("/accounts/merchant/token") {
+ basicAuth("merchant", "merchant-password")
+ jsonBody(json { "scope" to "readonly"})
+ }.assertOk().run {
+ // Checking that the token lifetime defaulted to 24 hours.
+ val resp = Json.decodeFromString<TokenSuccessResponse>(bodyAsText())
+ val token = db.bearerTokenGet(Base32Crockford.decode(resp.access_token))
+ val lifeTime = Duration.between(token!!.creationTime, token.expirationTime)
+ assertEquals(Duration.ofDays(1), lifeTime)
+ }
+
+ // Check default duration
+ client.post("/accounts/merchant/token") {
+ basicAuth("merchant", "merchant-password")
+ jsonBody(json { "scope" to "readonly" })
+ }.assertOk().run {
+ // Checking that the token lifetime defaulted to 24 hours.
+ val resp = Json.decodeFromString<TokenSuccessResponse>(bodyAsText())
+ val token = db.bearerTokenGet(Base32Crockford.decode(resp.access_token))
+ val lifeTime = Duration.between(token!!.creationTime, token.expirationTime)
+ assertEquals(Duration.ofDays(1), lifeTime)
+ }
+
+ // Check refresh
+ client.post("/accounts/merchant/token") {
+ basicAuth("merchant", "merchant-password")
+ jsonBody(json {
+ "scope" to "readonly"
+ "refreshable" to true
+ })
+ }.assertOk().run {
+ val token = Json.decodeFromString<TokenSuccessResponse>(bodyAsText()).access_token
+ client.post("/accounts/merchant/token") {
+ headers["Authorization"] = "Bearer secret-token:$token"
+ jsonBody(json { "scope" to "readonly" })
+ }.assertOk()
+ }
+
+ // Check'forever' case.
+ client.post("/accounts/merchant/token") {
+ basicAuth("merchant", "merchant-password")
+ jsonBody(json {
+ "scope" to "readonly"
+ "duration" to json {
+ "d_us" to "forever"
+ }
+ })
+ }.run {
+ val never: TokenSuccessResponse = Json.decodeFromString(bodyAsText())
+ assertEquals(Instant.MAX, never.expiration.t_s)
+ }
+
+ // Check too big or invalid durations
+ client.post("/accounts/merchant/token") {
+ basicAuth("merchant", "merchant-password")
+ jsonBody(json {
+ "scope" to "readonly"
+ "duration" to json {
+ "d_us" to "invalid"
+ }
+ })
+ }.assertBadRequest()
+ client.post("/accounts/merchant/token") {
+ basicAuth("merchant", "merchant-password")
+ jsonBody(json {
+ "scope" to "readonly"
+ "duration" to json {
+ "d_us" to Long.MAX_VALUE
+ }
+ })
+ }.assertBadRequest()
+ client.post("/accounts/merchant/token") {
+ basicAuth("merchant", "merchant-password")
+ jsonBody(json {
+ "scope" to "readonly"
+ "duration" to json {
+ "d_us" to -1
+ }
+ })
+ }.assertBadRequest()
+ }
+
+ // DELETE /accounts/USERNAME/token
+ @Test
+ fun delete() = bankSetup { _ ->
+ val token = client.post("/accounts/merchant/token") {
+ basicAuth("merchant", "merchant-password")
+ jsonBody(json { "scope" to "readonly" })
+ }.assertOk().run {
+ Json.decodeFromString<TokenSuccessResponse>(bodyAsText()).access_token
+ }
+ // Check OK
+ client.delete("/accounts/merchant/token") {
+ headers["Authorization"] = "Bearer secret-token:$token"
+ }.assertNoContent()
+ // Check token no longer work
+ client.delete("/accounts/merchant/token") {
+ headers["Authorization"] = "Bearer secret-token:$token"
+ }.assertUnauthorized()
+
+ // Checking merchant can still be served by basic auth, after token deletion.
+ client.get("/accounts/merchant") {
+ basicAuth("merchant", "merchant-password")
+ }.assertOk()
+ }
+}
+
class CoreBankAccountsMgmtApiTest {
// Testing the account creation and its idempotency
@Test
@@ -551,213 +676,159 @@ class CoreBankTransactionsApiTest {
}
}
-class LibeuFinApiTest {
- private val customerFoo = Customer(
- login = "foo",
- passwordHash = CryptoUtil.hashpw("pw"),
- name = "Foo",
- phone = "+00",
- email = "foo@b.ar",
- cashoutPayto = "payto://external-IBAN",
- cashoutCurrency = "KUDOS"
- )
- private val customerBar = Customer(
- login = "bar",
- passwordHash = CryptoUtil.hashpw("pw"),
- name = "Bar",
- phone = "+99",
- email = "bar@example.com",
- cashoutPayto = "payto://external-IBAN",
- cashoutCurrency = "KUDOS"
- )
-
- private fun genBankAccount(rowId: Long) = BankAccount(
- hasDebt = false,
- internalPaytoUri = IbanPayTo("payto://iban/ac${rowId}"),
- maxDebt = TalerAmount(100, 0, "KUDOS"),
- owningCustomerId = rowId
- )
-
+class CoreBankWithdrawalApiTest {
+ // POST /accounts/USERNAME/withdrawals
@Test
- fun getConfig() = bankSetup { _ ->
- val r = client.get("/config") {
- expectSuccess = true
+ fun create() = bankSetup { _ ->
+ client.post("/accounts/merchant/withdrawals") {
+ basicAuth("merchant", "merchant-password")
+ jsonBody(json { "amount" to "KUDOS:9.0" })
}.assertOk()
- println(r.bodyAsText())
}
-
+ // GET /withdrawals/withdrawal_id
@Test
- fun tokenDeletionTest() = setup { db, ctx ->
- assert(db.customerCreate(customerFoo) != null)
- val token = ByteArray(32)
- Random.nextBytes(token)
- assert(db.bearerTokenCreate(
- BearerToken(
- bankCustomer = 1L,
- content = token,
- creationTime = Instant.now(),
- expirationTime = Instant.now().plusSeconds(10),
- scope = TokenScope.readwrite
- )
- ))
- testApplication {
- application {
- corebankWebApp(db, ctx)
- }
- // Legitimate first attempt, should succeed
- client.delete("/accounts/foo/token") {
- expectSuccess = true
- headers["Authorization"] = "Bearer secret-token:${Base32Crockford.encode(token)}"
- }.apply {
- assert(this.status == HttpStatusCode.NoContent)
- }
- // Trying after deletion should hit 404.
- client.delete("/accounts/foo/token") {
- expectSuccess = false
- headers["Authorization"] = "Bearer secret-token:${Base32Crockford.encode(token)}"
- }.apply {
- assert(this.status == HttpStatusCode.Unauthorized)
- }
- // Checking foo can still be served by basic auth, after token deletion.
- assert(db.bankAccountCreate(
- BankAccount(
- hasDebt = false,
- internalPaytoUri = IbanPayTo("payto://iban/DE1234"),
- maxDebt = TalerAmount(100, 0, "KUDOS"),
- owningCustomerId = 1
- )
- ) != null)
- client.get("/accounts/foo") {
- expectSuccess = true
- basicAuth("foo", "pw")
- }
+ fun get() = bankSetup { _ ->
+ // Check OK
+ client.post("/accounts/merchant/withdrawals") {
+ basicAuth("merchant", "merchant-password")
+ jsonBody(json { "amount" to "KUDOS:9.0" } )
+ }.assertOk().run {
+ val opId = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(bodyAsText())
+ client.get("/withdrawals/${opId.withdrawal_id}") {
+ basicAuth("merchant", "merchant-password")
+ }.assertOk()
}
+
+ // Check bad UUID
+ client.get("/withdrawals/chocolate").assertBadRequest()
+
+ // Check unknown
+ client.get("/withdrawals/${UUID.randomUUID()}").assertNotFound()
}
- // Creating token with "forever" duration.
+ // POST /withdrawals/withdrawal_id/abort
@Test
- fun tokenForeverTest() = setup { db, ctx ->
- assert(db.customerCreate(customerFoo) != null)
- testApplication {
- application {
- corebankWebApp(db, ctx)
- }
- val newTok = client.post("/accounts/foo/token") {
- expectSuccess = true
- contentType(ContentType.Application.Json)
- basicAuth("foo", "pw")
- setBody(
- """
- {"duration": {"d_us": "forever"}, "scope": "readonly"}
- """.trimIndent()
- )
- }
- val newTokObj = Json.decodeFromString<TokenSuccessResponse>(newTok.bodyAsText())
- assert(newTokObj.expiration.t_s == Instant.MAX)
+ fun abort() = bankSetup { _ ->
+ // Check abort created
+ client.post("/accounts/merchant/withdrawals") {
+ basicAuth("merchant", "merchant-password")
+ jsonBody(json { "amount" to "KUDOS:1" })
+ }.assertOk().run {
+ val resp = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(bodyAsText())
+ val uuid = resp.taler_withdraw_uri.split("/").last()
+
+ // Check OK
+ client.post("/withdrawals/$uuid/abort").assertOk()
+ // Check idempotence
+ client.post("/withdrawals/$uuid/abort").assertOk()
}
- }
- // Testing that too big or invalid durations fail the request.
- @Test
- fun tokenInvalidDurationTest() = setup { db, ctx ->
- assert(db.customerCreate(customerFoo) != null)
- testApplication {
- application {
- corebankWebApp(db, ctx)
- }
- var r = client.post("/accounts/foo/token") {
- expectSuccess = false
- contentType(ContentType.Application.Json)
- basicAuth("foo", "pw")
- setBody("""{
- "duration": {"d_us": "invalid"},
- "scope": "readonly"}""".trimIndent())
- }
- assert(r.status == HttpStatusCode.BadRequest)
- r = client.post("/accounts/foo/token") {
- expectSuccess = false
- contentType(ContentType.Application.Json)
- basicAuth("foo", "pw")
- setBody("""{
- "duration": {"d_us": ${Long.MAX_VALUE}},
- "scope": "readonly"}""".trimIndent())
- }
- assert(r.status == HttpStatusCode.BadRequest)
- r = client.post("/accounts/foo/token") {
- expectSuccess = false
- contentType(ContentType.Application.Json)
- basicAuth("foo", "pw")
- setBody("""{
- "duration": {"d_us": -1},
- "scope": "readonly"}""".trimIndent())
- }
- assert(r.status == HttpStatusCode.BadRequest)
+ // Check abort selected
+ client.post("/accounts/merchant/withdrawals") {
+ basicAuth("merchant", "merchant-password")
+ jsonBody(json { "amount" to "KUDOS:1" })
+ }.assertOk().run {
+ val resp = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(bodyAsText())
+ val uuid = resp.taler_withdraw_uri.split("/").last()
+ client.post("/taler-integration/withdrawal-operation/$uuid") {
+ jsonBody(json {
+ "reserve_pub" to randEddsaPublicKey()
+ "selected_exchange" to IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ")
+ })
+ }.assertOk()
+
+ // Check OK
+ client.post("/withdrawals/$uuid/abort").assertOk()
+ // Check idempotence
+ client.post("/withdrawals/$uuid/abort").assertOk()
+ }
+
+ // Check abort confirmed
+ client.post("/accounts/merchant/withdrawals") {
+ basicAuth("merchant", "merchant-password")
+ jsonBody(json { "amount" to "KUDOS:1" })
+ }.assertOk().run {
+ val resp = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(bodyAsText())
+ val uuid = resp.taler_withdraw_uri.split("/").last()
+ client.post("/taler-integration/withdrawal-operation/$uuid") {
+ jsonBody(json {
+ "reserve_pub" to randEddsaPublicKey()
+ "selected_exchange" to IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ")
+ })
+ }.assertOk()
+ client.post("/withdrawals/$uuid/confirm").assertOk()
+
+ // Check error
+ client.post("/withdrawals/$uuid/abort").assertConflict()
}
+
+ // Check bad UUID
+ client.post("/withdrawals/chocolate/abort").assertBadRequest()
+
+ // Check unknown
+ client.post("/withdrawals/${UUID.randomUUID()}/abort").assertNotFound()
}
- // Checking the POST /token handling.
+
+ // POST /withdrawals/withdrawal_id/confirm
@Test
- fun tokenTest() = setup { db, ctx ->
- assert(db.customerCreate(customerFoo) != null)
- testApplication {
- application {
- corebankWebApp(db, ctx)
- }
- val newTok = client.post("/accounts/foo/token") {
- expectSuccess = true
- contentType(ContentType.Application.Json)
- basicAuth("foo", "pw")
- setBody(
- """
- {"scope": "readonly"}
- """.trimIndent()
- )
- }
- // Checking that the token lifetime defaulted to 24 hours.
- val newTokObj = Json.decodeFromString<TokenSuccessResponse>(newTok.bodyAsText())
- val newTokDb = db.bearerTokenGet(Base32Crockford.decode(newTokObj.access_token))
- val lifeTime = Duration.between(newTokDb!!.creationTime, newTokDb.expirationTime)
- assert(lifeTime == Duration.ofDays(1))
-
- // foo tries to create a token on behalf of bar, expect 403.
- val r = client.post("/accounts/bar/token") {
- expectSuccess = false
- basicAuth("foo", "pw")
- }
- assert(r.status == HttpStatusCode.Unauthorized)
- // Make ad-hoc token for foo.
- val fooTok = ByteArray(32).apply { Random.nextBytes(this) }
- assert(
- db.bearerTokenCreate(
- BearerToken(
- content = fooTok,
- bankCustomer = 1L, // only foo exists.
- scope = TokenScope.readonly,
- creationTime = Instant.now(),
- isRefreshable = true,
- expirationTime = Instant.now().plus(1, ChronoUnit.DAYS)
- )
- )
- )
- // Testing the secret-token:-scheme.
- client.post("/accounts/foo/token") {
- headers.set("Authorization", "Bearer secret-token:${Base32Crockford.encode(fooTok)}")
- contentType(ContentType.Application.Json)
- setBody("{\"scope\": \"readonly\"}")
- expectSuccess = true
- }
- // Testing the 'forever' case.
- val forever = client.post("/accounts/foo/token") {
- expectSuccess = true
- contentType(ContentType.Application.Json)
- basicAuth("foo", "pw")
- setBody("""{
- "scope": "readonly",
- "duration": {"d_us": "forever"}
- }""".trimIndent())
- }
- val never: TokenSuccessResponse = Json.decodeFromString(forever.bodyAsText())
- assert(never.expiration.t_s == Instant.MAX)
+ fun confirm() = bankSetup { db ->
+ // Check confirm created
+ client.post("/accounts/merchant/withdrawals") {
+ basicAuth("merchant", "merchant-password")
+ jsonBody(json { "amount" to "KUDOS:1" })
+ }.assertOk().run {
+ val resp = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(bodyAsText())
+ val uuid = resp.taler_withdraw_uri.split("/").last()
+
+ // Check err
+ client.post("/withdrawals/$uuid/confirm").assertStatus(HttpStatusCode.UnprocessableEntity)
+ }
+
+ // Check confirm selected
+ client.post("/accounts/merchant/withdrawals") {
+ basicAuth("merchant", "merchant-password")
+ jsonBody(json { "amount" to "KUDOS:1" })
+ }.assertOk().run {
+ val resp = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(bodyAsText())
+ val uuid = resp.taler_withdraw_uri.split("/").last()
+ client.post("/taler-integration/withdrawal-operation/$uuid") {
+ jsonBody(json {
+ "reserve_pub" to randEddsaPublicKey()
+ "selected_exchange" to IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ")
+ })
+ }.assertOk()
+
+ // Check OK
+ client.post("/withdrawals/$uuid/confirm").assertOk()
+ // Check idempotence
+ client.post("/withdrawals/$uuid/confirm").assertOk()
+ }
+
+ // Check confirm aborted
+ client.post("/accounts/merchant/withdrawals") {
+ basicAuth("merchant", "merchant-password")
+ jsonBody(json { "amount" to "KUDOS:1" })
+ }.assertOk().run {
+ val resp = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(bodyAsText())
+ val uuid = resp.taler_withdraw_uri.split("/").last()
+ client.post("/taler-integration/withdrawal-operation/$uuid") {
+ jsonBody(json {
+ "reserve_pub" to randEddsaPublicKey()
+ "selected_exchange" to IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ")
+ })
+ }.assertOk()
+ client.post("/withdrawals/$uuid/abort").assertOk()
+
+ // Check error
+ client.post("/withdrawals/$uuid/confirm").assertConflict()
+ .assertErr(TalerErrorCode.TALER_EC_BANK_CONFIRM_ABORT_CONFLICT)
}
+
+ // Check bad UUID
+ client.post("/withdrawals/chocolate/confirm").assertBadRequest()
+
+ // Check unknown
+ client.post("/withdrawals/${UUID.randomUUID()}/confirm").assertNotFound()
}
-}
+} \ No newline at end of file
diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt
index 549a489b..74ad25ff 100644
--- a/bank/src/test/kotlin/DatabaseTest.kt
+++ b/bank/src/test/kotlin/DatabaseTest.kt
@@ -108,7 +108,7 @@ class DatabaseTest {
* given by the exchange to pay one merchant.
*/
@Test
- fun talerTransferTest() = setupDb { db ->
+ fun talerTransferTest() = dbSetup { db ->
val exchangeReq = TransferRequest(
amount = TalerAmount(9, 0, "KUDOS"),
credit_account = IbanPayTo("payto://iban/BAR-IBAN-ABC"),
@@ -131,7 +131,7 @@ class DatabaseTest {
}
@Test
- fun bearerTokenTest() = setupDb { db ->
+ fun bearerTokenTest() = dbSetup { db ->
val tokenBytes = ByteArray(32)
Random().nextBytes(tokenBytes)
val token = BearerToken(
@@ -148,7 +148,7 @@ class DatabaseTest {
}
@Test
- fun tokenDeletionTest() = setupDb { db ->
+ fun tokenDeletionTest() = dbSetup { db ->
val token = ByteArray(32)
// Token not there, must fail.
assert(!db.bearerTokenDelete(token))
@@ -172,7 +172,7 @@ class DatabaseTest {
}
@Test
- fun bankTransactionsTest() = setupDb { db ->
+ fun bankTransactionsTest() = dbSetup { db ->
val fooId = db.customerCreate(customerFoo)
assert(fooId != null)
val barId = db.customerCreate(customerBar)
@@ -253,7 +253,7 @@ class DatabaseTest {
}
@Test
- fun customerCreationTest() = setupDb { db ->
+ fun customerCreationTest() = dbSetup { db ->
assert(db.customerGetFromLogin("foo") == null)
db.customerCreate(customerFoo)
assert(db.customerGetFromLogin("foo")?.name == "Foo")
@@ -262,7 +262,7 @@ class DatabaseTest {
}
@Test
- fun bankAccountTest() = setupDb { db ->
+ fun bankAccountTest() = dbSetup { db ->
val currency = "KUDOS"
assert(db.bankAccountGetFromOwnerId(1L) == null)
assert(db.customerCreate(customerFoo) != null)
@@ -272,7 +272,7 @@ class DatabaseTest {
}
@Test
- fun withdrawalTest() = setupDb { db ->
+ fun withdrawalTest() = dbSetup { db ->
val uuid = UUID.randomUUID()
val currency = "KUDOS"
assert(db.customerCreate(customerFoo) != null)
@@ -302,7 +302,7 @@ class DatabaseTest {
}
// Only testing the interaction between Kotlin and the DBMS. No actual logic tested.
@Test
- fun historyTest() = setupDb { db ->
+ fun historyTest() = dbSetup { db ->
val currency = "KUDOS"
db.customerCreate(customerFoo); db.bankAccountCreate(bankAccountFoo)
db.customerCreate(customerBar); db.bankAccountCreate(bankAccountBar)
@@ -330,7 +330,7 @@ class DatabaseTest {
assert(backward[0].row_id <= 50 && backward.size == 2 && backward[0].row_id > backward[1].row_id)
}
@Test
- fun cashoutTest() = setupDb { db ->
+ fun cashoutTest() = dbSetup { db ->
val currency = "KUDOS"
val op = Cashout(
cashoutUuid = UUID.randomUUID(),
@@ -385,7 +385,7 @@ class DatabaseTest {
// Tests the retrieval of many accounts, used along GET /accounts
@Test
- fun accountsForAdminTest() = setupDb { db ->
+ fun accountsForAdminTest() = dbSetup { db ->
assert(db.accountsGetForAdmin().isEmpty()) // No data exists yet.
assert(db.customerCreate(customerFoo) != null)
assert(db.bankAccountCreate(bankAccountFoo) != null)
@@ -397,7 +397,7 @@ class DatabaseTest {
}
@Test
- fun passwordChangeTest() = setupDb { db ->
+ fun passwordChangeTest() = dbSetup { db ->
// foo not found, this fails.
assert(!db.customerChangePassword("foo", "won't make it"))
// creating foo.
@@ -407,7 +407,7 @@ class DatabaseTest {
}
@Test
- fun getPublicAccountsTest() = setupDb { db ->
+ fun getPublicAccountsTest() = dbSetup { db ->
// Expecting empty, no accounts exist yet.
assert(db.accountsGetPublic("KUDOS").isEmpty())
// Make a NON-public account, so expecting still an empty result.
@@ -442,7 +442,7 @@ class DatabaseTest {
* PATCH /accounts/foo endpoint.
*/
@Test
- fun accountReconfigTest() = setupDb { db ->
+ fun accountReconfigTest() = dbSetup { db ->
// asserting for the customer not being found.
db.accountReconfig(
"foo",
diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt
index d756a4b6..503dfdea 100644
--- a/bank/src/test/kotlin/helpers.kt
+++ b/bank/src/test/kotlin/helpers.kt
@@ -49,6 +49,22 @@ val bankAccountExchange = BankAccount(
isTalerExchange = true
)
+fun setup(
+ conf: String = "test.conf",
+ lambda: suspend (Database, BankApplicationContext) -> Unit
+) {
+ val config = talerConfig("conf/$conf")
+ val dbCfg = config.loadDbConfig()
+ resetDatabaseTables(dbCfg, "libeufin-bank")
+ initializeDatabaseTables(dbCfg, "libeufin-bank")
+ val ctx = config.loadBankApplicationContext()
+ Database(dbCfg.dbConnStr, ctx.currency).use {
+ runBlocking {
+ lambda(it, ctx)
+ }
+ }
+}
+
fun bankSetup(
conf: String = "test.conf",
lambda: suspend ApplicationTestBuilder.(Database) -> Unit
@@ -77,23 +93,7 @@ fun bankSetup(
}
}
-fun setup(
- conf: String = "test.conf",
- lambda: suspend (Database, BankApplicationContext) -> Unit
-){
- val config = talerConfig("conf/$conf")
- val dbCfg = config.loadDbConfig()
- resetDatabaseTables(dbCfg, "libeufin-bank")
- initializeDatabaseTables(dbCfg, "libeufin-bank")
- val ctx = config.loadBankApplicationContext()
- Database(dbCfg.dbConnStr, ctx.currency).use {
- runBlocking {
- lambda(it, ctx)
- }
- }
-}
-
-fun setupDb(lambda: suspend (Database) -> Unit) {
+fun dbSetup(lambda: suspend (Database) -> Unit) {
setup() { db, _ -> lambda(db) }
}
@@ -119,8 +119,6 @@ suspend fun HttpResponse.assertErr(code: TalerErrorCode): HttpResponse {
return this
}
-
-
fun BankTransactionResult.assertSuccess() {
assertEquals(BankTransactionResult.SUCCESS, this)
}