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 }