libeufin

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

bench.kt (18690B)


      1 /*
      2  * This file is part of LibEuFin.
      3  * Copyright (C) 2024, 2025, 2026 Taler Systems S.A.
      4 
      5  * LibEuFin is free software; you can redistribute it and/or modify
      6  * it under the terms of the GNU Affero General Public License as
      7  * published by the Free Software Foundation; either version 3, or
      8  * (at your option) any later version.
      9 
     10  * LibEuFin is distributed in the hope that it will be useful, but
     11  * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
     12  * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
     13  * Public License for more details.
     14 
     15  * You should have received a copy of the GNU Affero General Public
     16  * License along with LibEuFin; see the file COPYING.  If not, see
     17  * <http://www.gnu.org/licenses/>
     18  */
     19 
     20 import io.ktor.client.request.*
     21 import io.ktor.http.*
     22 import org.junit.Test
     23 import org.postgresql.jdbc.PgConnection
     24 import tech.libeufin.bank.*
     25 import tech.libeufin.common.*
     26 import tech.libeufin.common.crypto.PwCrypto
     27 import tech.libeufin.common.crypto.CryptoUtil
     28 import tech.libeufin.common.test.*
     29 import java.time.Instant
     30 import java.time.LocalDateTime
     31 import java.time.ZoneId
     32 import java.util.*
     33 import kotlin.math.max
     34 
     35 class Bench {
     36 
     37     /** Generate [amount] rows to fill the database */
     38     fun genData(conn: PgConnection, amount: Int) {
     39         val amount = max(amount, 10)
     40 
     41         // Skip 4 accounts created by bankSetup
     42         val skipAccount = 4 
     43         // Customer account will be used in tests so we want to generate more data for him
     44         val customerAccount = 3
     45         val exchangeAccount = 2
     46         // In general half of the data is for generated account and half is for customer
     47         val mid = amount / 2
     48 
     49         val password = PwCrypto.Bcrypt(cost = 4).hashpw("password")
     50         
     51         val token16 = ByteArray(16)
     52         val token32 = ByteArray(32)
     53         val token64 = ByteArray(64)
     54 
     55         val accountPubs = List(amount*2) { EddsaPublicKey.randEdsaKey() } 
     56 
     57         conn.genData(amount, sequenceOf(
     58             "customers(username, name, password_hash, cashout_payto)" to {
     59                 "account_$it\t$password\tMr n°$it\t$unknownPayto\n"
     60             },
     61             "conversion_rate_classes(name)" to {
     62                 "Class n0$it\n"
     63             },
     64             "bank_accounts(internal_payto, owning_customer_id, is_public,conversion_rate_class_id)" to {
     65                 val conversionId = when (it%5) {
     66                     0 -> "\\N"
     67                     1, 2 -> "1"
     68                     3 -> "2"
     69                     else -> it%10
     70                 }
     71                 "payto://x-taler-bank/localhost/account_$it\t${it+skipAccount}\t${it%3==0}\t$conversionId\n"
     72             },
     73             "bearer_tokens(content, creation_time, expiration_time, scope, is_refreshable, bank_customer, description, last_access)" to {
     74                 val account = if (it > mid) customerAccount else it+4
     75                 val hex = token32.rand().encodeHex()
     76                 "\\\\x$hex\t0\t0\treadonly\tfalse\t$account\t\\N\t0\n"
     77             },
     78             "bank_account_transactions(creditor_payto, creditor_name, debtor_payto, debtor_name, subject, amount, transaction_date, direction, bank_account_id)" to {
     79                 val account = if (it > mid) customerAccount else it+4
     80                 "$unknownPayto\tcreditor_name\t$unknownPayto\tdebtor_name\tsubject\t(42,0)\t0\tcredit\t$exchangeAccount\n" + 
     81                 "$unknownPayto\tcreditor_name\t$unknownPayto\tdebtor_name\tsubject\t(42,0)\t0\tdebit\t$account\n"
     82             },
     83             "bank_transaction_operations" to {
     84                 val hex = token32.rand().encodeHex()
     85                 "\\\\x$hex\t$it\n"
     86             },
     87             "tan_challenges(uuid, hbody, salt, op, code, creation_date, expiration_date, retry_counter, customer, tan_channel, tan_info)" to {
     88                 val account = if (it > mid) customerAccount else it+4
     89                 val uuid = UUID.randomUUID()
     90                 val hex16 = token16.rand().encodeHex()
     91                 val hex64 = token64.rand().encodeHex()
     92                 "$uuid\t\\\\x$hex64\t\\\\x$hex16\taccount_reconfig\tcode\t0\t0\t0\t$account\tsms\tinfo\n"
     93             },
     94             "taler_withdrawal_operations(withdrawal_uuid, wallet_bank_account, reserve_pub, creation_date)" to {
     95                 val account = if (it > mid) customerAccount else it+4
     96                 val hex = token32.rand().encodeHex()
     97                 val uuid = UUID.randomUUID()
     98                 "$uuid\t$account\t\\\\x$hex\t0\n"
     99             },
    100             "prepared_transfers(type, account_pub, authorization_pub, authorization_sig, recurrent, registered_at, bank_transaction_id)" to {
    101                 val type = if (it % 2 == 0) "reserve" else "kyc"
    102                 val recurrent = if (it % 3 == 0) "true" else "false"
    103                 val incoming_transaction_id = if (it % 5 == 0) "\\N" else "${it*2}"
    104                 val hex = accountPubs[it].raw.encodeHex()
    105                 val hex64 = token64.rand().encodeHex()
    106                 "$type\t\\\\x$hex\t\\\\x$hex\t\\\\x$hex64\t$recurrent\t0\t$incoming_transaction_id\n"
    107             },
    108             "pending_recurrent_incoming_transactions(bank_transaction_id, debtor_account_id, authorization_pub)" to {
    109                 val hex = accountPubs[it].raw.encodeHex()
    110                 "${it*2}\t${it}\t\\\\x$hex\n"
    111             },
    112             "taler_exchange_outgoing(bank_transaction)" to {
    113                 "${it*2-1}\n"
    114             },
    115             "transfer_operations(wtid, request_uid, amount, exchange_base_url, exchange_outgoing_id, exchange_id, transfer_date, creditor_payto, status, status_msg)" to {
    116                 val hex32 = token32.rand().encodeHex()
    117                 val hex64 = token64.rand().encodeHex()
    118                 if (it % 2 == 0) {
    119                     "\\\\x$hex32\t\\\\x$hex64\t(42, 0)\turl\t$it\t$it\t0\tpayto://x-taler-bank/localhost/10\tsuccess\t\\N\n"
    120                 } else {
    121                     "\\\\x$hex32\t\\\\x$hex64\t(42, 0)\turl\t\\N\t$it\t0\tpayto://x-taler-bank/localhost/10\tpermanent_failure\tfailure\n"
    122                 }   
    123             },
    124             "taler_exchange_incoming(type, metadata, bank_transaction)" to {
    125                 val hex = token32.rand().encodeHex()
    126                 if (it % 2 == 0) {
    127                     "reserve\t\\\\x$hex\t${it*2}\n"
    128                 } else {
    129                     "kyc\t\\\\x$hex\t${it*2}\n"
    130                 }
    131             },
    132             "bank_stats(timeframe, start_time)" to {
    133                 val instant = Instant.ofEpochSecond(it.toLong())
    134                 val date = LocalDateTime.ofInstant(instant, ZoneId.of("UTC"))
    135                 "day\t$date\n"
    136             },
    137             "cashout_operations(request_uid,amount_debit,amount_credit,subject,creation_time,bank_account,local_transaction)" to {
    138                 val account = if (it > mid) customerAccount else it+4
    139                 val hex = token32.rand().encodeHex()
    140                 "\\\\x$hex\t(0,0)\t(0,0)\tsubject\t0\t$account\t$it\n"
    141             }
    142         ))
    143     }
    144 
    145     @Test
    146     fun benchDb() = bench { AMOUNT -> bankSetup { db ->
    147         // Prepare custoemr accounts
    148         fillCashoutInfo("customer")
    149         setMaxDebt("customer", "KUDOS:1000000")
    150 
    151         // Generate data
    152         db.conn { genData(it, AMOUNT) }
    153 
    154         val accountPubs = List(AMOUNT) { EddsaPublicKey.randEdsaKeyPair() } 
    155 
    156         // Warm HTTP client
    157         client.get("/config").assertOk()
    158         
    159         // Accounts
    160         measureAction("account_create") {
    161             client.post("/accounts") {
    162                 json {
    163                     "username" to "account_bench_$it"
    164                     "password" to "account_bench_$it-password"
    165                     "name" to "Bench Account $it"
    166                 }
    167             }.assertOkJson<RegisterAccountResponse>().internal_payto_uri
    168         }
    169         measureAction("account_reconfig") {
    170             client.patchA("/accounts/account_bench_$it") {
    171                 json {
    172                     "name" to "New Bench Account $it"
    173                 }
    174             }.assertNoContent()
    175         }
    176         measureAction("account_reconfig_auth") {
    177             client.patchA("/accounts/account_bench_$it/auth") {
    178                 json {
    179                     "old_password" to "account_bench_$it-password"
    180                     "new_password" to "account_bench_$it-password"
    181                 }
    182             }.assertNoContent()
    183         }
    184         measureAction("account_list") {
    185             client.getAdmin("/accounts").assertOk()
    186         }
    187         measureAction("account_list_class") {
    188             client.getAdmin("/accounts?conversion_rate_class=${it%10}").assertOk()
    189         }
    190         measureAction("account_list_name") {
    191             client.getAdmin("/accounts?name=Mr").assertOk()
    192         }
    193         measureAction("account_list_public") {
    194             client.get("/public-accounts").assertOk()
    195         }
    196         measureAction("account_get") {
    197             client.getA("/accounts/account_bench_$it").assertOk()
    198         }
    199         
    200         // Tokens
    201         val tokens = measureAction("token_create") {
    202             client.postPw("/accounts/customer/token") {
    203                 json { 
    204                     "scope" to "readonly"
    205                     "refreshable" to true
    206                 }
    207             }.assertOkJson<TokenSuccessResponse>().access_token
    208         }
    209         measureAction("token_refresh") {
    210             client.post("/accounts/customer/token") {
    211                 headers[HttpHeaders.Authorization] = "Bearer ${tokens[it]}"
    212                 json { "scope" to "readonly" }
    213             }.assertOk()
    214         }
    215         measureAction("token_list") {
    216             client.getA("/accounts/customer/tokens").assertOk()
    217         }
    218         measureAction("token_delete") {
    219             client.delete("/accounts/customer/token") {
    220                 headers[HttpHeaders.Authorization] = "Bearer ${tokens[it]}"
    221             }.assertNoContent()
    222         }
    223 
    224         // Conversion rate classes
    225         val classes = measureAction("class_create") {
    226             client.postAdmin("/conversion-rate-classes") {
    227                 json { 
    228                     "name" to "Gen class $it"
    229                 }
    230             }.assertOkJson<ConversionRateClassResponse>().conversion_rate_class_id
    231         }
    232         measureAction("class_patch") {
    233             client.patchAdmin("/conversion-rate-classes/${classes[it]}") {
    234                 json {
    235                     "name" to "Gen class $it"
    236                     "description" to "test $it"
    237                 }
    238             }.assertNoContent()
    239         }
    240         measureAction("class_get") {
    241             client.getAdmin("/conversion-rate-classes/${classes[it]}").assertOk()
    242         }
    243         measureAction("class_list") {
    244             client.getAdmin("/conversion-rate-classes").assertOk()
    245         }
    246         measureAction("class_delete") {
    247             client.deleteAdmin("/conversion-rate-classes/${classes[it]}").assertNoContent()
    248         }
    249 
    250         // Transaction
    251         val transactions = measureAction("transaction_create") {
    252             client.postA("/accounts/customer/transactions") {
    253                 json { 
    254                     "payto_uri" to "$merchantPayto?receiver-name=Test&message=payout"
    255                     "amount" to "KUDOS:0.0001"
    256                 }
    257             }.assertOkJson<TransactionCreateResponse>().row_id
    258         }
    259         measureAction("transaction_get") {
    260             client.getA("/accounts/customer/transactions/${transactions[it]}").assertOk()
    261         }
    262         measureAction("transaction_history") {
    263             client.getA("/accounts/customer/transactions").assertOk()
    264         }
    265         measureAction("transaction_revenue") {
    266             client.getA("/accounts/merchant/taler-revenue/history").assertOk()
    267         }
    268 
    269         // Withdrawal
    270         val withdrawals = measureAction("withdrawal_create") {
    271             client.postA("/accounts/customer/withdrawals") {
    272                 json { 
    273                     "amount" to "KUDOS:0.0001"
    274                 }
    275             }.assertOkJson<BankAccountCreateWithdrawalResponse>().withdrawal_id
    276         }
    277         measureAction("withdrawal_get") {
    278             client.get("/withdrawals/${withdrawals[it]}").assertOk()
    279         }
    280         measureAction("withdrawal_status") {
    281             client.get("/taler-integration/withdrawal-operation/${withdrawals[it]}").assertOk()
    282         }
    283         measureAction("withdrawal_select") {
    284             client.post("/taler-integration/withdrawal-operation/${withdrawals[it]}") {
    285                 json {
    286                     "reserve_pub" to EddsaPublicKey.randEdsaKey()
    287                     "selected_exchange" to exchangePayto
    288                 }
    289             }.assertOk()
    290         }
    291         measureAction("withdrawal_confirm") {
    292             client.postA("/accounts/customer/withdrawals/${withdrawals[it]}/confirm")
    293                 .assertNoContent()
    294         }
    295         measureAction("withdrawal_abort") {
    296             val uuid = client.postA("/accounts/customer/withdrawals") {
    297                 json { 
    298                     "amount" to "KUDOS:0.0001"
    299                 }
    300             }.assertOkJson<BankAccountCreateWithdrawalResponse>().withdrawal_id
    301             client.postA("/accounts/customer/withdrawals/$uuid/abort")
    302                 .assertNoContent()
    303         }
    304 
    305         // Cashout
    306         convert("KUDOS:0.1")
    307         val cashouts = measureAction("cashout_create") {
    308             client.postA("/accounts/customer/cashouts") {
    309                 json { 
    310                     "request_uid" to ShortHashCode.rand()
    311                     "amount_debit" to "KUDOS:0.1"
    312                     "amount_credit" to convert("KUDOS:0.1")
    313                 }
    314             }.assertOkJson<CashoutResponse>().cashout_id
    315         }
    316         measureAction("cashout_get") {
    317             client.getA("/accounts/customer/cashouts/${cashouts[it]}").assertOk()
    318         }
    319         measureAction("cashout_history") {
    320             client.getA("/accounts/customer/cashouts").assertOk()
    321         }
    322         measureAction("cashout_history_admin") {
    323             client.getAdmin("/cashouts").assertOk()
    324         }
    325 
    326         // Wire gateway
    327         val transfers = measureAction("wg_transfer") {
    328             client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
    329                 json { 
    330                     "request_uid" to HashCode.rand()
    331                     "amount" to "KUDOS:0.0001"
    332                     "exchange_base_url" to "http://exchange.example.com/"
    333                     "wtid" to ShortHashCode.rand()
    334                     "credit_account" to customerPayto.canonical
    335                 }
    336             }.assertOkJson<TransferResponse>().row_id
    337         }
    338         measureAction("wg_transfer_get") {
    339             client.getA("/accounts/exchange/taler-wire-gateway/transfers/${transfers[it]}").assertOk()
    340         }
    341         measureAction("wg_transfer_page") {
    342             client.getA("/accounts/exchange/taler-wire-gateway/transfers").assertOk()
    343         }
    344         measureAction("wg_transfer_page_filter") {
    345             client.getA("/accounts/exchange/taler-wire-gateway/transfers?status=success").assertOk()
    346         }
    347         measureAction("wg_add") {
    348             client.postA("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
    349                 json { 
    350                     "amount" to "KUDOS:0.0001"
    351                     "reserve_pub" to EddsaPublicKey.randEdsaKey()
    352                     "debit_account" to customerPayto.canonical
    353                 }
    354             }.assertOk()
    355         }
    356         measureAction("wg_incoming") {
    357             client.getA("/accounts/exchange/taler-wire-gateway/history/incoming")
    358                 .assertOk()
    359         }
    360         measureAction("wg_outgoing") {
    361             client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing")
    362                 .assertOk()
    363         }
    364 
    365         // TAN challenges
    366         val challenges = measureAction("tan_send") {
    367             val res = client.patchA("/accounts/account_bench_$it") {
    368                 json { 
    369                     "contact_data" to obj {
    370                         "phone" to "+99"
    371                         "email" to "email@example.com"
    372                     }
    373                     "tan_channel" to "sms"
    374                 }
    375             }.assertAcceptedJson<ChallengeResponse>()
    376             val challenge = res.challenges[0]
    377             client.postA("/accounts/account_bench_$it/challenge/${challenge.challenge_id}").assertOk()
    378             val code = tanCode(challenge.tan_info)
    379             Pair(challenge.challenge_id, code)
    380         }
    381         measureAction("tan_confirm") {
    382             val (id, code) = challenges[it]
    383             client.postA("/accounts/account_bench_$it/challenge/$id/confirm") {
    384                 json { "tan" to code }
    385             }.assertNoContent()
    386         }
    387 
    388         // Wire transfer
    389         measureAction("wt_register") {
    390             val (priv, pub) = accountPubs[it]
    391             val valid_req = obj {
    392                 "credit_amount" to "KUDOS:55"
    393                 "type" to "reserve"
    394                 "alg" to "ECDSA"
    395                 "account_pub" to pub
    396                 "authorization_pub" to pub
    397                 "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv)
    398                 "recurrent" to false
    399             }
    400             client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") {
    401                 json(valid_req)
    402             }.assertOkJson<SubjectResult>()
    403             client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") {
    404                 json(valid_req)
    405             }.assertOkJson<SubjectResult>()
    406         }
    407         measureAction("wt_unregister") {
    408             val (priv, pub) = accountPubs[it]
    409             val now = Instant.now().toString()
    410             val valid_req = obj {
    411                 "timestamp" to now
    412                 "authorization_pub" to pub
    413                 "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv)
    414             }
    415             client.delete("/accounts/exchange/taler-wire-transfer-gateway/registration") {
    416                 json(valid_req)
    417             }.assertNoContent()
    418             client.delete("/accounts/exchange/taler-wire-transfer-gateway/registration") {
    419                 json(valid_req)
    420             }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
    421         }
    422 
    423         // Delete accounts
    424 
    425         measureAction("account_delete") {
    426             client.deleteA("/accounts/account_bench_$it").assertNoContent()
    427         }
    428 
    429         // Other
    430         measureAction("monitor") {
    431             client.getAdmin("/monitor").assertOk()
    432         }
    433         db.gc.collect(Instant.now(), java.time.Duration.ZERO, java.time.Duration.ZERO, java.time.Duration.ZERO)
    434         measureAction("gc") {
    435             db.gc.collect(Instant.now(), java.time.Duration.ZERO, java.time.Duration.ZERO, java.time.Duration.ZERO)
    436         }
    437     } }
    438 }