libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit 427e71c9586c8cbf4e71b7abf268a85c8e03bab2
parent 5f0c56d06e7cc1832eb019f046921b1acecf3752
Author: Antoine A <>
Date:   Mon, 20 Nov 2023 15:09:25 +0000

Bounce malformed incoming transactions and improve exchange transaction
logic

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt | 59+++++++++++++++++++++++++++++++++++++++++++++++------------
Mbank/src/test/kotlin/CoreBankApiTest.kt | 77+++++++++++++++++++++++++++++++++++++++++------------------------------------
Mbank/src/test/kotlin/DatabaseTest.kt | 8++++----
Mbank/src/test/kotlin/RevenueApiTest.kt | 49+++++++++++++++++++++++++++++++++++++++++--------
Mbank/src/test/kotlin/StatsTest.kt | 1-
Mbank/src/test/kotlin/WireGatewayApiTest.kt | 129++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mbank/src/test/kotlin/helpers.kt | 39+++++++++++++++++++++++++++++++++++----
Mutil/src/main/kotlin/DB.kt | 13+++++++++++++
8 files changed, 259 insertions(+), 116 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt @@ -43,6 +43,7 @@ class TransactionDAO(private val db: Database) { amount: TalerAmount, timestamp: Instant, ): BankTransactionResult = db.serializable { conn -> + val now = timestamp.toDbMicros() ?: throw faultyTimestampByBank(); conn.transaction { val stmt = conn.prepareStatement(""" SELECT @@ -64,7 +65,7 @@ class TransactionDAO(private val db: Database) { stmt.setString(3, subject) stmt.setLong(4, amount.value) stmt.setInt(5, amount.frac) - stmt.setLong(6, timestamp.toDbMicros() ?: throw faultyTimestampByBank()) + stmt.setLong(6, now) stmt.executeQuery().use { when { !it.next() -> throw internalServerError("Bank transaction didn't properly return") @@ -78,31 +79,65 @@ class TransactionDAO(private val db: Database) { val debitAccountId = it.getLong("out_debit_bank_account_id") val debitRowId = it.getLong("out_debit_row_id") val metadata = TxMetadata.parse(subject) - if (it.getBoolean("out_creditor_is_exchange")) { - if (metadata is IncomingTxMetadata) { - conn.prepareStatement("CALL register_incoming(?, ?)").run { + val exchangeCreditor = it.getBoolean("out_creditor_is_exchange") + val exchangeDebtor = it.getBoolean("out_debtor_is_exchange") + if (exchangeCreditor && exchangeDebtor) { + val kind = when (metadata) { + is IncomingTxMetadata -> "an incoming taler " + is OutgoingTxMetadata -> "an outgoing taler" + null -> "a common" + }; + logger.warn("exchange account $exchangeDebtor sent $kind transaction to exchange account $exchangeCreditor, this should never happens and is not bounced to prevent bouncing loop") + } else if (exchangeCreditor) { + val bounce = if (metadata is IncomingTxMetadata) { + val registered = conn.prepareStatement("CALL register_incoming(?, ?)").run { setBytes(1, metadata.reservePub.raw) setLong(2, creditRowId) - executeUpdate() // TODO check reserve pub reuse + executeProcedureViolation() } + if (!registered) { + logger.warn("exchange account $creditAccountId received an incoming taler transaction $creditRowId with an already used reserve public key") + + } + !registered } else { - // TODO bounce - logger.warn("exchange account $creditAccountId received a transaction $creditRowId with malformed metadata, will bounce in future version") + logger.warn("exchange account $creditAccountId received a transaction $creditRowId with malformed metadata") + true } - } - if (it.getBoolean("out_debtor_is_exchange")) { + if (bounce) { + // No error can happens because an opposite transaction already took place in the same transaction + conn.prepareStatement(""" + SELECT bank_wire_transfer( + ?, ?, ?, (?, ?)::taler_amount, ?, + NULL, NULL, NULL + ); + """ + ).run { + setLong(1, debitAccountId) + setLong(2, creditAccountId) + setString(3, "Bounce $creditRowId") // TODO better subject + setLong(4, amount.value) + setInt(5, amount.frac) + setLong(6, now) + executeQuery() + } + } + } else if (exchangeDebtor) { if (metadata is OutgoingTxMetadata) { - conn.prepareStatement("CALL register_outgoing(NULL, ?, ?, ?, ?, ?, ?)").run { + val registered = conn.prepareStatement("CALL register_outgoing(NULL, ?, ?, ?, ?, ?, ?)").run { setBytes(1, metadata.wtid.raw) setString(2, metadata.exchangeBaseUrl.url) setLong(3, debitAccountId) setLong(4, creditAccountId) setLong(5, debitRowId) setLong(6, creditRowId) - executeUpdate() // TODO check wtid reuse + executeProcedureViolation() + } + if (!registered) { + logger.warn("exchange account $debitAccountId sent an outgoing taler transaction $debitRowId with an already used withdraw ID, use the API to catch this error") } } else { - logger.warn("exchange account $debitAccountId sent a transaction $debitRowId with malformed metadata") + logger.warn("exchange account $debitAccountId sent a transaction $debitRowId with malformed metadata, use the API instead") } } BankTransactionResult.Success(debitRowId) diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -240,9 +240,9 @@ class CoreBankAccountsApiTest { "username" to "foo$it" } }.assertCreated() - assertBalance("foo$it", CreditDebitInfo.credit, "KUDOS:100") + assertBalance("foo$it", "+KUDOS:100") } - assertBalance("admin", CreditDebitInfo.debit, "KUDOS:10000") + assertBalance("admin", "-KUDOS:10000") // Check unsufficient fund client.post("/accounts") { @@ -308,11 +308,11 @@ class CoreBankAccountsApiTest { // fail to delete, due to a non-zero balance. - tx("exchange", "KUDOS:1", "merchant") + tx("customer", "KUDOS:1", "merchant") client.delete("/accounts/merchant") { pwAuth("admin") }.assertConflict(TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO) - tx("merchant", "KUDOS:1", "exchange") + tx("merchant", "KUDOS:1", "customer") client.delete("/accounts/merchant") { pwAuth("admin") }.assertNoContent() @@ -563,11 +563,11 @@ class CoreBankTransactionsApiTest { // Gen three transactions from merchant to exchange repeat(3) { - tx("merchant", "KUDOS:0.$it", "exchange") + tx("merchant", "KUDOS:0.$it", "customer") } // Gen two transactions from exchange to merchant repeat(2) { - tx("exchange", "KUDOS:0.$it", "merchant") + tx("customer", "KUDOS:0.$it", "merchant") } // Check no useless polling @@ -597,12 +597,12 @@ class CoreBankTransactionsApiTest { } } delay(100) - tx("merchant", "KUDOS:4.2", "exchange") + tx("merchant", "KUDOS:4.2", "customer") } // Testing ranges. repeat(30) { - tx("merchant", "KUDOS:0.001", "exchange") + tx("merchant", "KUDOS:0.001", "customer") } // forward range: @@ -713,19 +713,9 @@ class CoreBankTransactionsApiTest { } }.assertConflict(TalerErrorCode.BANK_SAME_ACCOUNT) - suspend fun checkBalance( - merchantDebt: Boolean, - merchantAmount: String, - customerDebt: Boolean, - customerAmount: String, - ) { - assertBalance("merchant", if (merchantDebt) CreditDebitInfo.debit else CreditDebitInfo.credit, merchantAmount) - assertBalance("customer", if (customerDebt) CreditDebitInfo.debit else CreditDebitInfo.credit, customerAmount) - } - // Init state - assertBalance("merchant", CreditDebitInfo.debit, "KUDOS:2.4") - assertBalance("customer", CreditDebitInfo.credit, "KUDOS:0") + assertBalance("merchant", "+KUDOS:0") + assertBalance("customer", "+KUDOS:0") // Send 2 times 3 repeat(2) { tx("merchant", "KUDOS:3", "customer") @@ -733,20 +723,39 @@ class CoreBankTransactionsApiTest { client.post("/accounts/merchant/transactions") { pwAuth("merchant") json { - "payto_uri" to "$customerPayto?message=payout2&amount=KUDOS:3" + "payto_uri" to "$customerPayto?message=payout2&amount=KUDOS:5" } }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) - assertBalance("merchant", CreditDebitInfo.debit, "KUDOS:8.4") - assertBalance("customer", CreditDebitInfo.credit, "KUDOS:6") + assertBalance("merchant", "-KUDOS:6") + assertBalance("customer", "+KUDOS:6") // Send throught debt - client.post("/accounts/customer/transactions") { - pwAuth("customer") - json { - "payto_uri" to "$merchantPayto?message=payout2&amount=KUDOS:10" - } - }.assertOk() - assertBalance("merchant", CreditDebitInfo.credit, "KUDOS:1.6") - assertBalance("customer", CreditDebitInfo.debit, "KUDOS:4") + tx("customer", "KUDOS:10", "merchant") + assertBalance("merchant", "+KUDOS:4") + assertBalance("customer", "-KUDOS:4") + tx("merchant", "KUDOS:4", "customer") + + // Check bounce + assertBalance("merchant", "+KUDOS:0") + assertBalance("exchange", "+KUDOS:0") + tx("merchant", "KUDOS:1", "exchange", "") // Bounce common to transaction + tx("merchant", "KUDOS:1", "exchange", "Malformed") // Bounce malformed transaction + val reserve_pub = randShortHashCode(); + tx("merchant", "KUDOS:1", "exchange", IncomingTxMetadata(reserve_pub).encode()) // Accept incoming + tx("merchant", "KUDOS:1", "exchange", IncomingTxMetadata(reserve_pub).encode()) // Bounce reserve_pub reuse + assertBalance("merchant", "-KUDOS:1") + assertBalance("exchange", "+KUDOS:1") + + // Check warn + assertBalance("merchant", "-KUDOS:1") + assertBalance("exchange", "+KUDOS:1") + tx("exchange", "KUDOS:1", "merchant", "") // Warn common to transaction + tx("exchange", "KUDOS:1", "merchant", "Malformed") // Warn malformed transaction + val wtid = randShortHashCode() + val exchange = ExchangeUrl("http://exchange.example.com/") + tx("exchange", "KUDOS:1", "merchant", OutgoingTxMetadata(wtid, exchange).encode()) // Accept outgoing + tx("exchange", "KUDOS:1", "merchant", OutgoingTxMetadata(wtid, exchange).encode()) // Warn wtid reuse + assertBalance("merchant", "+KUDOS:3") + assertBalance("exchange", "-KUDOS:3") } } @@ -894,7 +903,7 @@ class CoreBankWithdrawalApiTest { withdrawalSelect(uuid) // Send too much money - tx("merchant", "KUDOS:5", "exchange") + tx("merchant", "KUDOS:5", "customer") client.post("/withdrawals/$uuid/confirm") .assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) @@ -1273,8 +1282,6 @@ class CoreBankCashoutApiTest { fun history() = bankSetup { _ -> // TODO auth routine - fillCashoutInfo("customer") - suspend fun HttpResponse.assertHistory(size: Int) { assertHistoryIds<Cashouts>(size) { it.cashouts.map { it.cashout_id } @@ -1308,8 +1315,6 @@ class CoreBankCashoutApiTest { fun globalHistory() = bankSetup { _ -> // TODO admin auth routine - fillCashoutInfo("customer") - suspend fun HttpResponse.assertHistory(size: Int) { assertHistoryIds<GlobalCashouts>(size) { it.cashouts.map { it.cashout_id } diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt @@ -51,8 +51,8 @@ class DatabaseTest { @Test fun serialisation() = bankSetup { - assertBalance("customer", CreditDebitInfo.credit, "KUDOS:0") - assertBalance("merchant", CreditDebitInfo.credit, "KUDOS:0") + assertBalance("customer", "+KUDOS:0") + assertBalance("merchant", "+KUDOS:0") coroutineScope { repeat(10) { launch { @@ -60,8 +60,8 @@ class DatabaseTest { } } } - assertBalance("customer", CreditDebitInfo.debit, "KUDOS:4.5") - assertBalance("merchant", CreditDebitInfo.credit, "KUDOS:4.5") + assertBalance("customer", "-KUDOS:4.5") + assertBalance("merchant", "+KUDOS:4.5") coroutineScope { repeat(5) { launch { diff --git a/bank/src/test/kotlin/RevenueApiTest.kt b/bank/src/test/kotlin/RevenueApiTest.kt @@ -40,6 +40,25 @@ class RevenueApiTest { } } + suspend fun latestId(): Long { + return client.getA("/accounts/merchant/taler-revenue/history?delta=-1") + .assertOkJson<MerchantIncomingHistory>().incoming_transactions[0].row_id + } + + suspend fun testTrigger(trigger: suspend () -> Unit) { + coroutineScope { + val id = latestId() + launch { + assertTime(100, 200) { + client.getA("/accounts/merchant/taler-revenue/history?delta=7&start=$id&long_poll_ms=1000") + .assertHistory(1) + } + } + delay(100) + trigger() + } + } + // TODO auth routine // Check error when no transactions @@ -52,8 +71,8 @@ class RevenueApiTest { } // Should not show up in the revenue API history tx("exchange", "KUDOS:10", "merchant", "bogus") - // Merchant pays exchange once, but that should not appear in the result - tx("merchant", "KUDOS:10", "exchange", "ignored") + // Merchant pays customer once, but that should not appear in the result + addIncoming("KUDOS:10") // Gen two transactions using raw bank transaction logic repeat(2) { tx("exchange", "KUDOS:10", "merchant", OutgoingTxMetadata(randShortHashCode(), ExchangeUrl("http://exchange.example.com/")).encode()) @@ -69,7 +88,7 @@ class RevenueApiTest { // Check no useless polling assertTime(0, 100) { - client.getA("/accounts/merchant/taler-revenue/history?delta=-6&start=14&long_poll_ms=1000") + client.getA("/accounts/merchant/taler-revenue/history?delta=-6&long_poll_ms=1000") .assertHistory(5) } @@ -80,15 +99,16 @@ class RevenueApiTest { } coroutineScope { + val id = latestId() launch { // Check polling succeed forward assertTime(100, 200) { - client.getA("/accounts/merchant/taler-revenue/history?delta=2&start=13&long_poll_ms=1000") + client.getA("/accounts/merchant/taler-revenue/history?delta=2&start=$id&long_poll_ms=1000") .assertHistory(1) } } launch { // Check polling timeout forward assertTime(200, 300) { - client.getA("/accounts/merchant/taler-revenue/history?delta=1&start=16&long_poll_ms=200") + client.getA("/accounts/merchant/taler-revenue/history?delta=1&start=${id+3}&long_poll_ms=200") .assertNoContent() } } @@ -96,17 +116,30 @@ class RevenueApiTest { transfer("KUDOS:10") } + // Test trigger by raw transaction + testTrigger { + tx("exchange", "KUDOS:10", "merchant", OutgoingTxMetadata(randShortHashCode(), ExchangeUrl("http://exchange.example.com/")).encode()) + } + // Test trigger by outgoing + testTrigger { transfer("KUDOS:9") } + // Testing ranges. - repeat(20) { + repeat(5) { transfer("KUDOS:10") } + val id = latestId() + // forward range: - client.getA("/accounts/merchant/taler-revenue/history?delta=10&start=20") + client.getA("/accounts/merchant/taler-revenue/history?delta=10") + .assertHistory(10) + client.getA("/accounts/merchant/taler-revenue/history?delta=10&start=4") .assertHistory(10) // backward range: - client.getA("/accounts/merchant/taler-revenue/history?delta=-10&start=25") + client.getA("/accounts/merchant/taler-revenue/history?delta=-10") + .assertHistory(10) + client.getA("/accounts/merchant/taler-revenue/history?delta=-10&start=${id-4}") .assertHistory(10) } } \ No newline at end of file diff --git a/bank/src/test/kotlin/StatsTest.kt b/bank/src/test/kotlin/StatsTest.kt @@ -36,7 +36,6 @@ class StatsTest { setMaxDebt("merchant", TalerAmount("KUDOS:1000")) setMaxDebt("exchange", TalerAmount("KUDOS:1000")) setMaxDebt("customer", TalerAmount("KUDOS:1000")) - fillCashoutInfo("customer") suspend fun cashin(amount: String) { db.conn { conn -> diff --git a/bank/src/test/kotlin/WireGatewayApiTest.kt b/bank/src/test/kotlin/WireGatewayApiTest.kt @@ -176,6 +176,25 @@ class WireGatewayApiTest { } } + suspend fun latestId(): Long { + return client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=-1") + .assertOkJson<IncomingHistory>().incoming_transactions[0].row_id + } + + suspend fun testTrigger(trigger: suspend () -> Unit) { + coroutineScope { + val id = latestId() + launch { + assertTime(100, 200) { + client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=7&start=$id&long_poll_ms=1000") + .assertHistory(1) + } + } + delay(100) + trigger() + } + } + authRoutine("/accounts/merchant/taler-wire-gateway/history/incoming?delta=7", method = HttpMethod.Get) // Check error when no transactions @@ -193,15 +212,7 @@ class WireGatewayApiTest { // Gen one transaction using raw bank transaction logic tx("merchant", "KUDOS:10", "exchange", IncomingTxMetadata(randShortHashCode()).encode()) // Gen one transaction using withdraw logic - client.postA("/accounts/merchant/withdrawals") { - json { "amount" to "KUDOS:9" } - }.assertOkJson<BankAccountCreateWithdrawalResponse> { - val uuid = it.taler_withdraw_uri.split("/").last() - withdrawalSelect(uuid) - client.post("/withdrawals/${uuid}/confirm") { - pwAuth("merchant") - }.assertNoContent() - } + withdrawal("KUDOS:9") // Check ignore bogus subject client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=7") @@ -213,7 +224,7 @@ class WireGatewayApiTest { // Check no useless polling assertTime(0, 100) { - client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=-6&start=15&long_poll_ms=1000") + client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=-6&long_poll_ms=1000") .assertHistory(5) } @@ -224,15 +235,16 @@ class WireGatewayApiTest { } coroutineScope { + val id = latestId() launch { // Check polling succeed assertTime(100, 200) { - client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=2&start=14&long_poll_ms=1000") + client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=2&start=$id&long_poll_ms=1000") .assertHistory(1) } } launch { // Check polling timeout assertTime(200, 300) { - client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=1&start=16&long_poll_ms=200") + client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=1&start=${id+2}&long_poll_ms=200") .assertNoContent() } } @@ -241,47 +253,30 @@ class WireGatewayApiTest { } // Test trigger by raw transaction - coroutineScope { - launch { - assertTime(100, 200) { - client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=7&start=16&long_poll_ms=1000") - .assertHistory(1) - } - } - delay(100) + testTrigger { tx("merchant", "KUDOS:10", "exchange", IncomingTxMetadata(randShortHashCode()).encode()) - } + } + // Test trigger by withdraw operation + testTrigger { withdrawal("KUDOS:9") } + // Test trigger by incoming + testTrigger { addIncoming("KUDOS:9") } - // Test trigger by withdraw operationr - coroutineScope { - launch { - assertTime(100, 200) { - client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=7&start=18&long_poll_ms=1000") - .assertHistory(1) - } - } - delay(100) - client.postA("/accounts/merchant/withdrawals") { - json { "amount" to "KUDOS:9" } - }.assertOkJson<BankAccountCreateWithdrawalResponse> { - val uuid = it.taler_withdraw_uri.split("/").last() - withdrawalSelect(uuid) - client.postA("/withdrawals/${uuid}/confirm") - .assertNoContent() - } - } - - // Testing ranges. - repeat(20) { + // Testing ranges. + repeat(5) { addIncoming("KUDOS:10") } + val id = latestId() // forward range: - client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=10&start=20") + client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=10") + .assertHistory(10) + client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=10&start=4") .assertHistory(10) // backward range: - client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=-10&start=25") + client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=-10") + .assertHistory(10) + client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=-10&start=${id-4}") .assertHistory(10) } @@ -299,6 +294,25 @@ class WireGatewayApiTest { } } + suspend fun latestId(): Long { + return client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=-1") + .assertOkJson<OutgoingHistory>().outgoing_transactions[0].row_id + } + + suspend fun testTrigger(trigger: suspend () -> Unit) { + coroutineScope { + val id = latestId() + launch { + assertTime(100, 200) { + client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=7&start=$id&long_poll_ms=1000") + .assertHistory(1) + } + } + delay(100) + trigger() + } + } + authRoutine("/accounts/merchant/taler-wire-gateway/history/outgoing?delta=7", method = HttpMethod.Get) // Check error when no transactions @@ -328,7 +342,7 @@ class WireGatewayApiTest { // Check no useless polling assertTime(0, 100) { - client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=-6&start=15&long_poll_ms=1000") + client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=-6&long_poll_ms=1000") .assertHistory(5) } @@ -339,15 +353,16 @@ class WireGatewayApiTest { } coroutineScope { + val id = latestId() launch { // Check polling succeed forward assertTime(100, 200) { - client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=2&start=14&long_poll_ms=1000") + client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=2&start=$id&long_poll_ms=1000") .assertHistory(1) } } launch { // Check polling timeout forward assertTime(200, 300) { - client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=1&start=16&long_poll_ms=200") + client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=1&start=${id+2}&long_poll_ms=200") .assertNoContent() } } @@ -355,17 +370,29 @@ class WireGatewayApiTest { transfer("KUDOS:10") } - // Testing ranges. - repeat(20) { + // Test trigger by raw transaction + testTrigger { + tx("exchange", "KUDOS:10", "merchant", OutgoingTxMetadata(randShortHashCode(), ExchangeUrl("http://exchange.example.com/")).encode()) + } + // Test trigger by outgoing + testTrigger { transfer("KUDOS:9") } + + // Testing ranges + repeat(5) { transfer("KUDOS:10") } + val id = latestId() // forward range: - client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=10&start=20") + client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=10") + .assertHistory(10) + client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=10&start=4") .assertHistory(10) // backward range: - client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=-10&start=25") + client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=-10") + .assertHistory(10) + client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=-10&start=${id-4}") .assertHistory(10) } diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -116,6 +116,7 @@ fun dbSetup(lambda: suspend (Database) -> Unit) { /* ----- Common actions ----- */ +/** Set [account] debit threshold to [maxDebt] amount */ suspend fun ApplicationTestBuilder.setMaxDebt(account: String, maxDebt: TalerAmount) { client.patch("/accounts/$account") { pwAuth("admin") @@ -123,16 +124,18 @@ suspend fun ApplicationTestBuilder.setMaxDebt(account: String, maxDebt: TalerAmo }.assertNoContent() } -suspend fun ApplicationTestBuilder.assertBalance(account: String, info: CreditDebitInfo, amount: String) { +/** Check [account] balance is [amount], [amount] is prefixed with + for credit and - for debit */ +suspend fun ApplicationTestBuilder.assertBalance(account: String, amount: String) { client.get("/accounts/$account") { pwAuth("admin") }.assertOkJson<AccountData> { val balance = it.balance; - assertEquals(info, balance.credit_debit_indicator) - assertEquals(TalerAmount(amount), balance.amount) + val fmt = "${if (balance.credit_debit_indicator == CreditDebitInfo.debit) '-' else '+'}${balance.amount}" + assertEquals(amount, fmt, "For $account") } } +/** Perform a bank transaction of [amount] [from] account [to] account with [subject} */ suspend fun ApplicationTestBuilder.tx(from: String, amount: String, to: String, subject: String = "payout"): Long { return client.post("/accounts/$from/transactions") { basicAuth("$from", "$from-password") @@ -142,6 +145,7 @@ suspend fun ApplicationTestBuilder.tx(from: String, amount: String, to: String, }.assertOkJson<TransactionCreateResponse>().row_id } +/** Perform a taler outgoing transaction of [amount] from exchange to merchant */ suspend fun ApplicationTestBuilder.transfer(amount: String) { client.post("/accounts/exchange/taler-wire-gateway/transfer") { pwAuth("exchange") @@ -155,6 +159,7 @@ suspend fun ApplicationTestBuilder.transfer(amount: String) { }.assertOk() } +/** Perform a taler incoming transaction of [amount] from merchant to exchange */ suspend fun ApplicationTestBuilder.addIncoming(amount: String) { client.post("/accounts/exchange/taler-wire-gateway/admin/add-incoming") { pwAuth("admin") @@ -166,13 +171,27 @@ suspend fun ApplicationTestBuilder.addIncoming(amount: String) { }.assertOk() } +/** Perform a cashout operation of [amount] from customer */ suspend fun ApplicationTestBuilder.cashout(amount: String) { - client.postA("/accounts/customer/cashouts") { + val res = client.postA("/accounts/customer/cashouts") { json { "request_uid" to randShortHashCode() "amount_debit" to amount "amount_credit" to convert(amount) } + } + if (res.status == HttpStatusCode.Conflict) { + // Retry with cashout info + fillCashoutInfo("customer") + client.postA("/accounts/customer/cashouts") { + json { + "request_uid" to randShortHashCode() + "amount_debit" to amount + "amount_credit" to convert(amount) + } + } + } else { + res }.assertOkJson<CashoutPending> { client.postA("/accounts/customer/cashouts/${it.cashout_id}/confirm") { json { "tan" to smsCode("+99") } @@ -180,6 +199,18 @@ suspend fun ApplicationTestBuilder.cashout(amount: String) { } } +/** Perform a whithrawal operation of [amount] from customer */ +suspend fun ApplicationTestBuilder.withdrawal(amount: String) { + client.postA("/accounts/merchant/withdrawals") { + json { "amount" to amount } + }.assertOkJson<BankAccountCreateWithdrawalResponse> { + val uuid = it.taler_withdraw_uri.split("/").last() + withdrawalSelect(uuid) + client.postA("/withdrawals/${uuid}/confirm") + .assertNoContent() + } +} + suspend fun ApplicationTestBuilder.fillCashoutInfo(account: String) { client.patchA("/accounts/$account") { json { diff --git a/util/src/main/kotlin/DB.kt b/util/src/main/kotlin/DB.kt @@ -176,6 +176,19 @@ fun PreparedStatement.executeUpdateViolation(): Boolean { } } +fun PreparedStatement.executeProcedureViolation(): Boolean { + val savepoint = connection.setSavepoint(); + return try { + executeUpdate() + connection.releaseSavepoint(savepoint) + true + } catch (e: SQLException) { + connection.rollback(savepoint); + if (e.sqlState == "23505") return false // unique_violation + throw e // rethrowing, not to hide other types of errors. + } +} + // sqlFilePrefix is, for example, "libeufin-bank" or "libeufin-nexus" (no trailing dash). fun initializeDatabaseTables(cfg: DatabaseConfig, sqlFilePrefix: String) { logger.info("doing DB initialization, sqldir ${cfg.sqlDir}, dbConnStr ${cfg.dbConnStr}")