libeufin

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

helpers.kt (13991B)


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