libeufin

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

IntegrationTest.kt (17523B)


      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 com.github.ajalt.clikt.core.CliktCommand
     21 import com.github.ajalt.clikt.testing.test
     22 import io.ktor.client.*
     23 import io.ktor.client.engine.cio.*
     24 import io.ktor.client.plugins.*
     25 import io.ktor.client.request.*
     26 import io.ktor.client.statement.*
     27 import io.ktor.http.*
     28 import kotlinx.coroutines.runBlocking
     29 import org.junit.Test
     30 import tech.libeufin.bank.BankAccountTransactionsResponse
     31 import tech.libeufin.bank.CashoutResponse
     32 import tech.libeufin.bank.ConversionResponse
     33 import tech.libeufin.bank.RegisterAccountResponse
     34 import tech.libeufin.bank.cli.LibeufinBank
     35 import tech.libeufin.common.*
     36 import tech.libeufin.common.db.*
     37 import tech.libeufin.common.test.*
     38 import tech.libeufin.common.api.engine
     39 import tech.libeufin.nexus.*
     40 import tech.libeufin.nexus.cli.LibeufinNexus
     41 import tech.libeufin.nexus.cli.registerIncomingPayment
     42 import tech.libeufin.nexus.iso20022.*
     43 import java.time.Instant
     44 import kotlin.io.path.Path
     45 import kotlin.io.path.readText
     46 import kotlin.test.*
     47 import tech.libeufin.nexus.db.Database as NexusDb
     48 
     49 const val UNIX_SOCKET_PATH: String = "/tmp/libeufin.sock";
     50 
     51 fun CliktCommand.run(cmd: String) {
     52     val result = test(cmd)
     53     if (result.statusCode != 0)
     54         throw Exception(result.output)
     55     println(result.output)
     56 }
     57 
     58 fun HttpResponse.assertNoContent() {
     59     assertEquals(HttpStatusCode.NoContent, this.status)
     60 }
     61 
     62 fun server(client: HttpClient, lambda: () -> Unit) {
     63     globalTestTokens.clear()
     64     // Start the HTTP server in another thread
     65     kotlin.concurrent.thread(isDaemon = true)  {
     66         lambda()
     67     }
     68     // Wait for the HTTP server to be up
     69     runBlocking {
     70         client.get("/config")
     71     }
     72    
     73 }
     74 
     75 fun setup(conf: String, lambda: suspend (NexusDb) -> Unit) {
     76     try {
     77         runBlocking {
     78             nexusConfig(Path(conf)).withDb { db, _ ->
     79                 lambda(db)
     80             }
     81         }
     82     } finally {
     83         engine?.stop(0, 0) // Stop http server if started
     84     }
     85 }
     86 
     87 inline fun assertException(msg: String, lambda: () -> Unit) {
     88     try {
     89         lambda()
     90         throw Exception("Expected failure: $msg")
     91     } catch (e: Exception) {
     92         assert(e.message!!.startsWith(msg)) { "${e.message}" }
     93     }
     94 }
     95 
     96 class IntegrationTest {
     97     val nexusCmd = LibeufinNexus()
     98     val bankCmd = LibeufinBank()
     99     val client = HttpClient(CIO) {
    100         install(HttpRequestRetry) {
    101             maxRetries = 10
    102             constantDelay(200, 100)
    103         }
    104         defaultRequest {
    105             url("http://socket/")
    106             unixSocket(UNIX_SOCKET_PATH)
    107         }
    108     }
    109 
    110     @Test
    111     fun mini() {
    112         val client = HttpClient(CIO) {
    113             install(HttpRequestRetry) {
    114                 maxRetries = 10
    115                 constantDelay(200, 100)
    116             }
    117             defaultRequest {
    118                 url("http://0.0.0.0:8080/")
    119             }
    120         }
    121         val flags = "-c conf/mini.conf -L DEBUG"
    122         bankCmd.run("dbinit $flags -r")
    123         bankCmd.run("passwd admin admin-password $flags")
    124         bankCmd.run("dbinit $flags") // Idempotent
    125         
    126         server(client) {
    127             bankCmd.run("serve $flags")
    128         }
    129         
    130         setup("conf/mini.conf") {
    131             // Check bank is running
    132             client.get("/public-accounts").assertNoContent()
    133         }
    134 
    135         bankCmd.run("gc $flags")
    136 
    137         server(client) {
    138             nexusCmd.run("serve $flags")
    139         }
    140         engine?.stop(0, 0) 
    141     }
    142 
    143     @Test
    144     fun errors() {
    145         val flags = "-c conf/integration.conf -L DEBUG"
    146         nexusCmd.run("dbinit $flags -r")
    147         bankCmd.run("dbinit $flags -r")
    148         bankCmd.run("passwd admin admin-password $flags")
    149 
    150         suspend fun NexusDb.checkCount(nbIncoming: Int, nbBounce: Int, nbTalerable: Int) {
    151             serializable(
    152                 """
    153                     SELECT (SELECT count(*) FROM incoming_transactions) AS incoming,
    154                         (SELECT count(*) FROM bounced_transactions) AS bounce,
    155                         (SELECT count(*) FROM talerable_incoming_transactions) AS talerable;
    156                 """
    157             ) {
    158                 one {
    159                     assertEquals(
    160                         Triple(nbIncoming, nbBounce, nbTalerable),
    161                         Triple(it.getInt("incoming"), it.getInt("bounce"), it.getInt("talerable"))
    162                     )
    163                 }
    164             }
    165         }
    166 
    167         setup("conf/integration.conf") { db ->
    168             val cfg = NexusIngestConfig.default(AccountType.exchange)
    169             val userPayTo = IbanPayto.rand()
    170     
    171             // Load conversion setup manually as the server would refuse to start without an exchange account
    172             val sqlProcedures = Path("../database-versioning/libeufin-conversion-setup.sql")
    173             db.conn { 
    174                 it.execSQLUpdate(sqlProcedures.readText())
    175                 it.execSQLUpdate("SET search_path TO libeufin_nexus;")
    176             }
    177 
    178             val reservePub = EddsaPublicKey.randEdsaKey()
    179             val reservePayment = IncomingPayment(
    180                 amount = TalerAmount("EUR:10"),
    181                 debtor = userPayTo,
    182                 subject = "Error test $reservePub",
    183                 executionTime = Instant.now(),
    184                 id = IncomingId(null, "reserve_error", null)
    185             )
    186 
    187             assertException("ERROR: cashin failed: missing exchange account") {
    188                 registerIncomingPayment(db, cfg, reservePayment)
    189             }
    190             db.checkCount(0, 0, 0)
    191 
    192             // But KYC works
    193             registerIncomingPayment(
    194                 db, cfg, 
    195                 reservePayment.copy(
    196                     id = IncomingId(null, "kyc", null),
    197                     subject = "Error test KYC:${EddsaPublicKey.randEdsaKey()}"
    198                 )
    199             )
    200             db.checkCount(1, 0, 1)
    201 
    202             // Create exchange account
    203             bankCmd.run("create-account $flags -u exchange -p exchange-password --name 'Mr Money' --exchange")
    204     
    205           
    206             // Missing rates
    207             registerIncomingPayment(db, cfg, reservePayment.copy(id = IncomingId(null, "rate_error", null)))
    208             db.checkCount(2, 1, 1)
    209 
    210             // Start server
    211             server(client) {
    212                 bankCmd.run("serve $flags")
    213             }
    214 
    215             // Set conversion rates
    216             client.postAdmin("/conversion-info/conversion-rate") {
    217                 json {
    218                     "cashin_ratio" to "0.8"
    219                     "cashin_fee" to "KUDOS:0.02"
    220                     "cashin_tiny_amount" to "KUDOS:0.01"
    221                     "cashin_rounding_mode" to "nearest"
    222                     "cashin_min_amount" to "EUR:0"
    223                     "cashout_ratio" to "1.25"
    224                     "cashout_fee" to "EUR:0.003"
    225                     "cashout_tiny_amount" to "EUR:0.01"
    226                     "cashout_rounding_mode" to "zero"
    227                     "cashout_min_amount" to "KUDOS:0.1"
    228                 }
    229             }.assertNoContent()
    230             
    231             assertException("ERROR: cashin failed: admin balance insufficient") {
    232                 db.payment.registerTalerableIncoming(reservePayment, IncomingSubject.Reserve(reservePub))
    233             }
    234 
    235             // Allow admin debt
    236             bankCmd.run("edit-account admin --debit_threshold KUDOS:100 $flags")
    237 
    238             // Too small amount
    239             db.checkCount(2, 1, 1)
    240             registerIncomingPayment(db, cfg, reservePayment.copy(
    241                 amount = TalerAmount("EUR:0.01"),
    242             ))
    243             db.checkCount(3, 2, 1)
    244             client.getA("/accounts/exchange/transactions").assertNoContent()
    245 
    246             // Check success
    247             val validPayment = reservePayment.copy(
    248                 subject = "Success $reservePub",
    249                 id = IncomingId(null, "success", null),
    250             )
    251             registerIncomingPayment(db, cfg, validPayment)
    252             db.checkCount(4, 2, 2)
    253             client.getA("/accounts/exchange/transactions")
    254                 .assertOkJson<BankAccountTransactionsResponse>()
    255 
    256             // Check idempotency
    257             registerIncomingPayment(db, cfg, validPayment)
    258             registerIncomingPayment(db, cfg, validPayment.copy(
    259                 subject="Success 2 $reservePub"
    260             ))
    261             db.checkCount(4, 2, 2)
    262         }
    263     }
    264 
    265     @Test
    266     fun conversion() {
    267         suspend fun NexusDb.checkInitiated(amount: TalerAmount, name: String?) {
    268             serializable(
    269                 """
    270                 SELECT 
    271                     (amount).val AS amount_val,
    272                     (amount).frac AS amount_frac,
    273                     credit_payto,
    274                     subject
    275                 FROM initiated_outgoing_transactions
    276                 ORDER BY initiation_time DESC
    277                 """
    278             ) {
    279                 one {
    280                     val am = it.getAmount("amount", amount.currency)
    281                     println(it.getString("credit_payto"))
    282                     val payto = it.getIbanPayto("credit_payto")
    283                     val subject = it.getString("subject")
    284                     assertEquals(amount, am)
    285                     assertEquals(payto.receiverName, name)
    286                 }
    287             }
    288         }
    289         val flags = "-c conf/integration.conf -L DEBUG"
    290         nexusCmd.run("dbinit $flags -r")
    291         bankCmd.run("dbinit $flags -r")
    292         bankCmd.run("passwd admin admin-password $flags")
    293         bankCmd.run("edit-account admin --debit_threshold KUDOS:1000 $flags")
    294         bankCmd.run("create-account $flags -u exchange -p exchange-password --name 'Mr Money' --exchange")
    295         nexusCmd.run("dbinit $flags") // Idempotent
    296         bankCmd.run("dbinit $flags") // Idempotent
    297 
    298         server(client) {
    299             bankCmd.run("serve $flags")
    300         }
    301         
    302         setup("conf/integration.conf") { db -> 
    303             val userPayTo = IbanPayto.rand()
    304             val fiatPayTo = IbanPayto.rand()
    305 
    306             // Create user
    307             client.postAdmin("/accounts") {
    308                 json {
    309                     "username" to "customer"
    310                     "password" to "customer-password"
    311                     "name" to "John Smith"
    312                     "internal_payto_uri" to userPayTo
    313                     "cashout_payto_uri" to fiatPayTo
    314                     "debit_threshold" to "KUDOS:100"
    315                     "contact_data" to obj {
    316                         "phone" to "+99"
    317                     }
    318                 }
    319             }.assertOkJson<RegisterAccountResponse>()
    320 
    321             // Set conversion rates
    322             client.postAdmin("/conversion-info/conversion-rate") {
    323                 json {
    324                     "cashin_ratio" to "0.8"
    325                     "cashin_fee" to "KUDOS:0.02"
    326                     "cashin_tiny_amount" to "KUDOS:0.01"
    327                     "cashin_rounding_mode" to "nearest"
    328                     "cashin_min_amount" to "EUR:0"
    329                     "cashout_ratio" to "1.25"
    330                     "cashout_fee" to "EUR:0.003"
    331                     "cashout_tiny_amount" to "EUR:0.01"
    332                     "cashout_rounding_mode" to "zero"
    333                     "cashout_min_amount" to "KUDOS:0.1"
    334                 }
    335             }.assertNoContent()
    336 
    337             // Cashin
    338             repeat(3) { i ->
    339                 val reservePub = EddsaPublicKey.randEdsaKey()
    340                 val amount = TalerAmount("EUR:${20+i}")
    341                 val subject = "cashin test $i: $reservePub"
    342                 nexusCmd.run("testing fake-incoming $flags --subject \"$subject\" --amount $amount $userPayTo")
    343                 val converted = client.get("/conversion-info/cashin-rate?amount_debit=EUR:${20 + i}")
    344                     .assertOkJson<ConversionResponse>().amount_credit
    345                 client.getA("/accounts/exchange/transactions").assertOkJson<BankAccountTransactionsResponse> {
    346                     val tx = it.transactions.first()
    347                     assertEquals(subject, tx.subject)
    348                     assertEquals(converted, tx.amount)
    349                 }
    350                 client.getA("/accounts/exchange/taler-wire-gateway/history/incoming").assertOkJson<IncomingHistory> {
    351                     val tx = it.incoming_transactions.first()
    352                     assertEquals(converted, tx.amount)
    353                     assertIs<IncomingReserveTransaction>(tx)
    354                     assertEquals(reservePub, tx.reserve_pub)
    355                 }
    356             }
    357 
    358             // Cashout
    359             repeat(3) { i ->  
    360                 val requestUid = ShortHashCode.rand()
    361                 val amount = TalerAmount("KUDOS:${10+i}")
    362                 val converted = client.get("/conversion-info/cashout-rate?amount_debit=$amount")
    363                     .assertOkJson<ConversionResponse>().amount_credit
    364                 client.postA("/accounts/customer/cashouts") {
    365                     json {
    366                         "request_uid" to requestUid
    367                         "amount_debit" to amount
    368                         "amount_credit" to converted
    369                     }
    370                 }.assertOkJson<CashoutResponse>()
    371                 db.checkInitiated(converted, "John Smith")
    372             }
    373 
    374             // Exchange bounce no name
    375             repeat(3) { i ->
    376                 val reservePub = EddsaPublicKey.randEdsaKey()
    377                 val amount = TalerAmount("EUR:${30+i}")
    378                 val subject = "exchange bounce test $i: $reservePub"
    379 
    380                 // Cashin
    381                 nexusCmd.run("testing fake-incoming $flags --subject \"$subject\" --amount $amount $userPayTo")
    382                 val converted = client.get("/conversion-info/cashin-rate?amount_debit=EUR:${30 + i}")
    383                     .assertOkJson<ConversionResponse>().amount_credit
    384                 client.getA("/accounts/exchange/transactions").assertOkJson<BankAccountTransactionsResponse> {
    385                     val tx = it.transactions.first()
    386                     assertEquals(subject, tx.subject)
    387                     assertEquals(converted, tx.amount)
    388                 }
    389                 client.getA("/accounts/exchange/taler-wire-gateway/history/incoming").assertOkJson<IncomingHistory> {
    390                     val tx = it.incoming_transactions.first()
    391                     assertEquals(converted, tx.amount)
    392                     assertIs<IncomingReserveTransaction>(tx)
    393                     assertEquals(reservePub, tx.reserve_pub)
    394                 }
    395 
    396                 // Bounce
    397                 client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
    398                     json {
    399                         "request_uid" to HashCode.rand()
    400                         "amount" to converted
    401                         "exchange_base_url" to "http://exchange.example.com/"
    402                         "wtid" to reservePub
    403                         "credit_account" to "payto://x-taler-bank/localhost/admin"
    404                     }
    405                 }.assertOkJson<TransferResponse>()
    406                 
    407                 db.checkInitiated(amount, null)
    408             }
    409             
    410             // Exchange bounce with name
    411             repeat(3) { i ->
    412                 val reservePub = EddsaPublicKey.randEdsaKey()
    413                 val amount = TalerAmount("EUR:${40+i}")
    414                 val subject = "exchange bounce test $i: $reservePub"
    415 
    416                 // Cashin
    417                 nexusCmd.run("testing fake-incoming $flags --subject \"$subject\" --amount $amount $userPayTo?receiver-name=John%20d%27Smith")
    418                 val converted = client.get("/conversion-info/cashin-rate?amount_debit=EUR:${40 + i}")
    419                     .assertOkJson<ConversionResponse>().amount_credit
    420                 client.getA("/accounts/exchange/transactions").assertOkJson<BankAccountTransactionsResponse> {
    421                     val tx = it.transactions.first()
    422                     assertEquals(subject, tx.subject)
    423                     assertEquals(converted, tx.amount)
    424                 }
    425                 client.getA("/accounts/exchange/taler-wire-gateway/history/incoming").assertOkJson<IncomingHistory> {
    426                     val tx = it.incoming_transactions.first()
    427                     assertEquals(converted, tx.amount)
    428                     assertIs<IncomingReserveTransaction>(tx)
    429                     assertEquals(reservePub, tx.reserve_pub)
    430                 }
    431 
    432                 // Bounce
    433                 client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
    434                     json {
    435                         "request_uid" to HashCode.rand()
    436                         "amount" to converted
    437                         "exchange_base_url" to "http://exchange.example.com/"
    438                         "wtid" to reservePub
    439                         "credit_account" to "payto://x-taler-bank/localhost/admin"
    440                     }
    441                 }.assertOkJson<TransferResponse>()
    442                 
    443                 db.checkInitiated(amount, "John d'Smith")
    444             }
    445         }
    446     }
    447 }