libeufin

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

helpers.kt (13849B)


      1 /*
      2  * This file is part of LibEuFin.
      3  * Copyright (C) 2023-2025 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.*
     21 import io.ktor.client.request.*
     22 import io.ktor.client.statement.*
     23 import io.ktor.http.*
     24 import io.ktor.server.testing.*
     25 import kotlinx.coroutines.runBlocking
     26 import tech.libeufin.bank.*
     27 import tech.libeufin.bank.db.AccountDAO.AccountCreationResult
     28 import tech.libeufin.bank.db.Database
     29 import tech.libeufin.common.*
     30 import tech.libeufin.common.test.*
     31 import tech.libeufin.common.db.dbInit
     32 import tech.libeufin.common.db.pgDataSource
     33 import java.nio.file.NoSuchFileException
     34 import kotlin.io.path.Path
     35 import kotlin.io.path.deleteExisting
     36 import kotlin.io.path.readText
     37 import kotlin.random.Random
     38 import kotlin.test.assertEquals
     39 import kotlin.test.assertIs
     40 import kotlin.test.assertNotNull
     41 
     42 /* ----- Setup ----- */
     43 
     44 val merchantPayto = IbanPayto.rand()
     45 val exchangePayto = IbanPayto.rand()
     46 val customerPayto = IbanPayto.rand()
     47 val unknownPayto  = IbanPayto.rand()
     48 var tmpPayTo      = IbanPayto.rand()
     49 lateinit var adminPayto: String
     50 val paytos = mapOf(
     51     "merchant" to merchantPayto, 
     52     "exchange" to exchangePayto, 
     53     "customer" to customerPayto
     54 )
     55 
     56 fun genTmpPayTo(): IbanPayto {
     57     tmpPayTo = IbanPayto.rand()
     58     return tmpPayTo
     59 }
     60 
     61 fun setup(
     62     conf: String = "test.conf",
     63     lambda: suspend (Database, BankConfig) -> Unit
     64 ) = runBlocking {
     65     globalTestTokens.clear()
     66     val cfg = bankConfig(Path("conf/$conf"))
     67     pgDataSource(cfg.dbCfg.dbConnStr).run {
     68         dbInit(cfg.dbCfg, "libeufin-nexus", true)
     69         dbInit(cfg.dbCfg, "libeufin-bank", true)
     70     }
     71     cfg.withDb { db, cfg ->
     72         db.conn { conn ->
     73             val sqlProcedures = Path("${cfg.dbCfg.sqlDir}/libeufin-conversion-setup.sql")
     74             conn.execSQLUpdate(sqlProcedures.readText())
     75         }
     76         lambda(db, cfg)
     77     }
     78 }
     79 
     80 fun bankSetup(
     81     conf: String = "test.conf",    
     82     lambda: suspend ApplicationTestBuilder.(Database) -> Unit
     83 ) = setup(conf) { db, cfg -> 
     84     // Creating the exchange and merchant accounts first.
     85     val bonus = TalerAmount.zero("KUDOS")
     86     assertIs<AccountCreationResult.Success>(db.account.create(
     87         username = "merchant",
     88         password = "merchant-password",
     89         name = "Merchant",
     90         internalPayto = merchantPayto,
     91         maxDebt = TalerAmount("KUDOS:10"),
     92         isTalerExchange = false,
     93         isPublic = false,
     94         bonus = bonus,
     95         checkPaytoIdempotent = false,
     96         email = null,
     97         phone = null,
     98         cashoutPayto = null,
     99         tanChannels = emptySet(),
    100         conversionRateClassId = null,
    101         pwCrypto = cfg.pwCrypto
    102     ))
    103     assertIs<AccountCreationResult.Success>(db.account.create(
    104         username = "exchange",
    105         password = "exchange-password",
    106         name = "Exchange",
    107         internalPayto = exchangePayto,
    108         maxDebt = TalerAmount("KUDOS:10"),
    109         isTalerExchange = true,
    110         isPublic = false,
    111         bonus = bonus,
    112         checkPaytoIdempotent = false,
    113         email = null,
    114         phone = null,
    115         cashoutPayto = null,
    116         tanChannels = emptySet(),
    117         conversionRateClassId = null,
    118         pwCrypto = cfg.pwCrypto
    119     ))
    120     assertIs<AccountCreationResult.Success>(db.account.create(
    121         username = "customer",
    122         password = "customer-password",
    123         name = "Customer",
    124         internalPayto = customerPayto,
    125         maxDebt = TalerAmount("KUDOS:10"),
    126         isTalerExchange = false,
    127         isPublic = false,
    128         bonus = bonus,
    129         checkPaytoIdempotent = false,
    130         email = null,
    131         phone = null,
    132         cashoutPayto = null,
    133         tanChannels = emptySet(),
    134         conversionRateClassId = null,
    135         pwCrypto = cfg.pwCrypto
    136     ))
    137     // Create admin account
    138     val result = assertIs<AccountCreationResult.Success>(createAdminAccount(db, cfg, "admin-password"))
    139     adminPayto = result.payto
    140     testApplication {
    141         application {
    142             corebankWebApp(db, cfg)
    143         }
    144         if (cfg.allowConversion) {
    145             // Set conversion rates
    146             client.postAdmin("/conversion-info/conversion-rate") {
    147                 json {
    148                     "cashin_ratio" to "0.8"
    149                     "cashin_fee" to "KUDOS:0.02"
    150                     "cashin_tiny_amount" to "KUDOS:0.01"
    151                     "cashin_rounding_mode" to "nearest"
    152                     "cashin_min_amount" to "EUR:0"
    153                     "cashout_ratio" to "1.26"
    154                     "cashout_fee" to "EUR:0.003"
    155                     "cashout_tiny_amount" to "EUR:0.01"
    156                     "cashout_rounding_mode" to "zero"
    157                     "cashout_min_amount" to "KUDOS:0.1"
    158                 }
    159             }.assertNoContent()
    160         }
    161         lambda(db)
    162     }
    163 }
    164 
    165 fun dbSetup(lambda: suspend (Database) -> Unit) =
    166     setup { db, _ -> lambda(db) }
    167 
    168 /* ----- Common actions ----- */
    169 
    170 /** Set [account] debit threshold to [maxDebt] amount */
    171 suspend fun ApplicationTestBuilder.setMaxDebt(account: String, maxDebt: String) {
    172     client.patchAdmin("/accounts/$account") { 
    173         json { "debit_threshold" to maxDebt }
    174     }.assertNoContent()
    175 }
    176 
    177 /** Check [account] balance is [amount], [amount] is prefixed with + for credit and - for debit */
    178 suspend fun ApplicationTestBuilder.assertBalance(account: String, amount: String) {
    179     client.getAdmin("/accounts/$account").assertOkJson<AccountData> {
    180         val balance = it.balance
    181         val fmt = "${if (balance.credit_debit_indicator == CreditDebitInfo.debit) '-' else '+'}${balance.amount}"
    182         assertEquals(amount, fmt, "For $account")
    183     }
    184 }
    185 
    186 /** Check [account] tan channel and info */
    187 suspend fun ApplicationTestBuilder.tanInfo(account: String): Pair<TanChannel?, String?> {
    188     val res = client.getA("/accounts/$account").assertOkJson<AccountData>()
    189     val channel: TanChannel? = res.tan_channel
    190     return Pair(channel, when (channel) {
    191         TanChannel.sms -> res.contact_data!!.phone.get()
    192         TanChannel.email -> res.contact_data!!.email.get()
    193         null -> null
    194     })
    195 }
    196 
    197 /** Perform a bank transaction of [amount] [from] account [to] account with [subject} */
    198 suspend fun ApplicationTestBuilder.tx(from: String, amount: String, to: String, subject: String = "payout"): Long {
    199     return client.postA("/accounts/$from/transactions") {
    200         json {
    201             "payto_uri" to "${paytos[to] ?: tmpPayTo}?message=${subject.encodeURLParameter()}&amount=$amount"
    202         }
    203     }.maybeChallenge().assertOkJson<TransactionCreateResponse>().row_id
    204 }
    205 
    206 /** Perform a taler outgoing transaction of [amount] from exchange to merchant */
    207 suspend fun ApplicationTestBuilder.transfer(amount: String, payto: IbanPayto = merchantPayto) {
    208     client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
    209         json {
    210             "request_uid" to HashCode.rand()
    211             "amount" to TalerAmount(amount)
    212             "exchange_base_url" to "http://exchange.example.com/"
    213             "wtid" to ShortHashCode.rand()
    214             "credit_account" to payto
    215         }
    216     }.assertOk()
    217 }
    218 
    219 /** Perform a taler incoming transaction of [amount] from merchant to exchange */
    220 suspend fun ApplicationTestBuilder.addIncoming(amount: String) {
    221     client.postA("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
    222         json {
    223             "amount" to TalerAmount(amount)
    224             "reserve_pub" to EddsaPublicKey.rand()
    225             "debit_account" to merchantPayto
    226         }
    227     }.assertOk()
    228 }
    229 
    230 /** Perform a taler kyc transaction of [amount] from merchant to exchange */
    231 suspend fun ApplicationTestBuilder.addKyc(amount: String) {
    232     client.postA("/accounts/exchange/taler-wire-gateway/admin/add-kycauth") {
    233         json {
    234             "amount" to TalerAmount(amount)
    235             "account_pub" to EddsaPublicKey.rand()
    236             "debit_account" to merchantPayto
    237         }
    238     }.assertOk()
    239 }
    240 
    241 /** Perform a cashout operation of [amount] from customer */
    242 suspend fun ApplicationTestBuilder.cashout(amount: String) {
    243     val res = client.postA("/accounts/customer/cashouts") {
    244         json {
    245             "request_uid" to ShortHashCode.rand()
    246             "amount_debit" to amount
    247             "amount_credit" to convert(amount)
    248         }
    249     } 
    250     if (res.status == HttpStatusCode.Conflict) {
    251         // Retry with cashout info
    252         fillCashoutInfo("customer")
    253         client.postA("/accounts/customer/cashouts") {
    254             json {
    255                 "request_uid" to ShortHashCode.rand()
    256                 "amount_debit" to amount
    257                 "amount_credit" to convert(amount)
    258             }
    259         } 
    260     } else { 
    261         res
    262     }.assertOk()
    263 }
    264 
    265 /** Perform a whithrawal operation of [amount] from customer */
    266 suspend fun ApplicationTestBuilder.withdrawal(amount: String) {
    267     client.postA("/accounts/merchant/withdrawals") {
    268         json { "amount" to amount } 
    269     }.assertOkJson<BankAccountCreateWithdrawalResponse> {
    270         val uuid = it.taler_withdraw_uri.split("/").last()
    271         withdrawalSelect(uuid)
    272         client.postA("/accounts/merchant/withdrawals/${uuid}/confirm")
    273             .assertNoContent()
    274     }
    275 }
    276 
    277 suspend fun ApplicationTestBuilder.fillCashoutInfo(account: String) {
    278     client.patchAdmin("/accounts/$account") {
    279         json {
    280             "cashout_payto_uri" to unknownPayto
    281             "contact_data" to obj {
    282                 "phone" to "+99"
    283             }
    284         }
    285     }.assertNoContent()
    286 }
    287 
    288 suspend fun ApplicationTestBuilder.fillTanInfo(username: String) {
    289     // Create a token before we require 2fa for it
    290     client.cachedToken(username)
    291     client.patchAdmin("/accounts/$username") {
    292         json {
    293             "contact_data" to obj {
    294                 "phone" to "+${Random.nextInt(0, 10000)}"
    295             }
    296             "tan_channel" to "sms"
    297         }
    298     }.assertNoContent()
    299 }
    300 
    301 suspend fun ApplicationTestBuilder.withdrawalSelect(uuid: String) {
    302     client.post("/taler-integration/withdrawal-operation/$uuid") {
    303         json {
    304             "reserve_pub" to EddsaPublicKey.rand()
    305             "selected_exchange" to exchangePayto
    306         }
    307     }.assertOk()
    308 }
    309 
    310 private var nbClass = 0;
    311 
    312 suspend fun ApplicationTestBuilder.createConversionRateClass(
    313     cashout_min_amount: TalerAmount? = null
    314 ): Long {
    315     nbClass += 1
    316     return client.postAdmin("/conversion-rate-classes") {
    317         json {
    318             "name" to "Gen class $nbClass"
    319             "cashout_min_amount" to cashout_min_amount
    320         }
    321     }.assertOkJson<ConversionRateClassResponse>().conversion_rate_class_id
    322 }
    323 
    324 
    325 suspend fun ApplicationTestBuilder.convert(amount: String): TalerAmount {
    326     return client.get("/conversion-info/cashout-rate?amount_debit=$amount")
    327         .assertOkJson<ConversionResponse>().amount_credit
    328 }
    329 
    330 fun tanCode(info: String): String? {
    331     try {
    332         val file = Path("/tmp/tan-$info.txt")
    333         val code = file.readText().split(" ", limit=2).first()
    334         file.deleteExisting()
    335         return code
    336     } catch (e: Exception) {
    337         if (e is NoSuchFileException) return null
    338         throw e
    339     }
    340 }
    341 
    342 
    343 /* ----- Assert ----- */
    344 
    345 suspend fun HttpResponse.maybeChallenge(): HttpResponse {
    346     return if (this.status == HttpStatusCode.Accepted) {
    347         this.assertChallenge()
    348     } else {
    349         this
    350     }
    351 }
    352 
    353 suspend fun HttpResponse.assertChallenge(
    354     check: suspend (ChallengeResponse) -> Unit = {}
    355 ): HttpResponse {
    356     val res = assertAcceptedJson<ChallengeResponse>()
    357     val username = call.request.url.segments[1]
    358 
    359     val challenge = res.challenges.random()
    360 
    361     if (res.combi_and) {
    362         for (challenge in res.challenges) {
    363             call.client.postA("/accounts/$username/challenge/${challenge.challenge_id}").assertOk()
    364         }
    365     } else {
    366         call.client.postA("/accounts/$username/challenge/${challenge.challenge_id}").assertOk()
    367     }
    368 
    369     check(res)
    370 
    371     if (res.combi_and) {
    372         for (challenge in res.challenges) {
    373             val code = assertNotNull(tanCode(challenge.tan_info))
    374             call.client.postA("/accounts/$username/challenge/${challenge.challenge_id}/confirm") {
    375                 json { "tan" to code }
    376             }.assertNoContent()
    377         }
    378     } else {
    379         val code = assertNotNull(tanCode(challenge.tan_info))
    380         call.client.postA("/accounts/$username/challenge/${challenge.challenge_id}/confirm") {
    381             json { "tan" to code }
    382         }.assertNoContent()
    383     }
    384 
    385     // Recover body from request
    386     val requestBody = this.request.content
    387     val ids = res.challenges.map { it.challenge_id }.joinToString(", ")
    388     return call.client.request(this.call.request.url) {
    389         tokenAuth(call.client, username)
    390         method = call.request.method
    391         headers[TALER_CHALLENGE_IDS] = ids
    392         setBody(requestBody)
    393     }
    394 }
    395 
    396 fun assertException(msg: String, lambda: () -> Unit) {
    397     try {
    398         lambda()
    399         throw Exception("Expected failure")
    400     } catch (e: Exception) {
    401         assert(e.message!!.startsWith(msg)) { "${e.message}" }
    402     }
    403 }
    404 
    405 /* ----- Random data generation ----- */
    406 
    407 fun randBase32Crockford(length: Int) = Base32Crockford.encode(ByteArray(length).rand())
    408 fun randIncomingSubject(reservePub: EddsaPublicKey): String = "$reservePub"
    409 fun randOutgoingSubject(wtid: ShortHashCode, url: BaseURL): String = "$wtid $url"