libeufin

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

IntegrationTest.kt (17546B)


      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 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 
    144     @Test
    145     fun errors() {
    146         val flags = "-c conf/integration.conf -L DEBUG"
    147         nexusCmd.run("dbinit $flags -r")
    148         bankCmd.run("dbinit $flags -r")
    149         bankCmd.run("passwd admin admin-password $flags")
    150 
    151         suspend fun NexusDb.checkCount(nbIncoming: Int, nbBounce: Int, nbTalerable: Int) {
    152             serializable(
    153                 """
    154                     SELECT (SELECT count(*) FROM incoming_transactions) AS incoming,
    155                         (SELECT count(*) FROM bounced_transactions) AS bounce,
    156                         (SELECT count(*) FROM talerable_incoming_transactions) AS talerable;
    157                 """
    158             ) {
    159                 one {
    160                     assertEquals(
    161                         Triple(nbIncoming, nbBounce, nbTalerable),
    162                         Triple(it.getInt("incoming"), it.getInt("bounce"), it.getInt("talerable"))
    163                     )
    164                 }
    165             }
    166         }
    167 
    168         setup("conf/integration.conf") { db ->
    169             val cfg = NexusIngestConfig.default(AccountType.exchange)
    170             val userPayTo = IbanPayto.rand("Sir Florian")
    171     
    172             // Load conversion setup manually as the server would refuse to start without an exchange account
    173             val sqlProcedures = Path("../database-versioning/libeufin-conversion-setup.sql")
    174             db.conn { 
    175                 it.execSQLUpdate(sqlProcedures.readText())
    176                 it.execSQLUpdate("SET search_path TO libeufin_nexus;")
    177             }
    178 
    179             val reservePub = EddsaPublicKey.randEdsaKey()
    180             val reservePayment = IncomingPayment(
    181                 amount = TalerAmount("EUR:10"),
    182                 debtor = userPayTo,
    183                 subject = "Error test $reservePub",
    184                 executionTime = Instant.now(),
    185                 id = IncomingId(null, "reserve_error", null)
    186             )
    187 
    188             assertException("ERROR: cashin failed: missing exchange account") {
    189                 registerIncomingPayment(db, cfg, reservePayment)
    190             }
    191             db.checkCount(0, 0, 0)
    192 
    193             // But KYC works
    194             registerIncomingPayment(
    195                 db, cfg, 
    196                 reservePayment.copy(
    197                     id = IncomingId(null, "kyc", null),
    198                     subject = "Error test KYC:${EddsaPublicKey.randEdsaKey()}"
    199                 )
    200             )
    201             db.checkCount(1, 0, 1)
    202 
    203             // Create exchange account
    204             bankCmd.run("create-account $flags -u exchange -p exchange-password --name 'Mr Money' --exchange")
    205     
    206           
    207             // Missing rates
    208             registerIncomingPayment(db, cfg, reservePayment.copy(id = IncomingId(null, "rate_error", null)))
    209             db.checkCount(2, 1, 1)
    210 
    211             // Start server
    212             server(client) {
    213                 bankCmd.run("serve $flags")
    214             }
    215 
    216             // Set conversion rates
    217             client.postAdmin("/conversion-info/conversion-rate") {
    218                 json {
    219                     "cashin_ratio" to "0.8"
    220                     "cashin_fee" to "KUDOS:0.02"
    221                     "cashin_tiny_amount" to "KUDOS:0.01"
    222                     "cashin_rounding_mode" to "nearest"
    223                     "cashin_min_amount" to "EUR:0"
    224                     "cashout_ratio" to "1.25"
    225                     "cashout_fee" to "EUR:0.003"
    226                     "cashout_tiny_amount" to "EUR:0.01"
    227                     "cashout_rounding_mode" to "zero"
    228                     "cashout_min_amount" to "KUDOS:0.1"
    229                 }
    230             }.assertNoContent()
    231             
    232             assertException("ERROR: cashin failed: admin balance insufficient") {
    233                 db.payment.registerTalerableIncoming(reservePayment, IncomingSubject.Reserve(reservePub))
    234             }
    235 
    236             // Allow admin debt
    237             bankCmd.run("edit-account admin --debit_threshold KUDOS:100 $flags")
    238 
    239             // Too small amount
    240             db.checkCount(2, 1, 1)
    241             registerIncomingPayment(db, cfg, reservePayment.copy(
    242                 amount = TalerAmount("EUR:0.01"),
    243             ))
    244             db.checkCount(3, 2, 1)
    245             client.getA("/accounts/exchange/transactions").assertNoContent()
    246 
    247             // Check success
    248             val validPayment = reservePayment.copy(
    249                 subject = "Success $reservePub",
    250                 id = IncomingId(null, "success", null),
    251             )
    252             registerIncomingPayment(db, cfg, validPayment)
    253             db.checkCount(4, 2, 2)
    254             client.getA("/accounts/exchange/transactions")
    255                 .assertOkJson<BankAccountTransactionsResponse>()
    256 
    257             // Check idempotency
    258             registerIncomingPayment(db, cfg, validPayment)
    259             registerIncomingPayment(db, cfg, validPayment.copy(
    260                 subject="Success 2 $reservePub"
    261             ))
    262             db.checkCount(4, 2, 2)
    263         }
    264     }
    265 
    266     @Test
    267     fun conversion() {
    268         suspend fun NexusDb.checkInitiated(amount: TalerAmount, name: String?) {
    269             serializable(
    270                 """
    271                 SELECT 
    272                     (amount).val AS amount_val,
    273                     (amount).frac AS amount_frac,
    274                     credit_payto,
    275                     subject
    276                 FROM initiated_outgoing_transactions
    277                 ORDER BY initiation_time DESC
    278                 """
    279             ) {
    280                 one {
    281                     val am = it.getAmount("amount", amount.currency)
    282                     println(it.getString("credit_payto"))
    283                     val payto = it.getIbanPayto("credit_payto")
    284                     val subject = it.getString("subject")
    285                     assertEquals(amount, am)
    286                     assertEquals(payto.receiverName, name)
    287                 }
    288             }
    289         }
    290         val flags = "-c conf/integration.conf -L DEBUG"
    291         nexusCmd.run("dbinit $flags -r")
    292         bankCmd.run("dbinit $flags -r")
    293         bankCmd.run("passwd admin admin-password $flags")
    294         bankCmd.run("edit-account admin --debit_threshold KUDOS:1000 $flags")
    295         bankCmd.run("create-account $flags -u exchange -p exchange-password --name 'Mr Money' --exchange")
    296         nexusCmd.run("dbinit $flags") // Idempotent
    297         bankCmd.run("dbinit $flags") // Idempotent
    298 
    299         server(client) {
    300             bankCmd.run("serve $flags")
    301         }
    302         
    303         setup("conf/integration.conf") { db -> 
    304             val userPayTo = IbanPayto.rand("Sir Christian")
    305             val fiatPayTo = IbanPayto.rand()
    306 
    307             // Create user
    308             client.postAdmin("/accounts") {
    309                 json {
    310                     "username" to "customer"
    311                     "password" to "customer-password"
    312                     "name" to "John Smith"
    313                     "internal_payto_uri" to userPayTo
    314                     "cashout_payto_uri" to fiatPayTo
    315                     "debit_threshold" to "KUDOS:100"
    316                     "contact_data" to obj {
    317                         "phone" to "+99"
    318                     }
    319                 }
    320             }.assertOkJson<RegisterAccountResponse>()
    321 
    322             // Set conversion rates
    323             client.postAdmin("/conversion-info/conversion-rate") {
    324                 json {
    325                     "cashin_ratio" to "0.8"
    326                     "cashin_fee" to "KUDOS:0.02"
    327                     "cashin_tiny_amount" to "KUDOS:0.01"
    328                     "cashin_rounding_mode" to "nearest"
    329                     "cashin_min_amount" to "EUR:0"
    330                     "cashout_ratio" to "1.25"
    331                     "cashout_fee" to "EUR:0.003"
    332                     "cashout_tiny_amount" to "EUR:0.01"
    333                     "cashout_rounding_mode" to "zero"
    334                     "cashout_min_amount" to "KUDOS:0.1"
    335                 }
    336             }.assertNoContent()
    337 
    338             // Cashin
    339             repeat(3) { i ->
    340                 val reservePub = EddsaPublicKey.randEdsaKey()
    341                 val amount = TalerAmount("EUR:${20+i}")
    342                 val subject = "cashin test $i: $reservePub"
    343                 nexusCmd.run("testing fake-incoming $flags --subject \"$subject\" --amount $amount $userPayTo")
    344                 val converted = client.get("/conversion-info/cashin-rate?amount_debit=EUR:${20 + i}")
    345                     .assertOkJson<ConversionResponse>().amount_credit
    346                 client.getA("/accounts/exchange/transactions").assertOkJson<BankAccountTransactionsResponse> {
    347                     val tx = it.transactions.first()
    348                     assertEquals(subject, tx.subject)
    349                     assertEquals(converted, tx.amount)
    350                 }
    351                 client.getA("/accounts/exchange/taler-wire-gateway/history/incoming").assertOkJson<IncomingHistory> {
    352                     val tx = it.incoming_transactions.first()
    353                     assertEquals(converted, tx.amount)
    354                     assertIs<IncomingReserveTransaction>(tx)
    355                     assertEquals(reservePub, tx.reserve_pub)
    356                 }
    357             }
    358 
    359             // Cashout
    360             repeat(3) { i ->  
    361                 val requestUid = ShortHashCode.rand()
    362                 val amount = TalerAmount("KUDOS:${10+i}")
    363                 val converted = client.get("/conversion-info/cashout-rate?amount_debit=$amount")
    364                     .assertOkJson<ConversionResponse>().amount_credit
    365                 client.postA("/accounts/customer/cashouts") {
    366                     json {
    367                         "request_uid" to requestUid
    368                         "amount_debit" to amount
    369                         "amount_credit" to converted
    370                     }
    371                 }.assertOkJson<CashoutResponse>()
    372                 db.checkInitiated(converted, "John Smith")
    373             }
    374 
    375             // Exchange bounce no name
    376             repeat(3) { i ->
    377                 val reservePub = EddsaPublicKey.randEdsaKey()
    378                 val amount = TalerAmount("EUR:${30+i}")
    379                 val subject = "exchange bounce test $i: $reservePub"
    380 
    381                 // Cashin
    382                 nexusCmd.run("testing fake-incoming $flags --subject \"$subject\" --amount $amount $userPayTo")
    383                 val converted = client.get("/conversion-info/cashin-rate?amount_debit=EUR:${30 + i}")
    384                     .assertOkJson<ConversionResponse>().amount_credit
    385                 client.getA("/accounts/exchange/transactions").assertOkJson<BankAccountTransactionsResponse> {
    386                     val tx = it.transactions.first()
    387                     assertEquals(subject, tx.subject)
    388                     assertEquals(converted, tx.amount)
    389                 }
    390                 client.getA("/accounts/exchange/taler-wire-gateway/history/incoming").assertOkJson<IncomingHistory> {
    391                     val tx = it.incoming_transactions.first()
    392                     assertEquals(converted, tx.amount)
    393                     assertIs<IncomingReserveTransaction>(tx)
    394                     assertEquals(reservePub, tx.reserve_pub)
    395                 }
    396 
    397                 // Bounce
    398                 client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
    399                     json {
    400                         "request_uid" to HashCode.rand()
    401                         "amount" to converted
    402                         "exchange_base_url" to "http://exchange.example.com/"
    403                         "wtid" to reservePub
    404                         "credit_account" to "payto://x-taler-bank/localhost/admin"
    405                     }
    406                 }.assertOkJson<TransferResponse>()
    407                 
    408                 db.checkInitiated(amount, "Sir Christian")
    409             }
    410             
    411             // Exchange bounce with name
    412             repeat(3) { i ->
    413                 val reservePub = EddsaPublicKey.randEdsaKey()
    414                 val amount = TalerAmount("EUR:${40+i}")
    415                 val subject = "exchange bounce test $i: $reservePub"
    416 
    417                 // Cashin
    418                 nexusCmd.run("testing fake-incoming $flags --subject \"$subject\" --amount $amount $userPayTo")
    419                 val converted = client.get("/conversion-info/cashin-rate?amount_debit=EUR:${40 + i}")
    420                     .assertOkJson<ConversionResponse>().amount_credit
    421                 client.getA("/accounts/exchange/transactions").assertOkJson<BankAccountTransactionsResponse> {
    422                     val tx = it.transactions.first()
    423                     assertEquals(subject, tx.subject)
    424                     assertEquals(converted, tx.amount)
    425                 }
    426                 client.getA("/accounts/exchange/taler-wire-gateway/history/incoming").assertOkJson<IncomingHistory> {
    427                     val tx = it.incoming_transactions.first()
    428                     assertEquals(converted, tx.amount)
    429                     assertIs<IncomingReserveTransaction>(tx)
    430                     assertEquals(reservePub, tx.reserve_pub)
    431                 }
    432 
    433                 // Bounce
    434                 client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
    435                     json {
    436                         "request_uid" to HashCode.rand()
    437                         "amount" to converted
    438                         "exchange_base_url" to "http://exchange.example.com/"
    439                         "wtid" to reservePub
    440                         "credit_account" to "payto://x-taler-bank/localhost/admin"
    441                     }
    442                 }.assertOkJson<TransferResponse>()
    443                 
    444                 db.checkInitiated(amount, "Sir Christian")
    445             }
    446         }
    447     }
    448 }