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 }